mirror of
https://github.com/ApfelTeeSaft/LiveSplitOne.git
synced 2026-08-26 19:23:40 +00:00
Clean up some async race conditions & CSS fixes (#930)
The dialogs are now asynchronous as opposed to the browser built-in ones that are synchronous, probably because they are incredibly old. The problem with the new dialogs being asynchronous is that for example the hotkeys, the server protocol and honestly any sort of async download that's still running in the background can all mess with the timer while the dialog is open. This could lead to all sorts of weird issues, like the timer being in a different state than what the dialog expects, the timer being reset twice and creating two dialogs that are open at the same time and possibly even memory corruption in the Rust code. This commit ensures that the timer can not be interacted with while it is "locked for interaction". In fact we already had such a notion where when you navigate to the settings, layout or run editor, the hotkeys would be disabled. Now this is concept is extended so the event sink also can prevent interaction with the timer. Navigating to these menus and opening dialogs will now trigger this locking mechanism. Additionally this improves various async race conditions in the splits editor related to downloading resources from speedrun.com or splits.io. This also touches the CSS of the dialogs again, adding some spacing around the dialog, so it doesn't touch the edges of the window and allowing the buttons to have more dynamic sizing. In particular I've noticed that the text sometimes doesn't fit into the buttons on iOS, so they are now allowed to grow a little to handle that situation. File open dialogs also now know the file extensions of the files we are looking for.
This commit is contained in:
+53
-40
@@ -1,52 +1,65 @@
|
||||
@import 'variables';
|
||||
|
||||
.is-mobile+dialog>.dialog {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
dialog {
|
||||
color: #eee;
|
||||
background: $main-background-color;
|
||||
border: 2px solid $border-color;
|
||||
border-radius: 10px;
|
||||
min-width: 225px;
|
||||
max-width: 400px;
|
||||
padding: $ui-large-margin;
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
button {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
min-width: 80px;
|
||||
|
||||
&:focus {
|
||||
border-color: #888;
|
||||
}
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
column-gap: $ui-margin;
|
||||
}
|
||||
background: none;
|
||||
border: none;
|
||||
outline: 0;
|
||||
|
||||
&::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-bottom: 1px solid hsla(0, 0%, 100%, 0.25);
|
||||
background: transparent;
|
||||
color: white;
|
||||
text-overflow: ellipsis;
|
||||
font-family: "fira", sans-serif;
|
||||
font-size: 16px;
|
||||
.dialog {
|
||||
color: #eee;
|
||||
background: $main-background-color;
|
||||
border: 2px solid $border-color;
|
||||
border-radius: 10px;
|
||||
min-width: 225px;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
max-width: 400px;
|
||||
padding: $ui-large-margin;
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
button {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
min-width: 80px;
|
||||
height: auto;
|
||||
|
||||
&:focus {
|
||||
border-color: #888;
|
||||
}
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: $ui-margin;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-bottom: 1px solid hsla(0, 0%, 100%, 0.25);
|
||||
background: transparent;
|
||||
color: white;
|
||||
text-overflow: ellipsis;
|
||||
font-family: "fira", sans-serif;
|
||||
font-size: 16px;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
.hotkey-box {
|
||||
button {
|
||||
margin: 0px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
height: 22px;
|
||||
min-height: 22px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.hotkey-button.focused {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
@import 'variables';
|
||||
|
||||
.livesplit-server-button {
|
||||
margin: 0px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
height: 22px;
|
||||
min-height: 22px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
// TODO: This is the same as the hotkey button.
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ $small-button-padding: 1px 3px 1px 3px;
|
||||
font-size: 15px;
|
||||
width: $button-width;
|
||||
margin-right: 0;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
|
||||
@include mobile {
|
||||
margin-top: 0;
|
||||
@@ -389,7 +389,7 @@ $small-button-padding: 1px 3px 1px 3px;
|
||||
content: "";
|
||||
height: 2px;
|
||||
width: 0;
|
||||
bottom: 0px;
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
background: hsla(50, 100%, 50%, 1);
|
||||
transition: 0.2s ease all;
|
||||
|
||||
@@ -18,9 +18,13 @@ $h2-font-size: 22px;
|
||||
|
||||
@include toggle;
|
||||
|
||||
>div>div.small>button {
|
||||
width: 50%;
|
||||
font-size: 18px;
|
||||
>div>div.small {
|
||||
display: flex;
|
||||
|
||||
>button {
|
||||
width: 50%;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-buttons {
|
||||
|
||||
+12
-10
@@ -12,12 +12,12 @@
|
||||
|
||||
.table-row-even {
|
||||
display: table-row;
|
||||
background: $dark-row-color !important;
|
||||
background: $dark-row-color !important;
|
||||
}
|
||||
|
||||
.table-row-odd {
|
||||
display: table-row;
|
||||
background: $light-row-color !important;
|
||||
background: $light-row-color !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,16 +54,16 @@
|
||||
}
|
||||
|
||||
.selected {
|
||||
background: $selected-row-color !important;
|
||||
background: $selected-row-color !important;
|
||||
}
|
||||
|
||||
.tab-bar>button {
|
||||
font-size: 15px;
|
||||
height: 30px;
|
||||
border-bottom-right-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
margin-bottom: 0px;
|
||||
border-bottom: 0px;
|
||||
min-height: 30px;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
tr>td>input {
|
||||
@@ -144,9 +144,11 @@
|
||||
grid-template-columns: 1fr 30px;
|
||||
|
||||
button {
|
||||
margin: 0px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
height: $settings-row-height;
|
||||
min-height: $settings-row-height;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -2,17 +2,17 @@
|
||||
|
||||
@mixin toggle {
|
||||
.toggle-left {
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.toggle-right {
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.toggle-middle {
|
||||
border-radius: 0px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.button-pressed,
|
||||
|
||||
+5
-3
@@ -59,7 +59,7 @@ td#dif {
|
||||
}
|
||||
|
||||
table {
|
||||
border-spacing: 0px 2px;
|
||||
border-spacing: 0 2px;
|
||||
}
|
||||
|
||||
button:active,
|
||||
@@ -83,6 +83,7 @@ button {
|
||||
color: $button-text-color;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1.1;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: $border-color;
|
||||
@@ -90,7 +91,8 @@ button {
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
font-family: "fira", sans-serif;
|
||||
height: $button-height;
|
||||
min-height: $button-height;
|
||||
padding: 5px $ui-margin;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -124,7 +126,7 @@ button:disabled:active {
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-clip: padding-box;
|
||||
background-color: #303030;
|
||||
border: 0px solid #0000;
|
||||
border: 0 solid #0000;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
|
||||
+58
-37
@@ -2,6 +2,11 @@ import * as React from "react";
|
||||
|
||||
import "../css/Dialog.scss";
|
||||
|
||||
export interface Props {
|
||||
onShow: () => void,
|
||||
onClose: () => void,
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
title: string | JSX.Element,
|
||||
description: string | JSX.Element,
|
||||
@@ -18,23 +23,32 @@ export interface State {
|
||||
let dialogElement: HTMLDialogElement | null = null;
|
||||
let setState: ((options: Options) => void) | undefined;
|
||||
let resolveFn: ((_: [number, string]) => void) | undefined;
|
||||
let onCloseFn: (() => void) | undefined;
|
||||
let alreadyClosed = false;
|
||||
|
||||
export function showDialog(options: Options): Promise<[number, string]> {
|
||||
if (dialogElement) {
|
||||
dialogElement.showModal();
|
||||
alreadyClosed = false;
|
||||
dialogElement.setAttribute("disabled", "");
|
||||
const closeWith = options.buttons.length - 1;
|
||||
dialogElement.onclose = () => {
|
||||
resolveFn?.([closeWith, ""]);
|
||||
if (!alreadyClosed) {
|
||||
resolveFn?.([closeWith, ""]);
|
||||
onCloseFn?.();
|
||||
}
|
||||
};
|
||||
}
|
||||
setState?.(options);
|
||||
return new Promise((resolve) => resolveFn = resolve);
|
||||
}
|
||||
|
||||
export default class DialogContainer extends React.Component<unknown, State> {
|
||||
constructor(props: unknown) {
|
||||
export default class DialogContainer extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
onCloseFn = props.onClose;
|
||||
|
||||
this.state = {
|
||||
options: {
|
||||
title: "",
|
||||
@@ -46,15 +60,17 @@ export default class DialogContainer extends React.Component<unknown, State> {
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
setState = (options) => this.setState({
|
||||
options,
|
||||
input: options.defaultText ?? "",
|
||||
});
|
||||
setState = (options) => {
|
||||
this.props.onShow();
|
||||
this.setState({
|
||||
options,
|
||||
input: options.defaultText ?? "",
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
public render() {
|
||||
return <dialog
|
||||
tabIndex={-1}
|
||||
ref={(element) => dialogElement = element}
|
||||
onKeyDown={(e) => {
|
||||
if (e?.key === "ArrowLeft") {
|
||||
@@ -66,38 +82,43 @@ export default class DialogContainer extends React.Component<unknown, State> {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h1>{this.state.options.title}</h1>
|
||||
<p>{this.state.options.description}</p>
|
||||
{
|
||||
this.state.options.textInput && <input
|
||||
type="text"
|
||||
value={this.state.input}
|
||||
autoFocus={true}
|
||||
onChange={(e) => this.setState({ input: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e?.key === "Enter") {
|
||||
e.preventDefault();
|
||||
dialogElement?.close();
|
||||
resolveFn?.([0, this.state.input]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
<div className="buttons">
|
||||
<div className="dialog">
|
||||
<h1>{this.state.options.title}</h1>
|
||||
<p>{this.state.options.description}</p>
|
||||
{
|
||||
this.state.options.buttons.map((button, i) => {
|
||||
return <button
|
||||
autoFocus={i === 0 && !this.state.options.textInput}
|
||||
onClick={() => {
|
||||
dialogElement?.close();
|
||||
resolveFn?.([i, this.state.input]);
|
||||
}}
|
||||
>
|
||||
{button}
|
||||
</button>;
|
||||
})
|
||||
this.state.options.textInput && <input
|
||||
type="text"
|
||||
value={this.state.input}
|
||||
autoFocus={true}
|
||||
onChange={(e) => this.setState({ input: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e?.key === "Enter") {
|
||||
e.preventDefault();
|
||||
this.close(0);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
<div className="buttons">
|
||||
{
|
||||
this.state.options.buttons.map((button, i) => {
|
||||
return <button
|
||||
autoFocus={i === 0 && !this.state.options.textInput}
|
||||
onClick={() => this.close(i)}
|
||||
>
|
||||
{button}
|
||||
</button>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>;
|
||||
}
|
||||
|
||||
private close(i: number) {
|
||||
alreadyClosed = true;
|
||||
dialogElement?.close();
|
||||
resolveFn?.([i, this.state.input]);
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
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,
|
||||
@@ -27,7 +34,24 @@ export class LSOEventSink {
|
||||
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();
|
||||
@@ -36,6 +60,10 @@ export class LSOEventSink {
|
||||
}
|
||||
|
||||
public split(): void {
|
||||
if (this.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer.split();
|
||||
|
||||
this.currentPhaseChanged();
|
||||
@@ -43,6 +71,10 @@ export class LSOEventSink {
|
||||
}
|
||||
|
||||
public splitOrStart(): void {
|
||||
if (this.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer.splitOrStart();
|
||||
|
||||
this.currentPhaseChanged();
|
||||
@@ -51,6 +83,10 @@ export class LSOEventSink {
|
||||
}
|
||||
|
||||
public async reset(): Promise<void> {
|
||||
if (this.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
let updateSplits = true;
|
||||
if (this.timer.currentAttemptHasNewBestTimes()) {
|
||||
const [result] = await showDialog({
|
||||
@@ -72,6 +108,10 @@ export class LSOEventSink {
|
||||
}
|
||||
|
||||
public undoSplit(): void {
|
||||
if (this.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer.undoSplit();
|
||||
|
||||
this.currentPhaseChanged();
|
||||
@@ -79,12 +119,20 @@ export class LSOEventSink {
|
||||
}
|
||||
|
||||
public skipSplit(): void {
|
||||
if (this.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer.skipSplit();
|
||||
|
||||
this.currentSplitChanged();
|
||||
}
|
||||
|
||||
public togglePauseOrStart(): void {
|
||||
if (this.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer.togglePauseOrStart();
|
||||
|
||||
this.currentPhaseChanged();
|
||||
@@ -93,39 +141,67 @@ export class LSOEventSink {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -136,6 +212,9 @@ export class LSOEventSink {
|
||||
}
|
||||
|
||||
public setGameTimeInner(timeSpan: TimeSpanRef): void {
|
||||
if (this.locked) {
|
||||
return;
|
||||
}
|
||||
this.timer.setGameTime(timeSpan);
|
||||
}
|
||||
|
||||
@@ -154,22 +233,37 @@ export class LSOEventSink {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -190,6 +284,10 @@ export class LSOEventSink {
|
||||
}
|
||||
|
||||
public markAsUnmodified(): void {
|
||||
if (this.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer.markAsUnmodified();
|
||||
this.splitsModifiedChanged();
|
||||
}
|
||||
|
||||
+76
-39
@@ -5,7 +5,7 @@ import {
|
||||
Timer, HotkeyConfig, LayoutState, LayoutStateJson,
|
||||
TimingMethod, TimerPhase,
|
||||
} from "../livesplit-core";
|
||||
import { convertFileToArrayBuffer, convertFileToString, exportFile, openFileAsString } from "../util/FileUtil";
|
||||
import { FILE_EXT_LAYOUTS, convertFileToArrayBuffer, convertFileToString, exportFile, openFileAsString } from "../util/FileUtil";
|
||||
import { Option, assertNull, expect, maybeDisposeAndThen, panic } from "../util/OptionUtil";
|
||||
import * as SplitsIO from "../util/SplitsIO";
|
||||
import { LayoutEditor as LayoutEditorComponent } from "./LayoutEditor";
|
||||
@@ -42,6 +42,16 @@ export enum MenuKind {
|
||||
About,
|
||||
}
|
||||
|
||||
function isMenuLocked(menuKind: MenuKind) {
|
||||
switch (menuKind) {
|
||||
case MenuKind.Timer:
|
||||
case MenuKind.Layout:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
type Menu =
|
||||
{ kind: MenuKind.Timer } |
|
||||
{ kind: MenuKind.Splits } |
|
||||
@@ -410,7 +420,10 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
|
||||
return <>
|
||||
{view}
|
||||
<DialogContainer />
|
||||
<DialogContainer
|
||||
onShow={() => this.lockTimerInteraction()}
|
||||
onClose={() => this.unlockTimerInteraction()}
|
||||
/>
|
||||
<ToastContainer
|
||||
position="bottom-right"
|
||||
toastClassName="toast-class"
|
||||
@@ -450,38 +463,65 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
private changeMenu(menu: Menu) {
|
||||
const wasLocked = isMenuLocked(this.state.menu.kind);
|
||||
const isLocked = isMenuLocked(menu.kind);
|
||||
|
||||
this.setState({ menu, sidebarOpen: false });
|
||||
|
||||
if (!wasLocked && isLocked) {
|
||||
this.lockTimerInteraction();
|
||||
} else if (wasLocked && !isLocked) {
|
||||
this.unlockTimerInteraction();
|
||||
}
|
||||
}
|
||||
|
||||
public openTimerView() {
|
||||
this.setState({
|
||||
menu: { kind: MenuKind.Timer },
|
||||
sidebarOpen: false,
|
||||
});
|
||||
this.state.hotkeySystem.activate();
|
||||
this.changeMenu({ kind: MenuKind.Timer });
|
||||
}
|
||||
|
||||
public openSplitsView() {
|
||||
this.setState({
|
||||
menu: { kind: MenuKind.Splits },
|
||||
sidebarOpen: false,
|
||||
});
|
||||
this.state.hotkeySystem.deactivate();
|
||||
this.changeMenu({ kind: MenuKind.Splits });
|
||||
}
|
||||
|
||||
public openLayoutView() {
|
||||
this.setState({
|
||||
menu: { kind: MenuKind.Layout },
|
||||
});
|
||||
this.changeMenu({ kind: MenuKind.Layout });
|
||||
}
|
||||
|
||||
public openAboutView() {
|
||||
this.setState({
|
||||
menu: { kind: MenuKind.About },
|
||||
sidebarOpen: false,
|
||||
});
|
||||
this.state.hotkeySystem.deactivate();
|
||||
this.changeMenu({ kind: MenuKind.About });
|
||||
}
|
||||
|
||||
private lockTimerInteraction() {
|
||||
if (!this.state.eventSink.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
|
||||
// result in a deadlock acquiring the internal state of the hotkey
|
||||
// system.
|
||||
setTimeout(() => this.state.hotkeySystem.deactivate());
|
||||
}
|
||||
this.state.eventSink.lockInteraction();
|
||||
}
|
||||
|
||||
private unlockTimerInteraction() {
|
||||
this.state.eventSink.unlockInteraction();
|
||||
if (!this.state.eventSink.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
|
||||
// result in a deadlock acquiring the internal state of the hotkey
|
||||
// system.
|
||||
setTimeout(() => this.state.hotkeySystem.activate());
|
||||
}
|
||||
}
|
||||
|
||||
public async importSplitsFromFile(file: File) {
|
||||
const splits = await convertFileToArrayBuffer(file);
|
||||
if (splits instanceof Error) {
|
||||
toast.error(`Failed to read the file: ${splits.message}`);
|
||||
return;
|
||||
}
|
||||
this.importSplitsFromArrayBuffer(splits);
|
||||
}
|
||||
|
||||
@@ -500,10 +540,14 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
public async importLayout() {
|
||||
const maybeFile = await openFileAsString();
|
||||
const maybeFile = await openFileAsString(FILE_EXT_LAYOUTS);
|
||||
if (maybeFile === undefined) {
|
||||
return;
|
||||
}
|
||||
if (maybeFile instanceof Error) {
|
||||
toast.error(`Failed to read the file: ${maybeFile.message}`);
|
||||
return;
|
||||
}
|
||||
const [file] = maybeFile;
|
||||
try {
|
||||
this.importLayoutFromString(file);
|
||||
@@ -513,7 +557,12 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
public async importLayoutFromFile(file: File) {
|
||||
const [fileString] = await convertFileToString(file);
|
||||
const maybeFile = await convertFileToString(file);
|
||||
if (maybeFile instanceof Error) {
|
||||
toast.error(`Failed to read the file: ${maybeFile.message}`);
|
||||
return;
|
||||
}
|
||||
const [fileString] = maybeFile;
|
||||
this.importLayoutFromString(fileString);
|
||||
}
|
||||
|
||||
@@ -532,10 +581,7 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
RunEditor.new(run),
|
||||
"The Run Editor should always be able to be opened.",
|
||||
);
|
||||
this.setState({
|
||||
menu: { kind: MenuKind.RunEditor, editor, splitsKey },
|
||||
sidebarOpen: false,
|
||||
});
|
||||
this.changeMenu({ kind: MenuKind.RunEditor, editor, splitsKey });
|
||||
}
|
||||
|
||||
public closeRunEditor(save: boolean) {
|
||||
@@ -569,17 +615,12 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
public openLayoutEditor() {
|
||||
this.state.hotkeySystem.deactivate();
|
||||
|
||||
const layout = this.state.layout.clone();
|
||||
const editor = expect(
|
||||
LayoutEditor.new(layout),
|
||||
"The Layout Editor should always be able to be opened.",
|
||||
);
|
||||
this.setState({
|
||||
menu: { kind: MenuKind.LayoutEditor, editor },
|
||||
sidebarOpen: false,
|
||||
});
|
||||
this.changeMenu({ kind: MenuKind.LayoutEditor, editor });
|
||||
}
|
||||
|
||||
public closeLayoutEditor(save: boolean) {
|
||||
@@ -597,13 +638,9 @@ export class LiveSplit extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
public openSettingsEditor() {
|
||||
this.state.hotkeySystem.deactivate();
|
||||
this.setState({
|
||||
menu: {
|
||||
kind: MenuKind.SettingsEditor,
|
||||
config: this.state.hotkeySystem.config(),
|
||||
},
|
||||
sidebarOpen: false,
|
||||
this.changeMenu({
|
||||
kind: MenuKind.SettingsEditor,
|
||||
config: this.state.hotkeySystem.config(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+47
-10
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { ContextMenu, ContextMenuTrigger, MenuItem } from "react-contextmenu";
|
||||
import * as LiveSplit from "../livesplit-core";
|
||||
import { openFileAsArrayBuffer } from "../util/FileUtil";
|
||||
import { FILE_EXT_IMAGES, FILE_EXT_SPLITS, openFileAsArrayBuffer } from "../util/FileUtil";
|
||||
import { TextBox } from "./TextBox";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
@@ -42,6 +42,7 @@ export interface State {
|
||||
attemptCountIsValid: boolean,
|
||||
rowState: RowState,
|
||||
tab: Tab,
|
||||
abortController: AbortController,
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
@@ -106,6 +107,7 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
splitTimeChanged: false,
|
||||
},
|
||||
tab: state.timing_method === "RealTime" ? Tab.RealTime : Tab.GameTime,
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
|
||||
if (props.generalSettings.speedrunComIntegration) {
|
||||
@@ -261,6 +263,11 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
private close(save: boolean) {
|
||||
this.state.abortController.abort();
|
||||
this.props.callbacks.closeRunEditor(save);
|
||||
}
|
||||
|
||||
private renderSidebarContent() {
|
||||
return (
|
||||
<div className="sidebar-buttons">
|
||||
@@ -269,13 +276,13 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
<div className="small">
|
||||
<button
|
||||
className="toggle-left"
|
||||
onClick={(_) => this.props.callbacks.closeRunEditor(true)}
|
||||
onClick={(_) => this.close(true)}
|
||||
>
|
||||
<i className="fa fa-check" aria-hidden="true" /> OK
|
||||
</button>
|
||||
<button
|
||||
className="toggle-right"
|
||||
onClick={(_) => this.props.callbacks.closeRunEditor(false)}
|
||||
onClick={(_) => this.close(false)}
|
||||
>
|
||||
<i className="fa fa-times" aria-hidden="true" /> Cancel
|
||||
</button>
|
||||
@@ -1564,10 +1571,14 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
private async importComparison() {
|
||||
const maybeFile = await openFileAsArrayBuffer();
|
||||
const maybeFile = await openFileAsArrayBuffer(FILE_EXT_SPLITS);
|
||||
if (maybeFile === undefined) {
|
||||
return;
|
||||
}
|
||||
if (maybeFile instanceof Error) {
|
||||
toast.error(`Failed to read the file: ${maybeFile.message}`);
|
||||
return;
|
||||
}
|
||||
const [data, file] = maybeFile;
|
||||
using result = LiveSplit.Run.parseArray(new Uint8Array(data), "");
|
||||
if (!result.parsedSuccessfully()) {
|
||||
@@ -1637,10 +1648,14 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
|
||||
private async changeSegmentIcon(index: number) {
|
||||
this.props.editor.selectOnly(index);
|
||||
const maybeFile = await openFileAsArrayBuffer();
|
||||
const maybeFile = await openFileAsArrayBuffer(FILE_EXT_IMAGES);
|
||||
if (maybeFile === undefined) {
|
||||
return;
|
||||
}
|
||||
if (maybeFile instanceof Error) {
|
||||
toast.error(`Failed to read the file: ${maybeFile.message}`);
|
||||
return;
|
||||
}
|
||||
const [file] = maybeFile;
|
||||
this.props.editor.activeSetIconFromArray(new Uint8Array(file));
|
||||
this.update();
|
||||
@@ -1656,10 +1671,14 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
private async changeGameIcon() {
|
||||
const maybeFile = await openFileAsArrayBuffer();
|
||||
const maybeFile = await openFileAsArrayBuffer(FILE_EXT_IMAGES);
|
||||
if (maybeFile === undefined) {
|
||||
return;
|
||||
}
|
||||
if (maybeFile instanceof Error) {
|
||||
toast.error(`Failed to read the file: ${maybeFile.message}`);
|
||||
return;
|
||||
}
|
||||
const [file] = maybeFile;
|
||||
this.props.editor.setGameIconFromArray(new Uint8Array(file));
|
||||
this.maybeUpdate();
|
||||
@@ -1993,6 +2012,7 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
private async downloadBoxArt() {
|
||||
const signal = this.state.abortController.signal;
|
||||
try {
|
||||
const gameName = this.state.editor.game;
|
||||
await downloadGameInfo(gameName);
|
||||
@@ -2000,8 +2020,11 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
if (game !== undefined) {
|
||||
const uri = game.assets["cover-medium"].uri;
|
||||
if (uri.startsWith("https://") && uri !== "https://www.speedrun.com/images/blankcover.png") {
|
||||
const response = await fetch(uri);
|
||||
const response = await fetch(uri, { signal });
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (this.props.editor.ptr === 0) {
|
||||
return;
|
||||
}
|
||||
this.props.editor.setGameIconFromArray(new Uint8Array(buffer));
|
||||
this.maybeUpdate();
|
||||
} else {
|
||||
@@ -2011,11 +2034,15 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
toast.error("Couldn't find the game.");
|
||||
}
|
||||
} catch {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
toast.error("Couldn't download the box art.");
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadIcon() {
|
||||
const signal = this.state.abortController.signal;
|
||||
try {
|
||||
const gameName = this.state.editor.game;
|
||||
await downloadGameInfo(gameName);
|
||||
@@ -2023,8 +2050,11 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
if (game !== undefined) {
|
||||
const uri = game.assets.icon.uri;
|
||||
if (uri.startsWith("https://") && uri !== "https://www.speedrun.com/images/1st.png") {
|
||||
const response = await fetch(uri);
|
||||
const response = await fetch(uri, { signal });
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (this.props.editor.ptr === 0) {
|
||||
return;
|
||||
}
|
||||
this.props.editor.setGameIconFromArray(new Uint8Array(buffer));
|
||||
this.maybeUpdate();
|
||||
} else {
|
||||
@@ -2034,6 +2064,9 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
toast.error("Couldn't find the game.");
|
||||
}
|
||||
} catch {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
toast.error("Couldn't download the icon.");
|
||||
}
|
||||
}
|
||||
@@ -2088,10 +2121,11 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
const baseUri = "https://splits.io/api/v3/runs/";
|
||||
assert(apiUri.startsWith(baseUri), "Unexpected Splits.io URL");
|
||||
const splitsId = apiUri.slice(baseUri.length);
|
||||
const signal = this.state.abortController.signal;
|
||||
try {
|
||||
const gameName = this.state.editor.game;
|
||||
const categoryName = this.state.editor.category;
|
||||
const runDownload = downloadById(splitsId);
|
||||
const runDownload = downloadById(splitsId, signal);
|
||||
const platformListDownload = downloadPlatformList();
|
||||
const regionListDownload = downloadRegionList();
|
||||
const gameInfoDownload = downloadGameInfo(gameName);
|
||||
@@ -2116,7 +2150,10 @@ export class RunEditor extends React.Component<Props, State> {
|
||||
} else {
|
||||
toast.error("The downloaded splits are not suitable for being edited.");
|
||||
}
|
||||
} catch (_) {
|
||||
} catch {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
toast.error("Failed to download the splits.");
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -5,10 +5,11 @@ import ColorPicker from "./ColorPicker";
|
||||
import HotkeyButton from "./HotkeyButton";
|
||||
import ToggleCheckbox from "./ToggleCheckbox";
|
||||
import { UrlCache } from "../util/UrlCache";
|
||||
import { openFileAsArrayBuffer } from "../util/FileUtil";
|
||||
import { FILE_EXT_IMAGES, openFileAsArrayBuffer } from "../util/FileUtil";
|
||||
import * as FontList from "../util/FontList";
|
||||
import { LiveSplitServer } from "../api/LiveSplitServer";
|
||||
import { showDialog } from "./Dialog";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import "../css/Tooltip.scss";
|
||||
import "../css/LiveSplitServerButton.scss";
|
||||
@@ -1251,10 +1252,14 @@ export class SettingsComponent<T> extends React.Component<Props<T>> {
|
||||
background: imageUrl ? `url("${imageUrl}") center / cover` : undefined,
|
||||
}}
|
||||
onClick={async (_) => {
|
||||
const maybeFile = await openFileAsArrayBuffer();
|
||||
const maybeFile = await openFileAsArrayBuffer(FILE_EXT_IMAGES);
|
||||
if (maybeFile === undefined) {
|
||||
return;
|
||||
}
|
||||
if (maybeFile instanceof Error) {
|
||||
toast.error(`Failed to read the file: ${maybeFile.message}`);
|
||||
return;
|
||||
}
|
||||
const [file] = maybeFile;
|
||||
const imageId = this.props.editorUrlCache.imageCache.cacheFromArray(
|
||||
new Uint8Array(file),
|
||||
|
||||
+26
-17
@@ -6,7 +6,7 @@ import {
|
||||
import { Run, Segment, TimerPhase } from "../livesplit-core";
|
||||
import * as SplitsIO from "../util/SplitsIO";
|
||||
import { toast } from "react-toastify";
|
||||
import { openFileAsArrayBuffer, exportFile, convertFileToArrayBuffer } from "../util/FileUtil";
|
||||
import { openFileAsArrayBuffer, exportFile, convertFileToArrayBuffer, FILE_EXT_SPLITS } from "../util/FileUtil";
|
||||
import { Option, bug, maybeDisposeAndThen } from "../util/OptionUtil";
|
||||
import DragUpload from "./DragUpload";
|
||||
import { ContextMenuTrigger, ContextMenu, MenuItem } from "react-contextmenu";
|
||||
@@ -314,34 +314,43 @@ export class SplitsSelection extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
private async importSplits() {
|
||||
const splits = await openFileAsArrayBuffer();
|
||||
const splits = await openFileAsArrayBuffer(FILE_EXT_SPLITS);
|
||||
if (splits === undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.importSplitsFromArrayBuffer(splits);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message);
|
||||
if (splits instanceof Error) {
|
||||
toast.error(`Failed to read the file: ${splits.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.importSplitsFromArrayBuffer(splits);
|
||||
if (result != null) {
|
||||
toast.error(`Failed to import the splits: ${result.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async importSplitsFromFile(file: File) {
|
||||
const splits = await convertFileToArrayBuffer(file);
|
||||
this.importSplitsFromArrayBuffer(splits);
|
||||
if (splits instanceof Error) {
|
||||
toast.error(`Failed to read the file: ${splits.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.importSplitsFromArrayBuffer(splits);
|
||||
if (result != null) {
|
||||
toast.error(`Failed to import the splits: ${result.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async importSplitsFromArrayBuffer(buffer: [ArrayBuffer, File]) {
|
||||
private async importSplitsFromArrayBuffer(buffer: [ArrayBuffer, File]): Promise<Option<Error>> {
|
||||
const [file] = buffer;
|
||||
const result = Run.parseArray(new Uint8Array(file), "");
|
||||
try {
|
||||
if (result.parsedSuccessfully()) {
|
||||
await this.storeRun(result.unwrap());
|
||||
} else {
|
||||
throw Error("Couldn't parse the splits.");
|
||||
}
|
||||
} finally {
|
||||
result[Symbol.dispose]();
|
||||
using result = Run.parseArray(new Uint8Array(file), "");
|
||||
if (result.parsedSuccessfully()) {
|
||||
await this.storeRun(result.unwrap());
|
||||
} else {
|
||||
return Error("Couldn't parse the splits.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
private async saveSplits() {
|
||||
|
||||
+54
-24
@@ -5,10 +5,15 @@ import { Option } from "./OptionUtil";
|
||||
// @ts-expect-error Unused variable due to above issue
|
||||
let fileInputElement = null; // eslint-disable-line
|
||||
|
||||
function openFile(): Promise<File | undefined> {
|
||||
export const FILE_EXT_SPLITS = ".lss";
|
||||
export const FILE_EXT_LAYOUTS = ".ls1l,.lsl";
|
||||
export const FILE_EXT_IMAGES = "image/*";
|
||||
|
||||
function openFile(accept: string): Promise<File | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("type", "file");
|
||||
input.setAttribute("accept", accept);
|
||||
input.onchange = () => {
|
||||
const file: Option<File> = input.files?.[0];
|
||||
if (file === undefined) {
|
||||
@@ -22,43 +27,68 @@ function openFile(): Promise<File | undefined> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function convertFileToArrayBuffer(file: File): Promise<[ArrayBuffer, File]> {
|
||||
return new Promise((resolve: (_: [ArrayBuffer, File]) => void) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const contents = reader.result as Option<ArrayBuffer>;
|
||||
if (contents != null) {
|
||||
resolve([contents, file]);
|
||||
export async function convertFileToArrayBuffer(file: File): Promise<[ArrayBuffer, File] | Error> {
|
||||
return new Promise((resolve: (_: [ArrayBuffer, File] | Error) => void) => {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const contents = reader.result as Option<ArrayBuffer>;
|
||||
if (contents != null) {
|
||||
resolve([contents, file]);
|
||||
} else {
|
||||
resolve(new Error("Failed to read the file."));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
resolve(new Error("Failed to read the file."));
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
resolve(e);
|
||||
} else {
|
||||
resolve(new Error("Unknown error while reading the file."));
|
||||
}
|
||||
};
|
||||
// FIXME: onerror
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function openFileAsArrayBuffer(): Promise<[ArrayBuffer, File] | undefined> {
|
||||
const file = await openFile();
|
||||
export async function openFileAsArrayBuffer(accept: string): Promise<[ArrayBuffer, File] | Error | undefined> {
|
||||
const file = await openFile(accept);
|
||||
if (file === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return convertFileToArrayBuffer(file);
|
||||
}
|
||||
|
||||
export async function convertFileToString(file: File): Promise<[string, File]> {
|
||||
return new Promise((resolve: (_: [string, File]) => void) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const contents = reader.result as Option<string>;
|
||||
if (contents != null) {
|
||||
resolve([contents, file]);
|
||||
export async function convertFileToString(file: File): Promise<[string, File] | Error> {
|
||||
return new Promise((resolve: (_: [string, File] | Error) => void) => {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const contents = reader.result as Option<string>;
|
||||
if (contents != null) {
|
||||
resolve([contents, file]);
|
||||
} else {
|
||||
resolve(new Error("Failed to read the file."));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
resolve(new Error("Failed to read the file."));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
resolve(e);
|
||||
} else {
|
||||
resolve(new Error("Unknown error while reading the file."));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function openFileAsString(): Promise<[string, File] | undefined> {
|
||||
const file = await openFile();
|
||||
export async function openFileAsString(accept: string): Promise<[string, File] | Error | undefined> {
|
||||
const file = await openFile(accept);
|
||||
if (file === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -74,13 +74,14 @@ export enum DownloadError {
|
||||
FailedParsing,
|
||||
}
|
||||
|
||||
export async function downloadById(id: string): Promise<Run> {
|
||||
export async function downloadById(id: string, signal?: AbortSignal): Promise<Run> {
|
||||
const response = await validatedFetch(
|
||||
`https://splits.io/api/v4/runs/${id}`,
|
||||
{
|
||||
headers: new Headers({
|
||||
Accept: "application/original-timer",
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
DownloadError.ApiRequestErrored,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user