diff --git a/packages/alphatab/src/AlphaTabApiBase.ts b/packages/alphatab/src/AlphaTabApiBase.ts index 4f6b9f95c..b22f13f1b 100644 --- a/packages/alphatab/src/AlphaTabApiBase.ts +++ b/packages/alphatab/src/AlphaTabApiBase.ts @@ -47,10 +47,11 @@ 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 { PlayerMode, ScrollMode } from '@coderline/alphatab/PlayerSettings'; +import { ResizeEventArgs } from '@coderline/alphatab/ResizeEventArgs'; import { BeatContainerGlyph } from '@coderline/alphatab/rendering/glyphs/BeatContainerGlyph'; import type { IScoreRenderer } from '@coderline/alphatab/rendering/IScoreRenderer'; import type { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/RenderFinishedEventArgs'; @@ -61,7 +62,15 @@ 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, + HorizontalSmoothScrollHandler, + type IScrollHandler, + VerticalContinuousScrollHandler, + VerticalOffScreenScrollHandler, + VerticalSmoothScrollHandler +} from '@coderline/alphatab/ScrollHandlers'; import type { Settings } from '@coderline/alphatab/Settings'; import { ActiveBeatsChangedEventArgs } from '@coderline/alphatab/synth/ActiveBeatsChangedEventArgs'; import { AlphaSynthWrapper } from '@coderline/alphatab/synth/AlphaSynthWrapper'; @@ -156,6 +165,8 @@ export class AlphaTabApiBase { private _player!: AlphaSynthWrapper; private _renderer: ScoreRendererWrapper; + private _defaultScrollHandler?: IScrollHandler; + /** * An indicator by how many midi-ticks the song contents are shifted. * Grace beats at start might require a shift for the first beat to start at 0. @@ -976,6 +987,43 @@ export class AlphaTabApiBase { private _tickCache: MidiTickLookup | null = null; + /** + * A custom scroll handler which will be used to handle scrolling operations during playback. + * + * @category Properties - Player + * @since 1.8.0 + * @example + * JavaScript + * ```js + * const api = new alphaTab.AlphaTabApi(document.querySelector('#alphaTab')); + * api.customScrollHandler = { + * forceScrollTo(currentBeatBounds) { + * const scroll = api.uiFacade.getScrollElement(); + * api.uiFacade.scrollToY(scroll, currentBeatBounds.barBounds.masterBarBounds.realBounds.y, 0); + * }, + * onBeatCursorUpdating(startBeat, endBeat, cursorMode, relativePosition, actualBeatCursorStartX, actualBeatCursorEndX, actualBeatCursorTransitionDuration) { + * const scroll = api.uiFacade.getScrollElement(); + * api.uiFacade.scrollToY(scroll, startBeat.barBounds.masterBarBounds.realBounds.y, 0); + * } + * } + * ``` + * + * @example + * C# + * ```cs + * var api = new AlphaTabApi(...); + * api.CustomScrollHandler = new CustomScrollHandler(); + * ``` + * + * @example + * Android + * ```kotlin + * val api = AlphaTabApi(...) + * api.customScrollHandler = CustomScrollHandler(); + * ``` + */ + public customScrollHandler?: IScrollHandler; + /** * The tick cache allowing lookup of midi ticks to beats. * @remarks @@ -2014,7 +2062,6 @@ export class AlphaTabApiBase { private _isInitialBeatCursorUpdate = true; private _previousStateForCursor: PlayerState = PlayerState.Paused; private _previousCursorCache: BoundsLookup | null = null; - private _lastScroll: number = 0; private _destroyCursors(): void { if (!this._cursorWrapper) { @@ -2027,28 +2074,83 @@ export class AlphaTabApiBase { this._selectionWrapper = null; } + private _createCursors() { + if (this._cursorWrapper) { + return; + } + const cursors = this.uiFacade.createCursors(); + if (cursors) { + // store options and created elements for fast access + this._cursorWrapper = cursors.cursorWrapper; + this._barCursor = cursors.barCursor; + this._beatCursor = cursors.beatCursor; + this._selectionWrapper = cursors.selectionWrapper; + this._isInitialBeatCursorUpdate = true; + } + if (this._currentBeat !== null) { + this._cursorUpdateBeat(this._currentBeat!, false, this._previousTick > 10, 1, true); + } + } + private _updateCursors() { + this._updateScrollHandler(); + const enable = this._hasCursor; - if (enable && !this._cursorWrapper) { - // - // Create cursors - const cursors = this.uiFacade.createCursors(); - if (cursors) { - // store options and created elements for fast access - this._cursorWrapper = cursors.cursorWrapper; - this._barCursor = cursors.barCursor; - this._beatCursor = cursors.beatCursor; - this._selectionWrapper = cursors.selectionWrapper; - this._isInitialBeatCursorUpdate = true; - } - if (this._currentBeat !== null) { - this._cursorUpdateBeat(this._currentBeat!, false, this._previousTick > 10, 1, true); - } + if (enable) { + this._createCursors(); } else if (!enable && this._cursorWrapper) { this._destroyCursors(); } } + private _scrollHandlerMode = ScrollMode.Off; + private _scrollHandlerVertical = true; + private _updateScrollHandler() { + const currentHandler = this._defaultScrollHandler; + const scrollMode = this.settings.player.scrollMode; + const isVertical = Environment.getLayoutEngineFactory(this.settings.display.layoutMode).vertical; + + // no change + if (this._scrollHandlerMode === scrollMode && this._scrollHandlerVertical === isVertical) { + return; + } + + // destroy current handler in favor of new one + if (currentHandler) { + currentHandler[Symbol.dispose](); + const scroll = this.uiFacade.getScrollContainer(); + this.uiFacade.stopScrolling(scroll); + } + + switch (scrollMode) { + case ScrollMode.Off: + this._defaultScrollHandler = undefined; + break; + case ScrollMode.Continuous: + if (isVertical) { + this._defaultScrollHandler = new VerticalContinuousScrollHandler(this); + } else { + this._defaultScrollHandler = new HorizontalContinuousScrollHandler(this); + } + break; + case ScrollMode.OffScreen: + if (isVertical) { + this._defaultScrollHandler = new VerticalOffScreenScrollHandler(this); + } else { + this._defaultScrollHandler = new HorizontalOffScreenScrollHandler(this); + } + break; + + case ScrollMode.Smooth: + if (isVertical) { + this._defaultScrollHandler = new VerticalSmoothScrollHandler(this); + } else { + this._defaultScrollHandler = new HorizontalSmoothScrollHandler(this); + } + break; + } + } + /** * updates the cursors to highlight the beat at the specified tick position * @param tick @@ -2153,69 +2255,9 @@ export class AlphaTabApiBase { public scrollToCursor() { const beatBounds = this._currentBeatBounds; if (beatBounds) { - this._internalScrollToCursor(beatBounds.barBounds.masterBarBounds); - } - } - - private _internalScrollToCursor(barBoundings: MasterBarBounds) { - const scrollElement: IContainer = this.uiFacade.getScrollContainer(); - const isVertical: boolean = Environment.getLayoutEngineFactory(this.settings.display.layoutMode).vertical; - const mode: ScrollMode = this.settings.player.scrollMode; - if (isVertical) { - // when scrolling on the y-axis, we preliminary check if the new beat/bar have - // moved on the y-axis - const y: number = barBoundings.realBounds.y + this.settings.player.scrollOffsetY; - if (y !== this._lastScroll) { - this._lastScroll = y; - switch (mode) { - case ScrollMode.Continuous: - const elementOffset: Bounds = this.uiFacade.getOffset(scrollElement, this.container); - this.uiFacade.scrollToY(scrollElement, elementOffset.y + y, this.settings.player.scrollSpeed); - break; - case ScrollMode.OffScreen: - const elementBottom: number = - scrollElement.scrollTop + this.uiFacade.getOffset(null, scrollElement).h; - if ( - barBoundings.visualBounds.y + barBoundings.visualBounds.h >= elementBottom || - barBoundings.visualBounds.y < scrollElement.scrollTop - ) { - const scrollTop: number = barBoundings.realBounds.y + this.settings.player.scrollOffsetY; - this.uiFacade.scrollToY(scrollElement, scrollTop, this.settings.player.scrollSpeed); - } - break; - } - } - } else { - // when scrolling on the x-axis, we preliminary check if the new bar has - // moved on the x-axis - const x: number = barBoundings.visualBounds.x; - if (x !== this._lastScroll) { - this._lastScroll = x; - switch (mode) { - case ScrollMode.Continuous: - const scrollLeftContinuous: number = - barBoundings.realBounds.x + this.settings.player.scrollOffsetX; - this._lastScroll = barBoundings.visualBounds.x; - this.uiFacade.scrollToX(scrollElement, scrollLeftContinuous, this.settings.player.scrollSpeed); - break; - case ScrollMode.OffScreen: - const elementRight: number = - scrollElement.scrollLeft + this.uiFacade.getOffset(null, scrollElement).w; - if ( - barBoundings.visualBounds.x + barBoundings.visualBounds.w >= elementRight || - barBoundings.visualBounds.x < scrollElement.scrollLeft - ) { - const scrollLeftOffScreen: number = - barBoundings.realBounds.x + this.settings.player.scrollOffsetX; - this._lastScroll = barBoundings.visualBounds.x; - this.uiFacade.scrollToX( - scrollElement, - scrollLeftOffScreen, - this.settings.player.scrollSpeed - ); - } - break; - } + const handler = this.customScrollHandler ?? this._defaultScrollHandler; + if (handler) { + handler.forceScrollTo(beatBounds); } } } @@ -2248,11 +2290,12 @@ export class AlphaTabApiBase { const isPlayingUpdate = this._player.state === PlayerState.Playing && !stop; let nextBeatX: number = barBoundings.visualBounds.x + barBoundings.visualBounds.w; + let nextBeatBoundings: BeatBounds | null = null; // get position of next beat on same system 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. - const nextBeatBoundings: BeatBounds | null = cache.findBeat(nextBeat); + nextBeatBoundings = cache.findBeat(nextBeat); if ( nextBeatBoundings && nextBeatBoundings.barBounds.masterBarBounds.staffSystemBounds === barBoundings.staffSystemBounds @@ -2286,23 +2329,28 @@ export class AlphaTabApiBase { 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(() => { - // 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; - const doubleEndBeatX = startBeatX + (nextBeatX - startBeatX) * factor; - beatCursor!.transitionToX((duration / cursorSpeed) * factor, doubleEndBeatX); + beatCursor!.transitionToX(duration, nextBeatX); }); } else { - beatCursor.transitionToX(0, startBeatX); + duration = 0; + beatCursor.transitionToX(duration, nextBeatX); beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h); } } else { // ticking cursor - beatCursor.transitionToX(0, startBeatX); + duration = 0; + nextBeatX = startBeatX; + beatCursor.transitionToX(duration, nextBeatX); beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h); } @@ -2329,7 +2377,15 @@ export class AlphaTabApiBase { } if (shouldScroll && !this._isBeatMouseDown && this.settings.player.scrollMode !== ScrollMode.Off) { - this._internalScrollToCursor(barBoundings); + const handler = this.customScrollHandler ?? this._defaultScrollHandler; + if (handler) { + handler.onBeatCursorUpdating(beatBoundings, nextBeatBoundings === null ? undefined : nextBeatBoundings, + cursorMode, + startBeatX, + nextBeatX, + duration + ); + } } // trigger an event for others to indicate which beat/bar is played @@ -3809,6 +3865,7 @@ export class AlphaTabApiBase { const tickCache = this._tickCache; if (currentBeat && tickCache) { this._player.tickPosition = tickCache.getBeatStart(currentBeat.beat); + this.scrollToCursor(); } } diff --git a/packages/alphatab/src/PlayerSettings.ts b/packages/alphatab/src/PlayerSettings.ts index 9e8b9d782..0e8fc9413 100644 --- a/packages/alphatab/src/PlayerSettings.ts +++ b/packages/alphatab/src/PlayerSettings.ts @@ -14,7 +14,13 @@ export enum ScrollMode { /** * Scrolling happens as soon the cursors exceed the displayed range. */ - OffScreen = 2 + OffScreen = 2, + /** + * Scrolling happens constantly in a smooth fashion. + * This will disable the use of any native scroll optimizations but + * manually scroll the scroll container in the required speed. + */ + Smooth = 3 } /** @@ -219,25 +225,25 @@ export class PlayerSettings { * @category Player * @remarks * This setting configures whether the player feature is enabled or not. Depending on the platform enabling the player needs some additional actions of the developer. - * + * * **Synthesizer** - * + * * If the synthesizer is used (via {@link PlayerMode.EnabledAutomatic} or {@link PlayerMode.EnabledSynthesizer}) a sound font is needed so that the midi synthesizer can produce the audio samples. - * + * * For the JavaScript version the [player.soundFont](/docs/reference/settings/player/soundfont) property must be set to the URL of the sound font that should be used or it must be loaded manually via API. * For .net manually the soundfont must be loaded. - * + * * **Backing Track** - * - * For a built-in backing track of the input file no additional data needs to be loaded (assuming everything is filled via the input file). + * + * For a built-in backing track of the input file no additional data needs to be loaded (assuming everything is filled via the input file). * Otherwise the `score.backingTrack` needs to be filled before loading and the related sync points need to be configured. - * + * * **External Media** - * + * * For synchronizing alphaTab with an external media no data needs to be loaded into alphaTab. The configured sync points on the MasterBars are used * as reference to synchronize the external media with the internal time axis. Then the related APIs on the AlphaTabApi object need to be used * to update the playback state and exterrnal audio position during playback. - * + * * **User Interface** * * AlphaTab does not ship a default UI for the player. The API must be hooked up to some UI controls to allow the user to interact with the player. diff --git a/packages/alphatab/src/ScrollHandlers.ts b/packages/alphatab/src/ScrollHandlers.ts new file mode 100644 index 000000000..e958193c7 --- /dev/null +++ b/packages/alphatab/src/ScrollHandlers.ts @@ -0,0 +1,397 @@ +import type { AlphaTabApiBase } from '@coderline/alphatab/AlphaTabApiBase'; +import type { MidiTickLookupFindBeatResultCursorMode } from '@coderline/alphatab/midi/MidiTickLookup'; +import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; +import { ScrollMode } from '@coderline/alphatab/PlayerSettings'; +import type { BeatBounds, MasterBarBounds } from '@coderline/alphatab/rendering/_barrel'; + +/** + * Classes implementing this interface can handle the scroll logic + * as the playback in alphaTab progresses. + * + * + * @public + */ +export interface IScrollHandler extends Disposable { + /** + * Requests a instant scrolling to the specified beat. + * @param currentBeatBounds The bounds and information about the current beat. + */ + forceScrollTo(currentBeatBounds: BeatBounds): void; + + /** + * Updates whenever the currently beat cursor is updating its start and end location + * from which it starts and animates to. + * @remarks + * This method is tightly coupled to how alphaTab internally handles the beat cursor display. + * alphaTab looks up the current and next beat to which the beat cursor needs to transition + * in a specific amount of time. + * + * In some occations the cursor will transition to the end of the bar instead of the next beat. + * + * @param startBeat the information about the beat where the cursor is starting its animation. + * @param endBeat the information about the beat where the cursor is ending its animation. + * @param cursorMode how the cursor is transitioning (e.g. to end of bar or to the location of the next beat) + * @param actualBeatCursorStartX the exact start position of the beat cursor animation. + * Depending on the exact time of the player, this position might be relatively adjusted. + * @param actualBeatCursorEndX the exact end position of the beat cursor animation. + * Depending on the exact time of the player and cursor mode, + * this might be beyond the expected bounds. + * To ensure a smooth cursor experience (no jumping/flicking back and forth), alphaTab + * optimizes the used end position and animation durations. + * @param actualBeatCursorTransitionDuration The duration of the beat cursor transition in milliseconds. + * Similar to the start and end positions, this duration is adjusted accordingly to ensure + * that the beat cursor remains smoothly at the expected position for the currently played time. + * + */ + onBeatCursorUpdating( + startBeat: BeatBounds, + endBeat: BeatBounds | undefined, + cursorMode: MidiTickLookupFindBeatResultCursorMode, + actualBeatCursorStartX: number, + actualBeatCursorEndX: number, + actualBeatCursorTransitionDuration: number + ): void; +} + +/** + * Some basic scroll handler checking for changed offsets and scroll if changed. + * @internal + */ +export abstract class BasicScrollHandler implements IScrollHandler { + protected api: AlphaTabApiBase; + protected lastScroll = -1; + + public constructor(api: AlphaTabApiBase) { + this.api = api; + } + + [Symbol.dispose]() { + // do nothing + } + + public forceScrollTo(currentBeatBounds: BeatBounds): void { + this._scrollToBeat(currentBeatBounds, true); + this.lastScroll = -1; // force new scroll on next update + } + + private _scrollToBeat(currentBeatBounds: BeatBounds, force: boolean) { + const newLastScroll = this.calculateLastScroll(currentBeatBounds); + // no change, and no instant/force scroll + if (newLastScroll === this.lastScroll && !force) { + return; + } + this.lastScroll = newLastScroll; + + this.doScroll(currentBeatBounds); + } + + protected abstract calculateLastScroll(currentBeatBounds: BeatBounds): number; + protected abstract doScroll(currentBeatBounds: BeatBounds): void; + + public onBeatCursorUpdating( + startBeat: BeatBounds, + _endBeat: BeatBounds|undefined, + _cursorMode: MidiTickLookupFindBeatResultCursorMode, + _actualBeatCursorStartX: number, + _actualBeatCursorEndX: number, + _actualBeatCursorTransitionDuration: number + ): void { + this._scrollToBeat(startBeat, false); + } +} + +/** + * This is the default scroll handler for vertical layouts using {@link ScrollMode.Continuous}. + * Whenever the system changes, we scroll to the new system position vertically. + * @internal + */ +export class VerticalContinuousScrollHandler extends BasicScrollHandler { + protected override calculateLastScroll(currentBeatBounds: BeatBounds): number { + return currentBeatBounds.barBounds.masterBarBounds.realBounds.y; + } + + protected override doScroll(currentBeatBounds: BeatBounds): void { + const ui = this.api.uiFacade; + const settings = this.api.settings; + + const scroll = ui.getScrollContainer(); + const elementOffset = ui.getOffset(scroll, this.api.container); + const y = currentBeatBounds.barBounds.masterBarBounds.realBounds.y + settings.player.scrollOffsetY; + ui.scrollToY(scroll, elementOffset.y + y, this.api.settings.player.scrollSpeed); + } +} + +/** + * This is the default scroll handler for vertical layouts using {@link ScrollMode.OffScreen}. + * Whenever the system changes, we check if the new system bounds are out-of-screen and if yes, we scroll. + * @internal + */ +export class VerticalOffScreenScrollHandler extends BasicScrollHandler { + protected override calculateLastScroll(currentBeatBounds: BeatBounds): number { + // check for system change + return currentBeatBounds.barBounds.masterBarBounds.realBounds.y; + } + + protected override doScroll(currentBeatBounds: BeatBounds): void { + const ui = this.api.uiFacade; + const settings = this.api.settings; + + const scroll = ui.getScrollContainer(); + const elementBottom: number = scroll.scrollTop + ui.getOffset(null, scroll).h; + const barBoundings = currentBeatBounds.barBounds.masterBarBounds; + if ( + barBoundings.visualBounds.y + barBoundings.visualBounds.h >= elementBottom || + barBoundings.visualBounds.y < scroll.scrollTop + ) { + const scrollTop: number = barBoundings.realBounds.y + settings.player.scrollOffsetY; + ui.scrollToY(scroll, scrollTop, settings.player.scrollSpeed); + } + } +} + +/** + * This is the default scroll handler for vertical layouts using {@link ScrollMode.Smooth}. + * vertical smooth scrolling aims to place the on-time position + * at scrollOffsetY **at the time when a system starts** + * this means when a system starts, it is at scrollOffsetY, + * then gradually scrolls down the system height reaching the bottom + * when the system completes. + * @internal + */ +export class VerticalSmoothScrollHandler implements IScrollHandler { + private _api: AlphaTabApiBase; + private _lastScroll = -1; + private _scrollContainerResizeUnregister: () => void; + + public constructor(api: AlphaTabApiBase) { + this._api = api; + // we need a resize listener for the overflow calculation + this._scrollContainerResizeUnregister = api.uiFacade.getScrollContainer().resize.on(() => { + const scrollContainer = api.uiFacade.getScrollContainer(); + + const overflowNeeded = api.settings.player.scrollOffsetX; + const viewPortSize = scrollContainer.width; + + // the content needs to shift out of screen (and back into screen with the offset) + // that's why we need the whole width as additional overflow + const overflowNeededAbsolute = viewPortSize + overflowNeeded; + + api.uiFacade.setCanvasOverflow(api.canvasElement, overflowNeededAbsolute, true); + }); + } + + [Symbol.dispose]() { + this._api.uiFacade.setCanvasOverflow(this._api.canvasElement, 0, true); + this._scrollContainerResizeUnregister(); + } + + public forceScrollTo(currentBeatBounds: BeatBounds): void { + const ui = this._api.uiFacade; + const settings = this._api.settings; + + const scroll = ui.getScrollContainer(); + const systemTop: number = + currentBeatBounds.barBounds.masterBarBounds.realBounds.y + settings.player.scrollOffsetY; + + ui.scrollToY(scroll, systemTop, 0); + this._lastScroll = -1; + } + + public onBeatCursorUpdating( + startBeat: BeatBounds, + _endBeat: BeatBounds | undefined, + _cursorMode: MidiTickLookupFindBeatResultCursorMode, + _actualBeatCursorStartX: number, + _actualBeatCursorEndX: number, + actualBeatCursorTransitionDuration: number + ): void { + const ui = this._api.uiFacade; + const settings = this._api.settings; + + const barBoundings = startBeat.barBounds.masterBarBounds; + const systemTop: number = barBoundings.realBounds.y + settings.player.scrollOffsetY; + if (systemTop === this._lastScroll && actualBeatCursorTransitionDuration > 0) { + return; + } + + // jump to start of new system + const scroll = ui.getScrollContainer(); + ui.scrollToY(scroll, systemTop, 0); + + // instant scroll + if (actualBeatCursorTransitionDuration === 0) { + this._lastScroll = -1; + return; + } + + // dynamic scrolling + this._lastScroll = systemTop; + // scroll to bottom over time + const systemBottom = systemTop + barBoundings.realBounds.h; + + // NOTE: this calculation is a bit more expensive, but we only do it once per system + // so we should be good: + // * the more bars we have, the longer the system will play, hence the duration can take a bit longer + // * if we have less bars, we calculate more often, but the calculation will be faster because we sum up less bars. + const systemDuration = this._calculateSystemDuration(barBoundings); + ui.scrollToY(scroll, systemBottom, systemDuration); + } + + private _calculateSystemDuration(barBoundings: MasterBarBounds) { + const systemBars = barBoundings.staffSystemBounds!.bars; + const tickCache = this._api.tickCache!; + + let duration = 0; + + const masterBars = this._api.score!.masterBars; + for (const bar of systemBars) { + const mb = masterBars[bar.index]; + + const mbInfo = tickCache.getMasterBar(mb); + const tempoChanges = tickCache.getMasterBar(mb).tempoChanges; + + let tempo = tempoChanges[0].tempo; + let tick = tempoChanges[0].tick; + for (let i = 1; i < tempoChanges.length; i++) { + const diff = tempoChanges[i].tick - tick; + duration += MidiUtils.ticksToMillis(diff, tempo); + tempo = tempoChanges[i].tempo; + tick = tempoChanges[i].tick; + } + + const toEnd = mbInfo.end - tick; + duration += MidiUtils.ticksToMillis(toEnd, tempo); + } + + return duration; + } +} + +/** + * This is the default scroll handler for horizontal layouts using {@link ScrollMode.Continuous}. + * Whenever the master bar changes, we scroll to the position horizontally. + * @internal + */ +export class HorizontalContinuousScrollHandler extends BasicScrollHandler { + protected override calculateLastScroll(currentBeatBounds: BeatBounds): number { + return currentBeatBounds.barBounds.masterBarBounds.visualBounds.x; + } + + protected override doScroll(currentBeatBounds: BeatBounds): void { + const ui = this.api.uiFacade; + const settings = this.api.settings; + const scroll = ui.getScrollContainer(); + + const barBoundings = currentBeatBounds.barBounds.masterBarBounds; + const scrollLeftContinuous: number = barBoundings.realBounds.x + settings.player.scrollOffsetX; + ui.scrollToX(scroll, scrollLeftContinuous, settings.player.scrollSpeed); + } +} + +/** + * This is the default scroll handler for horizontal layouts using {@link ScrollMode.OffScreen}. + * Whenever the system changes, we check if the new system bounds are out-of-screen and if yes, we scroll. + * @internal + */ +export class HorizontalOffScreenScrollHandler extends BasicScrollHandler { + protected override calculateLastScroll(currentBeatBounds: BeatBounds): number { + return currentBeatBounds.barBounds.masterBarBounds.visualBounds.x; + } + + protected override doScroll(currentBeatBounds: BeatBounds): void { + const ui = this.api.uiFacade; + const settings = this.api.settings; + const scroll = ui.getScrollContainer(); + + const elementRight: number = scroll.scrollLeft + ui.getOffset(null, scroll).w; + const barBoundings = currentBeatBounds.barBounds.masterBarBounds; + if ( + barBoundings.visualBounds.x + barBoundings.visualBounds.w >= elementRight || + barBoundings.visualBounds.x < scroll.scrollLeft + ) { + const scrollLeftOffScreen: number = barBoundings.realBounds.x + settings.player.scrollOffsetX; + ui.scrollToX(scroll, scrollLeftOffScreen, settings.player.scrollSpeed); + } + } +} + +/** + * This is the default scroll handler for horizontal layouts using {@link ScrollMode.Smooth}. + * horiontal smooth scrolling aims to place the on-time position + * at scrollOffsetX from a beat-to-beat perspective. + * This achieves an steady cursor at the same position with rather the music sheet scrolling past it. + * Due to some animation inconsistencies (e.g. CSS animation vs scrolling) there might be a slight + * flickering of the cursor. + * + * To get a fully steady cursor the beat cursor can simply be visually hidden and a cursor can be placed at + * `scrollOffsetX` by the integrator. + * @internal + */ +export class HorizontalSmoothScrollHandler implements IScrollHandler { + private _api: AlphaTabApiBase; + private _lastScroll = -1; + private _scrollContainerResizeUnregister: () => void; + + public constructor(api: AlphaTabApiBase) { + this._api = api; + // we need a resize listener for the overflow calculation + this._scrollContainerResizeUnregister = api.uiFacade.getScrollContainer().resize.on(() => { + const scrollContainer = api.uiFacade.getScrollContainer(); + + const overflowNeeded = api.settings.player.scrollOffsetX; + const viewPortSize = scrollContainer.width; + + // the content needs to shift out of screen (and back into screen with the offset) + // that's why we need the whole width as additional overflow + const overflowNeededAbsolute = viewPortSize + overflowNeeded; + + api.uiFacade.setCanvasOverflow(api.canvasElement, overflowNeededAbsolute, false); + }); + } + + [Symbol.dispose]() { + this._scrollContainerResizeUnregister(); + this._api.uiFacade.setCanvasOverflow(this._api.canvasElement, 0, false); + } + + public forceScrollTo(currentBeatBounds: BeatBounds): void { + const ui = this._api.uiFacade; + const settings = this._api.settings; + + const scroll = ui.getScrollContainer(); + const barStartX: number = currentBeatBounds.onNotesX + settings.player.scrollOffsetY; + + ui.scrollToY(scroll, barStartX, 0); + this._lastScroll = -1; + } + + public onBeatCursorUpdating( + _startBeat: BeatBounds, + _endBeat: BeatBounds | undefined, + _cursorMode: MidiTickLookupFindBeatResultCursorMode, + actualBeatCursorStartX: number, + actualBeatCursorEndX: number, + actualBeatCursorTransitionDuration: number + ): void { + const ui = this._api.uiFacade; + + if (actualBeatCursorEndX === this._lastScroll && actualBeatCursorTransitionDuration > 0) { + return; + } + + // jump to start of new system + const settings = this._api.settings; + const scroll = ui.getScrollContainer(); + ui.scrollToX(scroll, actualBeatCursorStartX + settings.player.scrollOffsetX, 0); + + // instant scroll + if (actualBeatCursorTransitionDuration === 0) { + this._lastScroll = -1; + return; + } + + this._lastScroll = actualBeatCursorEndX; + const scrollX = actualBeatCursorEndX + settings.player.scrollOffsetX; + ui.scrollToX(scroll, scrollX, actualBeatCursorTransitionDuration); + } +} diff --git a/packages/alphatab/src/alphaTab.core.ts b/packages/alphatab/src/alphaTab.core.ts index 86cc284e1..12f421550 100644 --- a/packages/alphatab/src/alphaTab.core.ts +++ b/packages/alphatab/src/alphaTab.core.ts @@ -40,6 +40,7 @@ export type { IEventEmitter, IEventEmitterOfT } from '@coderline/alphatab/EventE export { AlphaTabApi } from '@coderline/alphatab/platform/javascript/AlphaTabApi'; export { AlphaTabApiBase, type PlaybackHighlightChangeEventArgs } from '@coderline/alphatab/AlphaTabApiBase'; +export type { IScrollHandler } from '@coderline/alphatab/ScrollHandlers'; export { WebPlatform } from '@coderline/alphatab/platform/javascript/WebPlatform'; export { VersionInfo as meta } from '@coderline/alphatab/generated/VersionInfo'; diff --git a/packages/alphatab/src/platform/IUiFacade.ts b/packages/alphatab/src/platform/IUiFacade.ts index 57394a251..a0772ea55 100644 --- a/packages/alphatab/src/platform/IUiFacade.ts +++ b/packages/alphatab/src/platform/IUiFacade.ts @@ -175,6 +175,12 @@ export interface IUiFacade { */ scrollToX(scrollElement: IContainer, offset: number, speed: number): void; + /** + * Stops any ongoing scrolling of the given element. + * @param scrollElement The element which might be scrolling dynamically. + */ + stopScrolling(scrollElement: IContainer): void; + /** * Attempts a load of the score represented by the given data object. * @param data The data object to decode @@ -192,6 +198,18 @@ export interface IUiFacade { */ loadSoundFont(data: unknown, append: boolean): boolean; + /** + * Updates the overflows needed to ensure the smooth scrolling + * can reach the "end" at the desired position. + * @param canvasElement The canvas element. + * @param offset The offset we need + * @param isVertical Whether we have a vertical or horizontal overflow + * @remarks + * Without these overflows we might not have enough scroll space + * and we cannot reach a "sticky cursor" behavior. + */ + setCanvasOverflow(canvasElement:IContainer, overflow: number, isVertical: boolean): void; + /** * This events is fired when the {@link canRender} property changes. */ diff --git a/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts b/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts index 11b600a55..ffdb9a60f 100644 --- a/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts +++ b/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts @@ -29,7 +29,7 @@ import { WebPlatform } from '@coderline/alphatab/platform/javascript/WebPlatform import { AlphaTabError, AlphaTabErrorType } from '@coderline/alphatab/AlphaTabError'; import { AlphaSynthAudioWorkletOutput } from '@coderline/alphatab/platform/javascript/AlphaSynthAudioWorkletOutput'; import { ScalableHtmlElementContainer } from '@coderline/alphatab/platform/javascript/ScalableHtmlElementContainer'; -import { PlayerOutputMode } from '@coderline/alphatab/PlayerSettings'; +import { PlayerOutputMode, ScrollMode } from '@coderline/alphatab/PlayerSettings'; import type { SettingsJson } from '@coderline/alphatab/generated/SettingsJson'; import { AudioElementBackingTrackSynthOutput } from '@coderline/alphatab/platform/javascript/AudioElementBackingTrackSynthOutput'; import { BackingTrackPlayer } from '@coderline/alphatab/synth/BackingTrackPlayer'; @@ -252,6 +252,21 @@ export class BrowserUiFacade implements IUiFacade { return new HtmlElementContainer(canvasElement); } + public setCanvasOverflow(canvasElement: IContainer, overflow: number, isVertical: boolean): void { + const html = (canvasElement as HtmlElementContainer).element; + if (overflow === 0) { + html.style.boxSizing = ''; + html.style.paddingRight = ''; + html.style.paddingBottom = ''; + } else if (isVertical) { + html.style.boxSizing = 'content-box'; + html.style.paddingBottom = `${overflow}px`; + } else { + html.style.boxSizing = 'content-box'; + html.style.paddingRight = `${overflow}px`; + } + } + public triggerEvent( container: IContainer, name: string, @@ -878,54 +893,92 @@ export class BrowserUiFacade implements IUiFacade { this._internalScrollToX((element as HtmlElementContainer).element, scrollTargetY, speed); } + public stopScrolling(scrollElement: IContainer): void { + // stop any current animation + const currentAnimation = this._scrollAnimationLookup.get((scrollElement as HtmlElementContainer).element); + if (currentAnimation !== undefined) { + this._activeScrollAnimations.delete(currentAnimation); + } + } + + private get _nativeBrowserSmoothScroll() { + const settings = this._api.settings.player; + return settings.nativeBrowserSmoothScroll && settings.scrollMode !== ScrollMode.Smooth; + } + + private _scrollAnimationId = 0; + private readonly _activeScrollAnimations = new Set(); + private readonly _scrollAnimationLookup = new Map(); + private _internalScrollToY(element: HTMLElement, scrollTargetY: number, speed: number): void { - if (this._api.settings.player.nativeBrowserSmoothScroll) { + if (this._nativeBrowserSmoothScroll) { element.scrollTo({ top: scrollTargetY, behavior: 'smooth' }); } else { - const startY: number = element.scrollTop; - const diff: number = scrollTargetY - startY; + this._internalScrollTo(element, element.scrollTop, scrollTargetY, speed, scroll => { + element.scrollTop = scroll; + }); + } + } - let start: number = 0; - const step = (x: number) => { - if (start === 0) { - start = x; - } - const time: number = x - start; - const percent: number = Math.min(time / speed, 1); - element.scrollTop = (startY + diff * percent) | 0; - if (time < speed) { - window.requestAnimationFrame(step); - } - }; - window.requestAnimationFrame(step); + private _internalScrollTo( + element: HTMLElement, + startScroll: number, + endScroll: number, + scrollDuration: number, + setValue: (scroll: number) => void + ) { + // stop any current animation + const currentAnimation = this._scrollAnimationLookup.get(element); + if (currentAnimation !== undefined) { + this._activeScrollAnimations.delete(currentAnimation); } + + if (scrollDuration === 0) { + setValue(endScroll); + return; + } + + // start new animation + const animationId = this._scrollAnimationId++; + this._scrollAnimationLookup.set(element, animationId); + this._activeScrollAnimations.add(animationId); + + const diff: number = endScroll - startScroll; + + let start: number = 0; + const step = (x: number) => { + if (!this._activeScrollAnimations.has(animationId)) { + return; + } + + if (start === 0) { + start = x; + } + const time = x - start; + const percent = Math.min(time / scrollDuration, 1); + setValue((startScroll + diff * percent) | 0); + if (time < scrollDuration) { + window.requestAnimationFrame(step); + } else { + this._activeScrollAnimations.delete(animationId); + } + }; + window.requestAnimationFrame(step); } private _internalScrollToX(element: HTMLElement, scrollTargetX: number, speed: number): void { - if (this._api.settings.player.nativeBrowserSmoothScroll) { + if (this._nativeBrowserSmoothScroll) { element.scrollTo({ left: scrollTargetX, behavior: 'smooth' }); } else { - const startX: number = element.scrollLeft; - const diff: number = scrollTargetX - startX; - let start: number = 0; - const step = (t: number) => { - if (start === 0) { - start = t; - } - const time: number = t - start; - const percent: number = Math.min(time / speed, 1); - element.scrollLeft = (startX + diff * percent) | 0; - if (time < speed) { - window.requestAnimationFrame(step); - } - }; - window.requestAnimationFrame(step); + this._internalScrollTo(element, element.scrollLeft, scrollTargetX, speed, scroll => { + element.scrollLeft = scroll; + }); } } diff --git a/packages/alphatab/src/rendering/utils/BoundsLookup.ts b/packages/alphatab/src/rendering/utils/BoundsLookup.ts index eea224bf7..83788b4e9 100644 --- a/packages/alphatab/src/rendering/utils/BoundsLookup.ts +++ b/packages/alphatab/src/rendering/utils/BoundsLookup.ts @@ -31,6 +31,7 @@ export class BoundsLookup { mb.visualBounds = this._boundsToJson(masterBar.visualBounds); mb.realBounds = this._boundsToJson(masterBar.realBounds); mb.index = masterBar.index; + mb.isFirstOfLine = masterBar.isFirstOfLine; mb.bars = []; for (const bar of masterBar.bars) { const b: BarBounds = {} as any; @@ -88,7 +89,7 @@ export class BoundsLookup { mb.lineAlignedBounds = BoundsLookup._boundsFromJson(masterBar.lineAlignedBounds); mb.visualBounds = BoundsLookup._boundsFromJson(masterBar.visualBounds); mb.realBounds = BoundsLookup._boundsFromJson(masterBar.realBounds); - sg.addBar(mb); + lookup.addMasterBar(mb); for (const bar of masterBar.bars) { const b: BarBounds = new BarBounds(); b.visualBounds = BoundsLookup._boundsFromJson(bar.visualBounds); diff --git a/packages/alphatab/src/rendering/utils/MasterBarBounds.ts b/packages/alphatab/src/rendering/utils/MasterBarBounds.ts index 38a28c076..b40af1c5e 100644 --- a/packages/alphatab/src/rendering/utils/MasterBarBounds.ts +++ b/packages/alphatab/src/rendering/utils/MasterBarBounds.ts @@ -10,7 +10,7 @@ import type { StaffSystemBounds } from '@coderline/alphatab/rendering/utils/Staf */ export class MasterBarBounds { /** - * Gets or sets the index of this bounds relative within the parent lookup. + * The MasterBar index within the data model represented by these bounds. */ public index: number = 0; diff --git a/packages/alphatab/test/visualTests/TestUiFacade.ts b/packages/alphatab/test/visualTests/TestUiFacade.ts index 7d9950220..6e9efc966 100644 --- a/packages/alphatab/test/visualTests/TestUiFacade.ts +++ b/packages/alphatab/test/visualTests/TestUiFacade.ts @@ -1,5 +1,10 @@ import type { AlphaTabApiBase } from '@coderline/alphatab/AlphaTabApiBase'; -import { EventEmitter, EventEmitterOfT, type IEventEmitter, type IEventEmitterOfT } from '@coderline/alphatab/EventEmitter'; +import { + EventEmitter, + EventEmitterOfT, + type IEventEmitter, + type IEventEmitterOfT +} from '@coderline/alphatab/EventEmitter'; import { Settings } from '@coderline/alphatab/Settings'; import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; import { Score } from '@coderline/alphatab/model/Score'; @@ -128,6 +133,10 @@ export class TestUiFacade implements IUiFacade { this.resizeThrottle = 10; } + public stopScrolling(_scrollElement: IContainer): void {} + + public setCanvasOverflow(_canvasElement: IContainer, _overflow: number, _isVertical: boolean): void {} + public initialize(api: AlphaTabApiBase, raw: unknown): void { this._api = api; let settings: Settings; diff --git a/packages/csharp/src/AlphaTab.Windows/NAudioSynthOutput.cs b/packages/csharp/src/AlphaTab.Windows/NAudioSynthOutput.cs index 568aee037..01a1b1ff4 100644 --- a/packages/csharp/src/AlphaTab.Windows/NAudioSynthOutput.cs +++ b/packages/csharp/src/AlphaTab.Windows/NAudioSynthOutput.cs @@ -96,7 +96,10 @@ public void Play() /// public void Pause() { - _context!.Pause(); + if (_context!.PlaybackState == PlaybackState.Playing) + { + _context!.Pause(); + } } /// diff --git a/packages/csharp/src/AlphaTab.Windows/Themes/Generic.xaml b/packages/csharp/src/AlphaTab.Windows/Themes/Generic.xaml index 4097ad141..8c0ef4717 100644 --- a/packages/csharp/src/AlphaTab.Windows/Themes/Generic.xaml +++ b/packages/csharp/src/AlphaTab.Windows/Themes/Generic.xaml @@ -11,7 +11,9 @@ + Padding="{TemplateBinding Padding}" + VerticalScrollBarVisibility="Auto" + HorizontalScrollBarVisibility="Auto"> diff --git a/packages/csharp/src/AlphaTab.Windows/WinForms/ControlContainer.cs b/packages/csharp/src/AlphaTab.Windows/WinForms/ControlContainer.cs index fba7e4703..338a4da14 100644 --- a/packages/csharp/src/AlphaTab.Windows/WinForms/ControlContainer.cs +++ b/packages/csharp/src/AlphaTab.Windows/WinForms/ControlContainer.cs @@ -1,4 +1,5 @@ -using System.Drawing; +using System; +using System.Drawing; using System.Windows.Forms; using AlphaTab.Platform; @@ -45,14 +46,20 @@ public ControlContainer(Control control) public double Width { get => Control.ClientSize.Width - Control.Padding.Horizontal; - set => Control.Width = (int)value + Control.Padding.Horizontal; + set => Control.BeginInvoke(() => + { + Control.Width = (int)value + Control.Padding.Horizontal; + }); } public double Height { get => Control.ClientSize.Height - Control.Padding.Vertical; - set => Control.Height = (int)value + Control.Padding.Vertical; + set => Control.BeginInvoke(() => + { + Control.Height = (int)value + Control.Padding.Vertical; + }); } public bool IsVisible => Control.Visible && Control.Width > 0; @@ -62,10 +69,14 @@ public double ScrollLeft get => Control is ScrollableControl scroll ? scroll.AutoScrollPosition.X : 0; set { - if (Control is ScrollableControl scroll) + Control.BeginInvoke(() => { - scroll.AutoScrollPosition = new Point((int)value, scroll.AutoScrollPosition.Y); - } + if (Control is ScrollableControl scroll) + { + scroll.AutoScrollPosition = + new Point((int)value, scroll.AutoScrollPosition.Y); + } + }); } } @@ -74,42 +85,44 @@ public double ScrollTop get => Control is ScrollableControl scroll ? scroll.VerticalScroll.Value : 0; set { - if (Control is ScrollableControl scroll) + Control.BeginInvoke(() => { - scroll.AutoScrollPosition = new Point(scroll.AutoScrollPosition.X, (int)value); - } + if (Control is ScrollableControl scroll) + { + scroll.AutoScrollPosition = + new Point(scroll.AutoScrollPosition.X, (int)value); + } + }); } } public void AppendChild(IContainer child) { - Control.Controls.Add(((ControlContainer)child).Control); + Control.BeginInvoke(() => { Control.Controls.Add(((ControlContainer)child).Control); }); } public void StopAnimation() { - //Control.BeginAnimation(Canvas.LeftProperty, null); } public void TransitionToX(double duration, double x) { - // TODO: Animation - Control.Left = (int)x; - - //Control.BeginAnimation(Canvas.LeftProperty, - // new DoubleAnimation(x, new Duration(TimeSpan.FromMilliseconds(duration)))); + Control.BeginInvoke(() => { Control.Left = (int)x; }); } public void Clear() { - Control.Controls.Clear(); + Control.BeginInvoke(() => { Control.Controls.Clear(); }); } public void SetBounds(double x, double y, double w, double h) { - Control.Left = (int)x; - Control.Top = (int)y; - Control.Width = (int)w; - Control.Height = (int)h; + Control.BeginInvoke(() => + { + Control.Left = (int)x; + Control.Top = (int)y; + Control.Width = (int)w; + Control.Height = (int)h; + }); } public IEventEmitter Resize { get; set; } diff --git a/packages/csharp/src/AlphaTab.Windows/WinForms/WinFormsUiFacade.cs b/packages/csharp/src/AlphaTab.Windows/WinForms/WinFormsUiFacade.cs index 9a5427212..eb4bc7def 100644 --- a/packages/csharp/src/AlphaTab.Windows/WinForms/WinFormsUiFacade.cs +++ b/packages/csharp/src/AlphaTab.Windows/WinForms/WinFormsUiFacade.cs @@ -136,7 +136,6 @@ public override void BeginUpdateRenderResults(RenderFinishedEventArgs? r) { SettingsContainer.BeginInvoke((Action)(renderResult => { - if (renderResult == null || !_resultIdToElementLookup.TryGetValue(renderResult.Id, out var placeholder)) { @@ -304,5 +303,24 @@ public override void ScrollToX(IContainer scrollElement, double offset, double s var c = ((ControlContainer)scrollElement).Control; c.AutoScrollOffset = new Point((int)offset, c.AutoScrollOffset.Y); } + + public override void StopScrolling(IContainer scrollElement) + { + // no scrolling animations, hence nothing to do + } + + public override void SetCanvasOverflow(IContainer canvasElement, double overflow, + bool isVertical) + { + var c = ((ControlContainer)canvasElement).Control; + if (!(c is AlphaTabLayoutPanel p)) + { + return; + } + + p.Padding = isVertical + ? new Padding(0, 0, 0, (int)overflow) + : new Padding(0, 0, (int)overflow, 0); + } } } diff --git a/packages/csharp/src/AlphaTab.Windows/Wpf/FrameworkElementContainer.cs b/packages/csharp/src/AlphaTab.Windows/Wpf/FrameworkElementContainer.cs index 668d20777..13af754de 100644 --- a/packages/csharp/src/AlphaTab.Windows/Wpf/FrameworkElementContainer.cs +++ b/packages/csharp/src/AlphaTab.Windows/Wpf/FrameworkElementContainer.cs @@ -46,95 +46,119 @@ public FrameworkElementContainer(FrameworkElement control) public double Width { - get => (float) Control.ActualWidth; - set => Control.Width = value; + get => (float)Control.ActualWidth; + set => Control.Dispatcher.BeginInvoke((Action)(() => { Control.Width = value; })); } public double Height { - get => (float) Control.ActualHeight; - set => Control.Height = value; + get => (float)Control.ActualHeight; + set => Control.Dispatcher.BeginInvoke((Action)(() => { Control.Height = value; })); } public bool IsVisible => Control.IsVisible && Control.ActualWidth > 0; public double ScrollLeft { - get => Control is ScrollViewer scroll ? (float) scroll.HorizontalOffset : 0; + get => Control is ScrollViewer scroll ? (float)scroll.HorizontalOffset : 0; set { - if (Control is ScrollViewer scroll) + Control.Dispatcher.BeginInvoke((Action)(() => { - scroll.ScrollToHorizontalOffset(value); - } + if (Control is ScrollViewer scroll) + { + scroll.ScrollToHorizontalOffset(value); + } + })); } } public double ScrollTop { - get => Control is ScrollViewer scroll ? (float) scroll.VerticalOffset : 0; + get => Control is ScrollViewer scroll ? (float)scroll.VerticalOffset : 0; set { - if (Control is ScrollViewer scroll) + Control.Dispatcher.BeginInvoke((Action)(() => { - scroll.ScrollToVerticalOffset(value); - } + if (Control is ScrollViewer scroll) + { + scroll.ScrollToVerticalOffset(value); + } + })); } } public void AppendChild(IContainer child) { - if (Control is Panel p) - { - p.Children.Add(((FrameworkElementContainer) child).Control); - } - else if (Control is ScrollViewer s && s.Content is ContentControl sc) - { - sc.Content = ((FrameworkElementContainer) child).Control; - } - else if (Control is ScrollViewer ss && ss.Content is Decorator d) - { - d.Child = ((FrameworkElementContainer) child).Control; - } - else if (Control is ScrollViewer sss && sss.Content is Panel pp) - { - pp.Children.Add(((FrameworkElementContainer) child).Control); - } - else if (Control is ContentControl c) + Control.Dispatcher.BeginInvoke((Action)(() => { - c.Content = ((FrameworkElementContainer) child).Control; - } + if (Control is Panel p) + { + p.Children.Add(((FrameworkElementContainer)child).Control); + } + else if (Control is ScrollViewer s && s.Content is ContentControl sc) + { + sc.Content = ((FrameworkElementContainer)child).Control; + } + else if (Control is ScrollViewer ss && ss.Content is Decorator d) + { + d.Child = ((FrameworkElementContainer)child).Control; + } + else if (Control is ScrollViewer sss && sss.Content is Panel pp) + { + pp.Children.Add(((FrameworkElementContainer)child).Control); + } + else if (Control is ContentControl c) + { + c.Content = ((FrameworkElementContainer)child).Control; + } + })); } + private double _targetX = 0; public void StopAnimation() { - Control.BeginAnimation(Canvas.LeftProperty, null); + Control.Dispatcher.BeginInvoke((Action)(() => + { + Control.BeginAnimation(Canvas.LeftProperty, null); + Canvas.SetLeft(Control, _targetX); + })); } public void TransitionToX(double duration, double x) { - Control.BeginAnimation(Canvas.LeftProperty, - new DoubleAnimation(x, new Duration(TimeSpan.FromMilliseconds(duration)))); + _targetX = x; + Control.Dispatcher.BeginInvoke((Action)(() => + { + Control.BeginAnimation(Canvas.LeftProperty, + new DoubleAnimation(x, new Duration(TimeSpan.FromMilliseconds(duration)))); + })); } public void Clear() { - if (Control is Panel p) + Control.Dispatcher.BeginInvoke((Action)(() => { - p.Children.Clear(); - } + if (Control is Panel p) + { + p.Children.Clear(); + } + })); } public void SetBounds(double x, double y, double w, double h) { - Canvas.SetLeft(Control, x); - Canvas.SetTop(Control, y); - Control.Width = w; - Control.Height = h; + Control.Dispatcher.BeginInvoke((Action)(() => + { + Canvas.SetLeft(Control, x); + Canvas.SetTop(Control, y); + Control.Width = w; + Control.Height = h; + })); } public IEventEmitter Resize { get; set; } diff --git a/packages/csharp/src/AlphaTab.Windows/Wpf/WpfUiFacade.cs b/packages/csharp/src/AlphaTab.Windows/Wpf/WpfUiFacade.cs index 6506dd001..ae903ecdd 100644 --- a/packages/csharp/src/AlphaTab.Windows/Wpf/WpfUiFacade.cs +++ b/packages/csharp/src/AlphaTab.Windows/Wpf/WpfUiFacade.cs @@ -6,6 +6,7 @@ using System.Windows.Controls; using System.Windows.Data; using System.Windows.Media; +using System.Windows.Media.Animation; using System.Windows.Shapes; using AlphaTab.Synth; using AlphaTab.Platform; @@ -260,20 +261,22 @@ public override Cursors CreateCursors() IsHitTestVisible = false }; - barCursor.SetBinding(Shape.FillProperty, new Binding(nameof(SettingsContainer.BarCursorFill)) - { - Source = SettingsContainer - }); + barCursor.SetBinding(Shape.FillProperty, + new Binding(nameof(SettingsContainer.BarCursorFill)) + { + Source = SettingsContainer + }); var beatCursor = new Rectangle { IsHitTestVisible = false, Width = 3 }; - beatCursor.SetBinding(Shape.FillProperty, new Binding(nameof(SettingsContainer.BeatCursorFill)) - { - Source = SettingsContainer - }); + beatCursor.SetBinding(Shape.FillProperty, + new Binding(nameof(SettingsContainer.BeatCursorFill)) + { + Source = SettingsContainer + }); cursorWrapper.Children.Add(selectionWrapper); cursorWrapper.Children.Add(barCursor); @@ -351,23 +354,88 @@ public override void ScrollToY(IContainer scrollElement, double offset, double s { if (((FrameworkElementContainer)scrollElement).Control is ScrollViewer s) { - s.ScrollToVerticalOffset(offset); + if (speed < 10) + { + s.ScrollToVerticalOffset(offset); + } + else + { + s.BeginAnimation(ScrollViewerExtension.ScrollYProperty, + new DoubleAnimation(offset, + new Duration(TimeSpan.FromMilliseconds(speed)))); + } } - - //scrollElementWpf.BeginAnimation(ScrollViewer.VerticalOffsetProperty, - // new DoubleAnimation(offset, new System.Windows.Duration(TimeSpan.FromMilliseconds(speed)))); } public override void ScrollToX(IContainer scrollElement, double offset, double speed) { if (((FrameworkElementContainer)scrollElement).Control is ScrollViewer s) { - s.ScrollToHorizontalOffset(offset); + if (speed < 10) + { + s.ScrollToHorizontalOffset(offset); + } + else + { + s.BeginAnimation(ScrollViewerExtension.ScrollXProperty, + new DoubleAnimation(offset, + new Duration(TimeSpan.FromMilliseconds(speed)))); + } + } + } + + public override void StopScrolling(IContainer scrollElement) + { + if (((FrameworkElementContainer)scrollElement).Control is ScrollViewer s) + { + s.BeginAnimation(ScrollViewerExtension.ScrollXProperty, null); + s.BeginAnimation(ScrollViewerExtension.ScrollYProperty, null); } + } - //var scrollElementWpf = ((FrameworkElementContainer)scrollElement).Control; - //scrollElementWpf.BeginAnimation(ScrollViewer.HorizontalOffsetProperty, - // new DoubleAnimation(offset, new System.Windows.Duration(TimeSpan.FromMilliseconds(speed)))); + public override void SetCanvasOverflow(IContainer canvasElement, double overflow, + bool isVertical) + { + if (!(((FrameworkElementContainer)canvasElement).Control is Canvas c)) + { + return; + } + + c.Margin = isVertical + ? new Thickness(0, 0, 0, overflow) + : new Thickness(0, 0, overflow, 0); + } + } + + internal class ScrollViewerExtension + { + public static readonly DependencyProperty ScrollXProperty = + DependencyProperty.RegisterAttached( + "ScrollX", typeof(double), typeof(ScrollViewerExtension), + new PropertyMetadata(0.0, OnScrollXChanged)); + + + public static readonly DependencyProperty ScrollYProperty = + DependencyProperty.RegisterAttached( + "ScrollY", typeof(double), typeof(ScrollViewerExtension), + new PropertyMetadata(0.0, OnScrollYChanged)); + + private static void OnScrollXChanged(DependencyObject d, + DependencyPropertyChangedEventArgs e) + { + if (d is ScrollViewer s) + { + s.ScrollToHorizontalOffset((double)e.NewValue); + } + } + + private static void OnScrollYChanged(DependencyObject d, + DependencyPropertyChangedEventArgs e) + { + if (d is ScrollViewer s) + { + s.ScrollToVerticalOffset((double)e.NewValue); + } } } } diff --git a/packages/csharp/src/AlphaTab/Core/Console.cs b/packages/csharp/src/AlphaTab/Core/Console.cs index 0f4053500..9a81cfd6d 100644 --- a/packages/csharp/src/AlphaTab/Core/Console.cs +++ b/packages/csharp/src/AlphaTab/Core/Console.cs @@ -7,24 +7,24 @@ internal class Console public virtual void Debug(string format, object?[]? details) { var message = details != null ? string.Format(format, details) : format; - Trace.Write(message, "AlphaTab Debug"); + Trace.WriteLine(message, "AlphaTab Debug"); } public virtual void Warn(string format, object?[]? details) { var message = details != null ? string.Format(format, details) : format; - Trace.Write(message, "AlphaTab Warn"); + Trace.WriteLine(message, "AlphaTab Warn"); } public virtual void Info(string format, object?[]? details) { var message = details != null ? string.Format(format, details) : format; - Trace.Write(message, "AlphaTab Info"); + Trace.WriteLine(message, "AlphaTab Info"); } public virtual void Error(string format, object?[]? details) { var message = details != null ? string.Format(format, details) : format; - Trace.Write(message, "AlphaTab Error"); + Trace.WriteLine(message, "AlphaTab Error"); } -} \ No newline at end of file +} diff --git a/packages/csharp/src/AlphaTab/Platform/CSharp/AlphaSynthWorkerApiBase.cs b/packages/csharp/src/AlphaTab/Platform/CSharp/AlphaSynthWorkerApiBase.cs index 798e21e79..f79c64142 100644 --- a/packages/csharp/src/AlphaTab/Platform/CSharp/AlphaSynthWorkerApiBase.cs +++ b/packages/csharp/src/AlphaTab/Platform/CSharp/AlphaSynthWorkerApiBase.cs @@ -12,7 +12,7 @@ internal abstract class AlphaSynthWorkerApiBase : IAlphaSynth private LogLevel _logLevel; private readonly double _bufferTimeInMilliseconds; - public AlphaSynth Player { get; private set; } + public AlphaSynth? Player { get; private set; } protected AlphaSynthWorkerApiBase(ISynthOutput output, LogLevel logLevel, double bufferTimeInMilliseconds) { @@ -45,10 +45,10 @@ protected void Initialize() DispatchOnUiThread(OnReady); } - public bool IsReady => Player.IsReady; - public bool IsReadyForPlayback => Player.IsReadyForPlayback; + public bool IsReady => Player?.IsReady ?? false; + public bool IsReadyForPlayback => Player?.IsReadyForPlayback ?? false; - public PlayerState State => Player == null ? PlayerState.Paused : Player.State; + public PlayerState State => Player?.State ?? PlayerState.Paused; public LogLevel LogLevel { diff --git a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedUiFacade.cs b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedUiFacade.cs index 1d0b1f832..527ee4638 100644 --- a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedUiFacade.cs +++ b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedUiFacade.cs @@ -33,6 +33,10 @@ public virtual void Initialize(AlphaTabApiBase api, TSettings setting TotalResultCount = new ConcurrentQueue(); } + public abstract void StopScrolling(IContainer scrollElement); + public abstract void SetCanvasOverflow(IContainer canvasElement, double overflow, + bool isVertical); + public IScoreRenderer CreateWorkerRenderer() { return new ManagedThreadScoreRenderer(Api.Settings, BeginInvoke); diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidRootViewContainer.kt b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidRootViewContainer.kt index c24168653..a96c9b434 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidRootViewContainer.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidRootViewContainer.kt @@ -3,6 +3,7 @@ package alphaTab.platform.android import alphaTab.* import alphaTab.platform.IContainer import alphaTab.platform.IMouseEventArgs +import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.view.View import android.widget.HorizontalScrollView @@ -15,14 +16,14 @@ import kotlin.contracts.ExperimentalContracts internal class AndroidRootViewContainer : IContainer, View.OnLayoutChangeListener { private val _outerScroll: HorizontalScrollView private val _innerScroll: ScrollView - private val _uiInvoke: ( action: (() -> Unit) ) -> Unit + private val _uiInvoke: (action: (() -> Unit)) -> Unit internal val renderSurface: AlphaTabRenderSurface constructor( outerScroll: HorizontalScrollView, innerScroll: ScrollView, renderSurface: AlphaTabRenderSurface, - uiInvoke: ( action: (() -> Unit) ) -> Unit + uiInvoke: (action: (() -> Unit)) -> Unit ) { _uiInvoke = uiInvoke _innerScroll = innerScroll @@ -101,21 +102,53 @@ internal class AndroidRootViewContainer : IContainer, View.OnLayoutChangeListene } } - fun scrollToX(offset: Double) { + private var _scrollToX: ObjectAnimator? = null + private var _scrollToY: ObjectAnimator? = null + + fun scrollToX(offset: Double, speed: Double) { _uiInvoke { - _outerScroll.smoothScrollTo( - (offset * Environment.highDpiFactor).toInt(), - _outerScroll.scrollY - ) + _scrollToX?.end() + val scrollX = (offset * Environment.highDpiFactor).toInt() + if (speed < 10) { + _outerScroll.scrollX = scrollX + } else { + + val animation = ObjectAnimator.ofInt( + _outerScroll, + "scrollX", + scrollX + ).setDuration(speed.toLong()) + animation.start() + _scrollToX = animation + } + } + } + + fun scrollToY(offset: Double, speed: Double) { + _uiInvoke { + _scrollToY?.end() + val scrollY = (offset * Environment.highDpiFactor).toInt() + if (speed < 10) { + _innerScroll.scrollY = scrollY + } else { + + val animation = ObjectAnimator.ofInt( + _innerScroll, + "scrollY", + scrollY + ).setDuration(speed.toLong()) + animation.start() + _scrollToY = animation + } } } - fun scrollToY(offset: Double) { + fun stopScrolling(){ _uiInvoke { - _innerScroll.smoothScrollTo( - _innerScroll.scrollX, - (offset * Environment.highDpiFactor).toInt() - ) + _scrollToY?.end() + _scrollToY = null + _scrollToX?.end() + _scrollToX = null } } } diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidUiFacade.kt b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidUiFacade.kt index c4c921cf9..479b980fc 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidUiFacade.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidUiFacade.kt @@ -186,7 +186,10 @@ internal class AndroidUiFacade : IUiFacade { synthToUse = this.createWorkerPlayer(); } - return AndroidThreadAlphaSynthAudioExporter(synthToUse as AndroidThreadAlphaSynthWorkerPlayer, needNewWorker); + return AndroidThreadAlphaSynthAudioExporter( + synthToUse as AndroidThreadAlphaSynthWorkerPlayer, + needNewWorker + ); } @@ -382,12 +385,32 @@ internal class AndroidUiFacade : IUiFacade { override fun scrollToX(scrollElement: IContainer, offset: Double, speed: Double) { val view = (scrollElement as AndroidRootViewContainer) - view.scrollToX(offset) + view.scrollToX(offset, speed) } override fun scrollToY(scrollElement: IContainer, offset: Double, speed: Double) { val view = (scrollElement as AndroidRootViewContainer) - view.scrollToY(offset) + view.scrollToY(offset, speed) + } + + override fun stopScrolling(scrollElement: IContainer) { + val view = (scrollElement as AndroidRootViewContainer) + view.stopScrolling() + } + + override fun setCanvasOverflow( + canvasElement: IContainer, + overflow: Double, + isVertical: Boolean + ) { + val view = (canvasElement as AndroidViewContainer).view; + if (view is AlphaTabRenderSurface) { + if (isVertical) { + view.setPadding(0, 0, 0, overflow.toInt()) + } else { + view.setPadding(0, 0, overflow.toInt(), 0) + } + } } override fun load( diff --git a/packages/playground/control-template.html b/packages/playground/control-template.html index 67ebedd4b..8e6d6a852 100644 --- a/packages/playground/control-template.html +++ b/packages/playground/control-template.html @@ -141,17 +141,34 @@ Layout + diff --git a/packages/playground/control.ts b/packages/playground/control.ts index 23dc29f7f..eb311af3e 100644 --- a/packages/playground/control.ts +++ b/packages/playground/control.ts @@ -22,6 +22,7 @@ const defaultSettings = { player: { playerMode: alphaTab.PlayerMode.EnabledAutomatic, scrollOffsetX: -10, + scrollOffsetY: -20, soundFont: '/font/sonivox/sonivox.sf2' } } satisfies alphaTab.json.SettingsJson; @@ -598,15 +599,38 @@ export function setupControl(selector: string, customSettings: alphaTab.json.Set switch ((e.target as HTMLAnchorElement).dataset.layout) { case 'page': settings.display.layoutMode = alphaTab.LayoutMode.Page; - settings.player.scrollMode = alphaTab.ScrollMode.Continuous; break; - case 'horizontal-bar': + case 'horizontal': settings.display.layoutMode = alphaTab.LayoutMode.Horizontal; + break; + } + + at.updateSettings(); + at.render(); + }; + } + for (const a of control.querySelectorAll('.at-scroll-options a')) { + a.onclick = e => { + e.preventDefault(); + const settings = at.settings; + switch ((e.target as HTMLAnchorElement).dataset.scroll) { + case 'off': + settings.player.scrollMode = alphaTab.ScrollMode.Off; + break; + case 'continuous': settings.player.scrollMode = alphaTab.ScrollMode.Continuous; + settings.player.scrollOffsetX = -10; + settings.player.scrollOffsetY = -10; break; - case 'horizontal-screen': - settings.display.layoutMode = alphaTab.LayoutMode.Horizontal; + case 'offscreen': settings.player.scrollMode = alphaTab.ScrollMode.OffScreen; + settings.player.scrollOffsetX = -10; + settings.player.scrollOffsetY = -10; + break; + case 'smooth': + settings.player.scrollMode = alphaTab.ScrollMode.Smooth; + settings.player.scrollOffsetX = -50; + settings.player.scrollOffsetY = -100; break; } @@ -619,13 +643,11 @@ export function setupControl(selector: string, customSettings: alphaTab.json.Set new bootstrap.Tooltip(t); } - at.playbackRangeChanged.on(e => { - if (e.playbackRange) { - } - }); - setupSelectionHandles(el, at); + // TEMP: for testing without sound + at.masterVolume = 0; + // expose api for fiddling in developer tools (window as any).api = at; (window as any).alphaTab = alphaTab;