From 7ebc3a90eb65ef912dcd3084e9dc0bffcc1ca3c1 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Sat, 22 Jun 2024 13:41:13 +0200 Subject: [PATCH] More Server Protocol to `livesplit-core` This moves the server protocol to `livesplit-core` and improves on it in various ways: - The protocol is based on JSON messages. This allows for example for more structured commands where it's easier to provide multiple arguments for a command and even have optional arguments. - For each command, there is a corresponding response. It is either a `success` response with possibly the value that you requested, or an `error` response with an error `code`. - On top of the responses you also get sent `event` messages that indicate changes to the timer. These can either be changes triggered via a command that you sent or by changes that happened through other sources, such as the user directly interacting with the timer or an auto splitter. The protocol is still work in progress and we will evolve it into a protocol that fully allows synchronizing timers over the network. The event sink has now been renamed to command sink, because there is now a clear distinction between incoming commands and events that are the results of these commands. Changelog: The protocol used for the server connection has been significantly improved. There is a response for each command and clear errors when something goes wrong. Additionally, there are now event messages that indicate changes to the timer. --- livesplit-core | 2 +- src/api/LiveSplitServer.ts | 66 +++--- src/ui/LSOCommandSink.ts | 446 +++++++++++++++++++++++++++++++++++++ src/ui/LSOEventSink.ts | 357 ----------------------------- src/ui/LayoutEditor.tsx | 6 +- src/ui/LayoutView.tsx | 6 +- src/ui/LiveSplit.tsx | 167 +++++++++----- src/ui/SettingsEditor.tsx | 6 +- src/ui/SplitsSelection.tsx | 20 +- src/ui/TimerView.tsx | 26 +-- 10 files changed, 631 insertions(+), 471 deletions(-) create mode 100644 src/ui/LSOCommandSink.ts delete mode 100644 src/ui/LSOEventSink.ts diff --git a/livesplit-core b/livesplit-core index 4ebb4bb6..922cc847 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 4ebb4bb6fc06528e5b9ed935e18191150cee2b33 +Subproject commit 922cc8475499f9c8d48877ff437661cefebfefd5 diff --git a/src/api/LiveSplitServer.ts b/src/api/LiveSplitServer.ts index 27f5282a..fa629c9b 100644 --- a/src/api/LiveSplitServer.ts +++ b/src/api/LiveSplitServer.ts @@ -1,5 +1,7 @@ import { toast } from "react-toastify"; -import { LSOEventSink } from "../ui/LSOEventSink"; +import { LSOCommandSink } from "../ui/LSOCommandSink"; +import { ServerProtocol } from "../livesplit-core/livesplit_core"; +import { Event } from "../livesplit-core"; export class LiveSplitServer { private connection: WebSocket; @@ -9,7 +11,7 @@ export class LiveSplitServer { url: string, private forceUpdate: () => void, onServerConnectionClosed: () => void, - eventSink: LSOEventSink, + commandSink: LSOCommandSink, ) { try { this.connection = new WebSocket(url); @@ -29,38 +31,31 @@ export class LiveSplitServer { }; this.connection.onerror = () => { - // The onerror event does not contain any useful information. - toast.error("An error while communicating with the server occurred."); + if (wasConnected) { + // The onerror event does not contain any useful information. + toast.error("An error while communicating with the server occurred."); + } }; + let sendQueue = Promise.resolve(); + this.connection.onmessage = (e) => { if (typeof e.data === "string") { - const index = e.data.indexOf(" "); - let command = e.data; - let arg = ""; - if (index >= 0) { - command = e.data.substring(0, index); - arg = e.data.substring(index + 1); - } - switch (command) { - case "start": eventSink.start(); break; - case "split": eventSink.split(); break; - case "splitorstart": eventSink.splitOrStart(); break; - case "reset": eventSink.reset(); break; - case "togglepause": eventSink.togglePauseOrStart(); break; - case "undo": eventSink.undoSplit(); break; - case "skip": eventSink.skipSplit(); break; - case "initgametime": eventSink.initializeGameTime(); break; - case "setgametime": eventSink.setGameTimeString(arg ?? ""); break; - case "setloadingtimes": eventSink.setLoadingTimesString(arg ?? ""); break; - case "pausegametime": eventSink.pauseGameTime(); break; - case "resumegametime": eventSink.resumeGameTime(); break; - case "setvariable": { - const [key, value] = JSON.parse(arg ?? ""); - eventSink.setCustomVariable(key, value); - break; + // Handle and enqueue the command handling immediately, but send + // the response only after all previous responses have been + // sent. + + const promise = ServerProtocol.handleCommand(e.data, commandSink.getCommandSink().ptr); + sendQueue = sendQueue.then(async () => { + const message = await promise; + if (this.connection.readyState === WebSocket.OPEN) { + this.connection.send(message); } - } + }); + } else { + sendQueue = sendQueue.then(() => { + this.connection.send('{"Err":{"code":"InvalidCommand"}}'); + }); } }; @@ -80,7 +75,7 @@ export class LiveSplitServer { }; } - close(): void { + public close(): void { if (this.connection.readyState === WebSocket.OPEN) { this.wasIntendingToDisconnect = true; this.connection.close(); @@ -88,7 +83,16 @@ export class LiveSplitServer { } } - getConnectionState(): number { + public getConnectionState(): number { return this.connection.readyState; } + + public sendEvent(event: Event) { + if (this.connection.readyState === WebSocket.OPEN) { + const message = ServerProtocol.encodeEvent(event); + if (message !== undefined) { + this.connection.send(message); + } + } + } } diff --git a/src/ui/LSOCommandSink.ts b/src/ui/LSOCommandSink.ts new file mode 100644 index 00000000..ffdc8a61 --- /dev/null +++ b/src/ui/LSOCommandSink.ts @@ -0,0 +1,446 @@ +import { CommandError, CommandResult, CommandSink, CommandSinkRef, Event, ImageCacheRefMut, LayoutEditorRefMut, LayoutRefMut, LayoutStateRefMut, Run, RunRef, TimeSpan, TimeSpanRef, Timer, TimerPhase, TimingMethod, isEvent } from "../livesplit-core"; +import { WebCommandSink } from "../livesplit-core/livesplit_core"; +import { assert } from "../util/OptionUtil"; +import { showDialog } from "./Dialog"; + +interface Callbacks { + handleEvent(event: Event): void, + runChanged(): void, + runNotModifiedAnymore(): void, +} + + +export class LSOCommandSink { + private commandSink: CommandSink; + // We don't want to the timer to be interacted with while we are in menus + // where the timer is not visible or otherwise meant to be interacted with, + // nor do we want it to to be interacted with while dialogs are open. + // Multiple of these conditions can be true at the same time, so we count + // them. + private locked = 0; + + constructor( + private timer: Timer, + private callbacks: Callbacks, + ) { + this.commandSink = new CommandSink(new WebCommandSink(this).intoGeneric()); + } + + public [Symbol.dispose](): void { + this.commandSink[Symbol.dispose](); + this.timer[Symbol.dispose](); + } + + public getCommandSink(): CommandSinkRef { + return this.commandSink; + } + + public isLocked(): boolean { + return this.locked > 0; + } + + public lockInteraction() { + this.locked++; + } + + public unlockInteraction() { + this.locked--; + assert(this.locked >= 0, "The lock count should never be negative."); + } + + public start(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const result = this.timer.start() as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public split(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const result = this.timer.split() as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public splitOrStart(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const result = this.timer.splitOrStart() as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public async reset(): Promise { + if (this.locked) { + return CommandError.Busy; + } + + let updateSplits = true; + if (this.timer.currentAttemptHasNewBestTimes()) { + const [result] = await showDialog({ + title: "Save Best Times?", + description: "You have beaten some of your best times. Do you want to update them?", + buttons: ["Yes", "No", "Don't Reset"], + }); + if (result === 2) { + return CommandError.RunnerDecidedAgainstReset; + } + updateSplits = result === 0; + } + + const result = this.timer.reset(updateSplits) as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public undoSplit(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const result = this.timer.undoSplit() as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public skipSplit(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const result = this.timer.skipSplit() as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public togglePauseOrStart(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const result = this.timer.togglePauseOrStart() as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public pause(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const result = this.timer.pause() as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public resume(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const result = this.timer.resume() as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public undoAllPauses(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const result = this.timer.undoAllPauses() as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public switchToPreviousComparison(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + this.timer.switchToPreviousComparison(); + const result = Event.ComparisonChanged; + + this.callbacks.handleEvent(result); + + return result; + } + + public switchToNextComparison(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + this.timer.switchToNextComparison(); + const result = Event.ComparisonChanged; + + this.callbacks.handleEvent(result); + + return result; + } + + public setCurrentComparison(comparison: string): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const result = this.timer.setCurrentComparison(comparison) as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public toggleTimingMethod(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + this.timer.toggleTimingMethod(); + const result = Event.TimingMethodChanged; + + this.callbacks.handleEvent(result); + + return result; + } + + public setCurrentTimingMethod(method: TimingMethod): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + this.timer.setCurrentTimingMethod(method); + const result = Event.TimingMethodChanged; + + this.callbacks.handleEvent(result); + + return result; + } + + public initializeGameTime(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const result = this.timer.initializeGameTime() as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public setGameTime(timeSpanPtr: number): CommandResult { + const timeSpan = new TimeSpanRef(timeSpanPtr); + return this.setGameTimeInner(timeSpan); + } + + public setGameTimeString(gameTime: string): CommandResult { + using time = TimeSpan.parse(gameTime); + if (time !== null) { + return this.setGameTimeInner(time); + } else { + return CommandError.CouldNotParseTime; + } + } + + public setGameTimeInner(timeSpan: TimeSpanRef): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const result = this.timer.setGameTime(timeSpan) as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public pauseGameTime(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const result = this.timer.pauseGameTime() as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public resumeGameTime(): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const result = this.timer.resumeGameTime() as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public setLoadingTimes(timeSpanPtr: number): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + const timeSpan = new TimeSpanRef(timeSpanPtr); + const result = this.timer.setLoadingTimes(timeSpan) as CommandResult; + + if (isEvent(result)) { + this.callbacks.handleEvent(result); + } + + return result; + } + + public setCustomVariable(name: string, value: string): CommandResult { + if (this.locked) { + return CommandError.Busy; + } + + this.timer.setCustomVariable(name, value); + const result = Event.CustomVariableSet; + + this.callbacks.handleEvent(result); + + return result; + } + + public setRun(run: Run): Run | null { + const result = this.timer.setRun(run); + + this.callbacks.runChanged(); + + return result; + } + + public hasBeenModified(): boolean { + return this.timer.getRun().hasBeenModified(); + } + + public markAsUnmodified(): void { + this.timer.markAsUnmodified(); + this.callbacks.runNotModifiedAnymore(); + } + + public getRun(): RunRef { + return this.timer.getRun(); + } + + public extendedFileName(useExtendedCategoryName: boolean): string { + return this.timer.getRun().extendedFileName(useExtendedCategoryName); + } + + public saveAsLssBytes(): Uint8Array { + return this.timer.saveAsLssBytes(); + } + + public updateLayoutState( + layout: LayoutRefMut, + layoutState: LayoutStateRefMut, + imageCache: ImageCacheRefMut, + ): void { + layout.updateState(layoutState, imageCache, this.timer); + } + + public updateLayoutEditorLayoutState( + layoutEditor: LayoutEditorRefMut, + layoutState: LayoutStateRefMut, + imageCache: ImageCacheRefMut, + ): void { + layoutEditor.updateLayoutState(layoutState, imageCache, this.timer); + } + + public currentSplitIndex(): number { + return this.timer.currentSplitIndex(); + } + + public segmentsCount(): number { + return this.timer.getRun().segmentsLen(); + } + + public currentPhase(): TimerPhase { + return this.timer.currentPhase(); + } + + public currentComparison(): string { + return this.timer.currentComparison(); + } + + public getAllComparisons(): string[] { + const run = this.timer.getRun(); + const len = run.comparisonsLen(); + const comparisons = []; + for (let i = 0; i < len; i++) { + comparisons.push(run.comparison(i)); + } + return comparisons; + } + + public currentTimingMethod(): TimingMethod { + return this.timer.currentTimingMethod(); + } + + getTimer(): number { + return this.timer.ptr; + } +} diff --git a/src/ui/LSOEventSink.ts b/src/ui/LSOEventSink.ts deleted file mode 100644 index 355c3990..00000000 --- a/src/ui/LSOEventSink.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { EventSink, EventSinkRef, ImageCacheRefMut, LayoutEditorRefMut, LayoutRefMut, LayoutStateRefMut, Run, RunRef, TimeSpan, TimeSpanRef, Timer, TimerPhase, TimingMethod } from "../livesplit-core"; -import { WebEventSink } from "../livesplit-core/livesplit_core"; -import { assert } from "../util/OptionUtil"; -import { showDialog } from "./Dialog"; - -export class LSOEventSink { - private eventSink: EventSink; - // We don't want to the timer to be interacted with while we are in menus - // where the timer is not visible or otherwise meant to be interacted with, - // nor do we want it to to be interacted with while dialogs are open. - // Multiple of these conditions can be true at the same time, so we count - // them. - private locked = 0; - - constructor( - private timer: Timer, - private currentComparisonChanged: () => void, - private currentTimingMethodChanged: () => void, - private currentPhaseChanged: () => void, - private currentSplitChanged: () => void, - private comparisonListChanged: () => void, - private splitsModifiedChanged: () => void, - private onReset: () => void, - ) { - this.eventSink = new EventSink(new WebEventSink(this).intoGeneric()); - } - - public [Symbol.dispose](): void { - this.eventSink[Symbol.dispose](); - this.timer[Symbol.dispose](); - } - - public getEventSink(): EventSinkRef { - return this.eventSink; - } - - public isLocked(): boolean { - return this.locked > 0; - } - - public lockInteraction() { - this.locked++; - } - - public unlockInteraction() { - this.locked--; - assert(this.locked >= 0, "The lock count should never be negative."); - } - - public start(): void { - if (this.locked) { - return; - } - - this.timer.start(); - - this.currentPhaseChanged(); - this.currentSplitChanged(); - this.splitsModifiedChanged(); - } - - public split(): void { - if (this.locked) { - return; - } - - this.timer.split(); - - this.currentPhaseChanged(); - this.currentSplitChanged(); - } - - public splitOrStart(): void { - if (this.locked) { - return; - } - - this.timer.splitOrStart(); - - this.currentPhaseChanged(); - this.currentSplitChanged(); - this.splitsModifiedChanged(); - } - - public async reset(): Promise { - if (this.locked) { - return; - } - - let updateSplits = true; - if (this.timer.currentAttemptHasNewBestTimes()) { - const [result] = await showDialog({ - title: "Save Best Times?", - description: "You have beaten some of your best times. Do you want to update them?", - buttons: ["Yes", "No", "Don't Reset"], - }); - if (result === 2) { - return; - } - updateSplits = result === 0; - } - - this.timer.reset(updateSplits); - - this.currentPhaseChanged(); - this.currentSplitChanged(); - this.onReset(); - } - - public undoSplit(): void { - if (this.locked) { - return; - } - - this.timer.undoSplit(); - - this.currentPhaseChanged(); - this.currentSplitChanged(); - } - - public skipSplit(): void { - if (this.locked) { - return; - } - - this.timer.skipSplit(); - - this.currentSplitChanged(); - } - - public togglePauseOrStart(): void { - if (this.locked) { - return; - } - - this.timer.togglePauseOrStart(); - - this.currentPhaseChanged(); - this.currentSplitChanged(); - this.splitsModifiedChanged(); - } - - public pause(): void { - if (this.locked) { - return; - } - - this.timer.pause(); - - this.currentPhaseChanged(); - } - - public resume(): void { - if (this.locked) { - return; - } - - this.timer.resume(); - - this.currentPhaseChanged(); - } - - public undoAllPauses(): void { - if (this.locked) { - return; - } - - this.timer.undoAllPauses(); - - this.currentPhaseChanged(); - } - - public switchToPreviousComparison(): void { - if (this.locked) { - return; - } - - this.timer.switchToPreviousComparison(); - this.currentComparisonChanged(); - } - - public switchToNextComparison(): void { - if (this.locked) { - return; - } - - this.timer.switchToNextComparison(); - this.currentComparisonChanged(); - } - - public setCurrentComparison(comparison: string): void { - if (this.locked) { - return; - } - - this.timer.setCurrentComparison(comparison); - this.currentComparisonChanged(); - } - - public toggleTimingMethod(): void { - if (this.locked) { - return; - } - - this.timer.toggleTimingMethod(); - this.currentTimingMethodChanged(); - } - - public setGameTime(timeSpanPtr: number): void { - const timeSpan = new TimeSpanRef(timeSpanPtr); - this.timer.setGameTime(timeSpan); - } - - public setGameTimeInner(timeSpan: TimeSpanRef): void { - if (this.locked) { - return; - } - this.timer.setGameTime(timeSpan); - } - - public setGameTimeString(gameTime: string): void { - using time = TimeSpan.parse(gameTime); - if (time !== null) { - this.setGameTimeInner(time); - } - } - - public setLoadingTimesString(loadingTimes: string): void { - using time = TimeSpan.parse(loadingTimes); - if (time !== null) { - this.setLoadingTimesInner(time); - } - } - - public pauseGameTime(): void { - if (this.locked) { - return; - } - this.timer.pauseGameTime(); - } - - public resumeGameTime(): void { - if (this.locked) { - return; - } - this.timer.resumeGameTime(); - } - - public setCustomVariable(name: string, value: string): void { - if (this.locked) { - return; - } - this.timer.setCustomVariable(name, value); - } - - public initializeGameTime(): void { - if (this.locked) { - return; - } - this.timer.initializeGameTime(); - } - - public setLoadingTimesInner(timeSpan: TimeSpanRef): void { - if (this.locked) { - return; - } - this.timer.setLoadingTimes(timeSpan); - } - - public setRun(run: Run): Run | null { - const result = this.timer.setRun(run); - - this.currentComparisonChanged(); - this.currentPhaseChanged(); - this.currentSplitChanged(); - this.comparisonListChanged(); - this.splitsModifiedChanged(); - - return result; - } - - public hasBeenModified(): boolean { - return this.timer.getRun().hasBeenModified(); - } - - public markAsUnmodified(): void { - if (this.locked) { - return; - } - - this.timer.markAsUnmodified(); - this.splitsModifiedChanged(); - } - - public getRun(): RunRef { - return this.timer.getRun(); - } - - public extendedFileName(useExtendedCategoryName: boolean): string { - return this.timer.getRun().extendedFileName(useExtendedCategoryName); - } - - public saveAsLssBytes(): Uint8Array { - return this.timer.saveAsLssBytes(); - } - - public updateLayoutState( - layout: LayoutRefMut, - layoutState: LayoutStateRefMut, - imageCache: ImageCacheRefMut, - ): void { - layout.updateState(layoutState, imageCache, this.timer); - } - - public updateLayoutEditorLayoutState( - layoutEditor: LayoutEditorRefMut, - layoutState: LayoutStateRefMut, - imageCache: ImageCacheRefMut, - ): void { - layoutEditor.updateLayoutState(layoutState, imageCache, this.timer); - } - - public currentSplitIndex(): number { - return this.timer.currentSplitIndex(); - } - - public segmentsCount(): number { - return this.timer.getRun().segmentsLen(); - } - - public currentPhase(): TimerPhase { - return this.timer.currentPhase(); - } - - public currentComparison(): string { - return this.timer.currentComparison(); - } - - public getAllComparisons(): string[] { - const run = this.timer.getRun(); - const len = run.comparisonsLen(); - const comparisons = []; - for (let i = 0; i < len; i++) { - comparisons.push(run.comparison(i)); - } - return comparisons; - } - - public currentTimingMethod(): TimingMethod { - return this.timer.currentTimingMethod(); - } - - public setCurrentTimingMethod(method: TimingMethod): void { - this.timer.setCurrentTimingMethod(method); - this.currentTimingMethodChanged(); - } -} diff --git a/src/ui/LayoutEditor.tsx b/src/ui/LayoutEditor.tsx index 01eb491b..c4d5e7f9 100644 --- a/src/ui/LayoutEditor.tsx +++ b/src/ui/LayoutEditor.tsx @@ -6,7 +6,7 @@ import { UrlCache } from "../util/UrlCache"; import Layout from "../layout/Layout"; import { WebRenderer } from "../livesplit-core/livesplit_core"; import { GeneralSettings } from "./SettingsEditor"; -import { LSOEventSink } from "./LSOEventSink"; +import { LSOCommandSink } from "./LSOCommandSink"; import "../css/LayoutEditor.scss"; @@ -20,7 +20,7 @@ export interface Props { generalSettings: GeneralSettings, allComparisons: string[], isDesktop: boolean, - eventSink: LSOEventSink, + commandSink: LSOCommandSink, renderer: WebRenderer, callbacks: Callbacks, } @@ -319,7 +319,7 @@ export class LayoutEditor extends React.Component {
{ - this.props.eventSink.updateLayoutEditorLayoutState( + this.props.commandSink.updateLayoutEditorLayoutState( this.props.editor, this.props.layoutState, this.props.layoutUrlCache.imageCache, diff --git a/src/ui/LayoutView.tsx b/src/ui/LayoutView.tsx index 450a8cfe..cf13269f 100644 --- a/src/ui/LayoutView.tsx +++ b/src/ui/LayoutView.tsx @@ -6,7 +6,7 @@ import { WebRenderer } from "../livesplit-core/livesplit_core"; import { GeneralSettings } from "./SettingsEditor"; import { LiveSplitServer } from "../api/LiveSplitServer"; import { Option } from "../util/OptionUtil"; -import { LSOEventSink } from "./LSOEventSink"; +import { LSOCommandSink } from "./LSOCommandSink"; export interface Props { isDesktop: boolean, @@ -18,7 +18,7 @@ export interface Props { generalSettings: GeneralSettings, renderWithSidebar: boolean, sidebarOpen: boolean, - eventSink: LSOEventSink, + commandSink: LSOCommandSink, renderer: WebRenderer, serverConnection: Option, callbacks: Callbacks, @@ -62,7 +62,7 @@ export class LayoutView extends React.Component { isDesktop={this.props.isDesktop} renderWithSidebar={false} sidebarOpen={this.props.sidebarOpen} - eventSink={this.props.eventSink} + commandSink={this.props.commandSink} renderer={this.props.renderer} serverConnection={this.props.serverConnection} callbacks={this.props.callbacks} diff --git a/src/ui/LiveSplit.tsx b/src/ui/LiveSplit.tsx index 8eec519e..cc94db23 100644 --- a/src/ui/LiveSplit.tsx +++ b/src/ui/LiveSplit.tsx @@ -4,6 +4,7 @@ import { HotkeySystem, Layout, LayoutEditor, Run, RunEditor, Segment, Timer, HotkeyConfig, LayoutState, LayoutStateJson, TimingMethod, TimerPhase, + Event, } from "../livesplit-core"; import { FILE_EXT_LAYOUTS, convertFileToArrayBuffer, convertFileToString, exportFile, openFileAsString } from "../util/FileUtil"; import { Option, assertNull, expect, maybeDisposeAndThen, panic } from "../util/OptionUtil"; @@ -20,7 +21,7 @@ import * as Storage from "../storage"; import { UrlCache } from "../util/UrlCache"; import { WebRenderer } from "../livesplit-core/livesplit_core"; import { LiveSplitServer } from "../api/LiveSplitServer"; -import { LSOEventSink } from "./LSOEventSink"; +import { LSOCommandSink } from "./LSOCommandSink"; import DialogContainer from "./Dialog"; import variables from "../css/variables.scss"; @@ -90,7 +91,7 @@ export interface State { sidebarTransitionsEnabled: boolean, storedLayoutWidth: number, storedLayoutHeight: number, - eventSink: LSOEventSink, + commandSink: LSOCommandSink, renderer: WebRenderer, generalSettings: GeneralSettings, serverConnection: Option, @@ -144,15 +145,9 @@ export class LiveSplit extends React.Component { "The Default Run should be a valid Run", ); - const eventSink = new LSOEventSink( + const commandSink = new LSOCommandSink( timer, - () => this.currentComparisonChanged(), - () => this.currentTimingMethodChanged(), - () => this.currentPhaseChanged(), - () => this.currentSplitChanged(), - () => this.comparisonListChanged(), - () => this.splitsModifiedChanged(), - () => this.onReset(), + this, ); const hotkeys = props.hotkeys; @@ -160,13 +155,13 @@ export class LiveSplit extends React.Component { if (hotkeys !== undefined) { const config = HotkeyConfig.parseJson(hotkeys); if (config !== null) { - hotkeySystem = HotkeySystem.withConfig(eventSink.getEventSink(), config); + hotkeySystem = HotkeySystem.withConfig(commandSink.getCommandSink(), config); } } } catch (_) { /* Looks like the storage has no valid data */ } if (hotkeySystem == null) { hotkeySystem = expect( - HotkeySystem.new(eventSink.getEventSink()), + HotkeySystem.new(commandSink.getCommandSink()), "Couldn't initialize the hotkeys", ); } @@ -177,7 +172,7 @@ export class LiveSplit extends React.Component { loadingRun.setCategoryName("Loading..."); loadingRun.pushSegment(Segment.new("Time")); assertNull( - eventSink.setRun(loadingRun), + commandSink.setRun(loadingRun), "The Default Loading Run should be a valid Run", ); this.loadFromSplitsIO(window.location.hash.substring("#/splits-io/".length)); @@ -185,7 +180,7 @@ export class LiveSplit extends React.Component { using result = Run.parseArray(props.splits, ""); if (result.parsedSuccessfully()) { using r = result.unwrap(); - eventSink.setRun(r)?.[Symbol.dispose](); + commandSink.setRun(r)?.[Symbol.dispose](); } } @@ -226,18 +221,18 @@ export class LiveSplit extends React.Component { sidebarTransitionsEnabled: false, storedLayoutWidth: props.layoutWidth, storedLayoutHeight: props.layoutHeight, - eventSink, + commandSink, hotkeySystem, openedSplitsKey: props.splitsKey, renderer, generalSettings: props.generalSettings, serverConnection: null, - currentComparison: eventSink.currentComparison(), - currentTimingMethod: eventSink.currentTimingMethod(), - currentPhase: eventSink.currentPhase(), - currentSplitIndex: eventSink.currentSplitIndex(), - allComparisons: eventSink.getAllComparisons(), - splitsModified: eventSink.hasBeenModified(), + currentComparison: commandSink.currentComparison(), + currentTimingMethod: commandSink.currentTimingMethod(), + currentPhase: commandSink.currentPhase(), + currentSplitIndex: commandSink.currentSplitIndex(), + allComparisons: commandSink.getAllComparisons(), + splitsModified: commandSink.hasBeenModified(), layoutModified: false, }; @@ -310,7 +305,7 @@ export class LiveSplit extends React.Component { "resize", expect(this.resizeEvent, "A Resize Event should exist"), ); - this.state.eventSink[Symbol.dispose](); + this.state.commandSink[Symbol.dispose](); this.state.layout[Symbol.dispose](); this.state.layoutState[Symbol.dispose](); this.state.hotkeySystem?.[Symbol.dispose](); @@ -346,7 +341,7 @@ export class LiveSplit extends React.Component { generalSettings={this.state.generalSettings} allComparisons={this.state.allComparisons} isDesktop={this.state.isDesktop} - eventSink={this.state.eventSink} + commandSink={this.state.commandSink} renderer={this.state.renderer} callbacks={this} />; @@ -356,7 +351,7 @@ export class LiveSplit extends React.Component { hotkeyConfig={this.state.menu.config} urlCache={this.state.layoutUrlCache} callbacks={this} - eventSink={this.state.eventSink} + commandSink={this.state.commandSink} serverConnection={this.state.serverConnection} allComparisons={this.state.allComparisons} />; @@ -365,7 +360,7 @@ export class LiveSplit extends React.Component { } else if (this.state.menu.kind === MenuKind.Splits) { view = { isDesktop={this.state.isDesktop} renderWithSidebar={true} sidebarOpen={this.state.sidebarOpen} - eventSink={this.state.eventSink} + commandSink={this.state.commandSink} renderer={this.state.renderer} serverConnection={this.state.serverConnection} callbacks={this} @@ -404,7 +399,7 @@ export class LiveSplit extends React.Component { isDesktop={this.state.isDesktop} renderWithSidebar={true} sidebarOpen={this.state.sidebarOpen} - eventSink={this.state.eventSink} + commandSink={this.state.commandSink} renderer={this.state.renderer} serverConnection={this.state.serverConnection} callbacks={this} @@ -463,11 +458,14 @@ export class LiveSplit extends React.Component { ); } - private changeMenu(menu: Menu) { + private changeMenu(menu: Menu, closeSidebar: boolean = true) { const wasLocked = isMenuLocked(this.state.menu.kind); const isLocked = isMenuLocked(menu.kind); - this.setState({ menu, sidebarOpen: false }); + this.setState({ menu }); + if (closeSidebar) { + this.setState({ sidebarOpen: false }); + } if (!wasLocked && isLocked) { this.lockTimerInteraction(); @@ -485,7 +483,7 @@ export class LiveSplit extends React.Component { } public openLayoutView() { - this.changeMenu({ kind: MenuKind.Layout }); + this.changeMenu({ kind: MenuKind.Layout }, false); } public openAboutView() { @@ -493,7 +491,7 @@ export class LiveSplit extends React.Component { } private lockTimerInteraction() { - if (!this.state.eventSink.isLocked()) { + if (!this.state.commandSink.isLocked()) { // We need to schedule this to happen in the next micro task, // because the hotkey system itself may be what triggered this // function, so the hotkey system might still be in use, which would @@ -501,12 +499,12 @@ export class LiveSplit extends React.Component { // system. setTimeout(() => this.state.hotkeySystem.deactivate()); } - this.state.eventSink.lockInteraction(); + this.state.commandSink.lockInteraction(); } private unlockTimerInteraction() { - this.state.eventSink.unlockInteraction(); - if (!this.state.eventSink.isLocked()) { + this.state.commandSink.unlockInteraction(); + if (!this.state.commandSink.isLocked()) { // We need to schedule this to happen in the next micro task, // because the hotkey system itself may be what triggered this // function, so the hotkey system might still be in use, which would @@ -593,7 +591,7 @@ export class LiveSplit extends React.Component { if (save) { if (splitsKey == null) { assertNull( - this.state.eventSink.setRun(run), + this.state.commandSink.setRun(run), "The Run Editor should always return a valid Run.", ); } else { @@ -764,7 +762,7 @@ export class LiveSplit extends React.Component { private setRun(run: Run, callback: () => void) { maybeDisposeAndThen( - this.state.eventSink.setRun(run), + this.state.commandSink.setRun(run), callback, ); this.setSplitsKey(undefined); @@ -817,8 +815,8 @@ export class LiveSplit extends React.Component { try { const openedSplitsKey = await Storage.storeSplits( (callback) => { - callback(this.state.eventSink.getRun(), this.state.eventSink.saveAsLssBytes()); - this.state.eventSink.markAsUnmodified(); + callback(this.state.commandSink.getRun(), this.state.commandSink.saveAsLssBytes()); + this.state.commandSink.markAsUnmodified(); }, this.state.openedSplitsKey, ); @@ -838,9 +836,78 @@ export class LiveSplit extends React.Component { this.setState({ serverConnection: null }); } - currentComparisonChanged(): void { + handleEvent(event: Event): void { + switch (event) { + case Event.Started: + this.splitsModifiedChanged(); + this.currentSplitChanged(); + this.currentPhaseChanged(); + break; + case Event.Splitted: + this.currentSplitChanged(); + break; + case Event.SplitSkipped: + this.currentSplitChanged(); + break; + case Event.SplitUndone: + this.currentPhaseChanged(); + this.currentSplitChanged(); + break; + case Event.Resumed: + this.currentPhaseChanged(); + break; + case Event.Paused: + this.currentPhaseChanged(); + break; + case Event.Finished: + this.currentPhaseChanged(); + this.currentSplitChanged(); + break; + case Event.Reset: + this.currentPhaseChanged(); + this.currentSplitChanged(); + this.onReset(); + break; + case Event.PausesUndoneAndResumed: + this.currentPhaseChanged(); + break; + case Event.ComparisonChanged: + this.currentComparisonChanged(); + break; + case Event.TimingMethodChanged: + this.currentTimingMethodChanged(); + break; + case Event.PausesUndone: + case Event.GameTimeInitialized: + case Event.GameTimeSet: + case Event.GameTimePaused: + case Event.GameTimeResumed: + case Event.LoadingTimesSet: + case Event.CustomVariableSet: + default: + break; + } + + if (this.state.serverConnection != null) { + this.state.serverConnection.sendEvent(event); + } + } + + runChanged(): void { + this.currentComparisonChanged(); + this.currentPhaseChanged(); + this.currentSplitChanged(); + this.comparisonListChanged(); + this.splitsModifiedChanged(); + } + + runNotModifiedAnymore(): void { + this.splitsModifiedChanged(); + } + + private currentComparisonChanged(): void { if (this.state != null) { - const currentComparison = this.state.eventSink.currentComparison(); + const currentComparison = this.state.commandSink.currentComparison(); (async () => { try { @@ -854,9 +921,9 @@ export class LiveSplit extends React.Component { } } - currentTimingMethodChanged(): void { + private currentTimingMethodChanged(): void { if (this.state != null) { - const currentTimingMethod = this.state.eventSink.currentTimingMethod(); + const currentTimingMethod = this.state.commandSink.currentTimingMethod(); (async () => { try { @@ -870,31 +937,31 @@ export class LiveSplit extends React.Component { } } - currentPhaseChanged(): void { + private currentPhaseChanged(): void { if (this.state != null) { this.setState({ - currentPhase: this.state.eventSink.currentPhase(), + currentPhase: this.state.commandSink.currentPhase(), }); } } - currentSplitChanged(): void { + private currentSplitChanged(): void { if (this.state != null) { this.setState({ - currentSplitIndex: this.state.eventSink.currentSplitIndex(), + currentSplitIndex: this.state.commandSink.currentSplitIndex(), }); } } - comparisonListChanged(): void { + private comparisonListChanged(): void { if (this.state != null) { this.setState({ - allComparisons: this.state.eventSink.getAllComparisons(), + allComparisons: this.state.commandSink.getAllComparisons(), }); } } - onReset(): void { + private onReset(): void { if (this.state.generalSettings.saveOnReset) { this.saveSplits(); } @@ -902,7 +969,7 @@ export class LiveSplit extends React.Component { splitsModifiedChanged(): void { if (this.state != null) { - const splitsModified = this.state.eventSink.hasBeenModified(); + const splitsModified = this.state.commandSink.hasBeenModified(); this.setState({ splitsModified }, () => this.updateBadge()); } } diff --git a/src/ui/SettingsEditor.tsx b/src/ui/SettingsEditor.tsx index 997d4df2..453ddcf6 100644 --- a/src/ui/SettingsEditor.tsx +++ b/src/ui/SettingsEditor.tsx @@ -7,7 +7,7 @@ import { UrlCache } from "../util/UrlCache"; import { FRAME_RATE_AUTOMATIC as FRAME_RATE_BATTERY_AWARE, FRAME_RATE_MATCH_SCREEN as FRAME_RATE_MATCH_SCREEN, FrameRateSetting } from "../util/FrameRate"; import { LiveSplitServer } from "../api/LiveSplitServer"; import { Option } from "../util/OptionUtil"; -import { LSOEventSink } from "./LSOEventSink"; +import { LSOCommandSink } from "./LSOCommandSink"; import "../css/SettingsEditor.scss"; @@ -27,7 +27,7 @@ export interface Props { urlCache: UrlCache, callbacks: Callbacks, serverConnection: Option, - eventSink: LSOEventSink, + commandSink: LSOCommandSink, allComparisons: string[], } @@ -229,7 +229,7 @@ export class SettingsEditor extends React.Component { value.String, () => this.forceUpdate(), () => this.props.callbacks.onServerConnectionClosed(), - this.props.eventSink, + this.props.commandSink, )); } catch { // It's fine if it fails. diff --git a/src/ui/SplitsSelection.tsx b/src/ui/SplitsSelection.tsx index 5b1b6f76..79ef8438 100644 --- a/src/ui/SplitsSelection.tsx +++ b/src/ui/SplitsSelection.tsx @@ -11,7 +11,7 @@ import { Option, bug, maybeDisposeAndThen } from "../util/OptionUtil"; import DragUpload from "./DragUpload"; import { ContextMenuTrigger, ContextMenu, MenuItem } from "react-contextmenu"; import { GeneralSettings } from "./SettingsEditor"; -import { LSOEventSink } from "./LSOEventSink"; +import { LSOCommandSink } from "./LSOCommandSink"; import { showDialog } from "./Dialog"; import "../css/SplitsSelection.scss"; @@ -22,7 +22,7 @@ export interface EditingInfo { } export interface Props { - eventSink: LSOEventSink, + commandSink: LSOCommandSink, openedSplitsKey?: number, callbacks: Callbacks, generalSettings: GeneralSettings, @@ -186,11 +186,11 @@ export class SplitsSelection extends React.Component {

Splits