diff --git a/livesplit-core b/livesplit-core index 5d2bd50..78aba86 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 5d2bd50010b1387c984d13aa6d91511fdace6996 +Subproject commit 78aba86a8b60d6135ecd333bef6bece0606f3016 diff --git a/src/css/variables.icss.scss b/src/css/variables.icss.scss index faf492e..042dcce 100644 --- a/src/css/variables.icss.scss +++ b/src/css/variables.icss.scss @@ -45,4 +45,5 @@ $selected-row-hover-color: linear-gradient( largeMargin: $ui-large-margin; manualGameTimeHeight: $manual-game-time-height; contributorAvatarSize: $contributor-avatar-size; + mainBackgroundColor: $main-background-color; } diff --git a/src/index.tsx b/src/index.tsx index 0099cc3..596f43f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -69,8 +69,7 @@ try { // as otherwise information may be cached incorrectly. try { const promises = []; - // TypeScript doesn't seem to know that the fonts are iterable. - for (const fontFace of document.fonts as any as Iterable) { + for (const fontFace of document.fonts) { if (fontFace.family === "timer" || fontFace.family === "fira") { promises.push(fontFace.load()); } diff --git a/src/platform/Hotkeys.ts b/src/platform/Hotkeys.ts index cfd3947..d0ee10b 100644 --- a/src/platform/Hotkeys.ts +++ b/src/platform/Hotkeys.ts @@ -2,6 +2,7 @@ import { CommandSinkRef, HotkeyConfig, HotkeySystem } from "../livesplit-core"; import { expect } from "../util/OptionUtil"; export interface HotkeyImplementation { + ptr: number; config(): Promise | HotkeyConfig; setConfig(config: HotkeyConfig): void; activate(): void; @@ -10,7 +11,10 @@ export interface HotkeyImplementation { } class GlobalHotkeys implements HotkeyImplementation { - constructor(private hotkeySystem?: HotkeySystem) {} + public ptr: number; + constructor(private hotkeySystem?: HotkeySystem) { + this.ptr = hotkeySystem?.ptr ?? 0; + } public async config(): Promise { return expect( diff --git a/src/type-definitions/assets.d.ts b/src/type-definitions/assets.d.ts new file mode 100644 index 0000000..8c76d68 --- /dev/null +++ b/src/type-definitions/assets.d.ts @@ -0,0 +1,5 @@ +declare module "*.scss"; +declare module "*.css"; +declare module "*.woff"; +declare module "*.svg"; +declare module "*.wasm"; diff --git a/src/type-definitions/images.d.ts b/src/type-definitions/images.d.ts deleted file mode 100644 index dd4ab7e..0000000 --- a/src/type-definitions/images.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "*.svg"; diff --git a/src/type-definitions/scss.d.ts b/src/type-definitions/scss.d.ts deleted file mode 100644 index fbb911b..0000000 --- a/src/type-definitions/scss.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare module "*.scss"; -declare module "*.css"; diff --git a/src/type-definitions/wasm.d.ts b/src/type-definitions/wasm.d.ts deleted file mode 100644 index 26c59c0..0000000 --- a/src/type-definitions/wasm.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "*.wasm"; diff --git a/src/ui/LiveSplit.tsx b/src/ui/LiveSplit.tsx index 66d6eb0..8c9d72a 100644 --- a/src/ui/LiveSplit.tsx +++ b/src/ui/LiveSplit.tsx @@ -13,7 +13,9 @@ import { TimingMethod, TimerPhase, Event, + LayoutRefMut, } from "../livesplit-core"; +import { Layout as ShowLayout } from "./components/Layout"; import { FILE_EXT_LAYOUTS, convertFileToArrayBuffer, @@ -41,15 +43,24 @@ import { LayoutView } from "./views/LayoutView"; import { ToastContainer, toast } from "react-toastify"; import * as Storage from "../storage"; import { UrlCache } from "../util/UrlCache"; -import { ServerProtocol, WebRenderer } from "../livesplit-core/livesplit_core"; +import { + HotkeySystem_add_window, + ServerProtocol, + WebRenderer, +} from "../livesplit-core/livesplit_core"; import { LiveSplitServer } from "../api/LiveSplitServer"; import { LSOCommandSink } from "../util/LSOCommandSink"; import { DialogContainer } from "./components/Dialog"; import { createHotkeys, HotkeyImplementation } from "../platform/Hotkeys"; import { Menu } from "lucide-react"; +import { createRoot } from "react-dom/client"; import * as variables from "../css/variables.icss.scss"; +import LiveSplitIcon from "../assets/icon.svg"; +import timerFont from "../css/timer.woff"; +import firaFont from "../css/FiraSans-Regular.woff"; + import "react-toastify/dist/ReactToastify.css"; import * as classes from "../css/LiveSplit.module.scss"; import * as sidebarClasses from "../css/Sidebar.module.scss"; @@ -1068,4 +1079,112 @@ export class LiveSplit extends React.Component { document.title = "LiveSplit One"; } } + + popOut(): void { + const { layoutWidth, layoutHeight, generalSettings } = this.state; + popOut( + this.state.commandSink, + () => this.state.layout, + generalSettings, + layoutWidth, + layoutHeight, + ); + } +} + +async function popOut( + commandSink: LSOCommandSink, + getLayout: () => LayoutRefMut, + generalSettings: GeneralSettings, + width: number, + height: number, +) { + const childWindow = window.open( + "", + "_blank", + `popup,width=${width},height=${height}`, + ); + if (!childWindow) { + return; + } + + const childDoc = childWindow.document; + + childDoc.title = "LiveSplit One"; + + const link = childDoc.createElement("link"); + link.rel = "icon"; + link.type = "image/svg+xml"; + link.href = LiveSplitIcon; + childDoc.head.appendChild(link); + + childDoc.body.style.margin = "0"; + childDoc.body.style.background = variables.mainBackgroundColor; + + const timerFontFace = new FontFace("timer", `url(${timerFont})`); + const firaFontFace = new FontFace("fira", `url(${firaFont})`); + childDoc.fonts.add(timerFontFace); + childDoc.fonts.add(firaFontFace); + await Promise.all([timerFontFace.load(), firaFontFace.load()]); + + const layoutState = LayoutState.new(); + const urlCache = new UrlCache(); + + const renderer = new WebRenderer(); + childWindow.addEventListener("unload", async () => { + async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + while (!childWindow.closed) { + await sleep(1); + } + renderer.free(); + layoutState[Symbol.dispose](); + urlCache.imageCache[Symbol.dispose](); + }); + + const element = renderer.element(); + element.style.width = "100%"; + element.style.height = "100%"; + + if (hotkeySystem) HotkeySystem_add_window(hotkeySystem.ptr, childWindow); + + createRoot(childDoc.body).render( + { + const layout = getLayout(); + if (layout.ptr !== 0) { + commandSink.updateLayoutState( + layout, + layoutState, + urlCache.imageCache, + ); + urlCache.collect(); + } + return layoutState; + }} + layoutUrlCache={urlCache} + allowResize={false} + width="100%" + height="100%" + generalSettings={generalSettings} + renderer={renderer} + onResize={(width, height) => + childWindow.resizeTo( + width + childWindow.outerWidth - childWindow.innerWidth, + height + childWindow.outerHeight - childWindow.innerHeight, + ) + } + onScroll={(e) => { + const delta = Math.sign(-e.deltaY); + const layout = getLayout(); + if (delta === 1) { + layout.scrollUp(); + } else if (delta === -1) { + layout.scrollDown(); + } + }} + window={childWindow} + />, + ); } diff --git a/src/ui/components/Layout.tsx b/src/ui/components/Layout.tsx index d7b87f1..d6b4108 100644 --- a/src/ui/components/Layout.tsx +++ b/src/ui/components/Layout.tsx @@ -8,7 +8,7 @@ import { GeneralSettings } from "../views/MainSettings"; import * as classes from "../../css/Layout.module.scss"; -export default function Layout({ +export function Layout({ getState, layoutUrlCache, allowResize, @@ -17,16 +17,24 @@ export default function Layout({ generalSettings, renderer, onResize, + onScroll, + window, }: { getState: () => LayoutStateRef; layoutUrlCache: UrlCache; - allowResize: boolean; - width: number; - height: number; generalSettings: GeneralSettings; renderer: WebRenderer; onResize: (width: number, height: number) => void; -}) { + onScroll?: (e: WheelEvent) => void; + window: Window; +} & ( + | { + allowResize: false; + width: string | number; + height: string | number; + } + | { allowResize: true; width: number; height: number } +)) { const update = () => { const layoutState = getState(); const newDims = renderer.render( @@ -39,12 +47,19 @@ export default function Layout({ }; return ( - +
{ element?.appendChild(renderer.element()); + if (onScroll) { + element?.addEventListener("wheel", onScroll); + } }} /> {allowResize && ( diff --git a/src/ui/views/LayoutEditor.tsx b/src/ui/views/LayoutEditor.tsx index 30030e9..05a0213 100644 --- a/src/ui/views/LayoutEditor.tsx +++ b/src/ui/views/LayoutEditor.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import * as LiveSplit from "../../livesplit-core"; import { SettingsComponent } from "../components/Settings"; import { UrlCache } from "../../util/UrlCache"; -import Layout from "../components/Layout"; +import { Layout } from "../components/Layout"; import { WebRenderer } from "../../livesplit-core/livesplit_core"; import { GeneralSettings } from "./MainSettings"; import { LSOCommandSink } from "../../util/LSOCommandSink"; @@ -271,6 +271,7 @@ export function View({ onResize={(width, height) => callbacks.onResize(width, height) } + window={window} />
diff --git a/src/ui/views/LayoutView.tsx b/src/ui/views/LayoutView.tsx index ccffeb1..966d79f 100644 --- a/src/ui/views/LayoutView.tsx +++ b/src/ui/views/LayoutView.tsx @@ -67,6 +67,7 @@ interface Callbacks { saveLayout(): void; onServerConnectionOpened(serverConnection: LiveSplitServer): void; onServerConnectionClosed(): void; + popOut(): void; } export function LayoutView(props: Props) { diff --git a/src/ui/views/MainSettings.tsx b/src/ui/views/MainSettings.tsx index 3166a80..96c3a67 100644 --- a/src/ui/views/MainSettings.tsx +++ b/src/ui/views/MainSettings.tsx @@ -71,11 +71,20 @@ interface Callbacks { } export function MainSettings(props: Props) { + // TODO: Use memo instead? + const [generalSettingsState, setGeneralSettings] = useState(() => ({ + ...props.generalSettings, + })); + return props.callbacks.renderViewWithSidebar( - , + , , ); } @@ -89,14 +98,13 @@ export function View({ commandSink, allComparisons, allVariables, -}: Props) { + setGeneralSettings, +}: Props & { + setGeneralSettings: React.Dispatch>; +}) { const [settings, setSettings] = useState(() => hotkeyConfig.settingsDescriptionAsJson(), ); - // TODO: Use memo instead? - const [generalSettingsState, setGeneralSettings] = useState(() => ({ - ...generalSettings, - })); const [, forceUpdate] = useState({}); const update = () => { @@ -111,14 +119,12 @@ export function View({ value: { CustomCombobox: { value: - generalSettingsState.frameRate === - FRAME_RATE_MATCH_SCREEN + generalSettings.frameRate === FRAME_RATE_MATCH_SCREEN ? FRAME_RATE_MATCH_SCREEN - : generalSettingsState.frameRate === + : generalSettings.frameRate === FRAME_RATE_BATTERY_AWARE ? FRAME_RATE_BATTERY_AWARE - : generalSettingsState.frameRate.toString() + - " FPS", + : generalSettings.frameRate.toString() + " FPS", list: [ FRAME_RATE_BATTERY_AWARE, "30 FPS", @@ -135,27 +141,27 @@ export function View({ tooltip: "Determines whether to automatically save the splits when resetting the timer.", value: { - Bool: generalSettingsState.saveOnReset, + Bool: generalSettings.saveOnReset, }, }, { text: "Show Control Buttons", tooltip: "Determines whether to show buttons beneath the timer that allow controlling it. When disabled, you have to use the hotkeys instead.", - value: { Bool: generalSettingsState.showControlButtons }, + value: { Bool: generalSettings.showControlButtons }, }, { text: "Show Manual Game Time Input", tooltip: 'Shows a text box beneath the timer that allows you to manually input the game time. You start the timer and do splits by pressing the Enter key in the text box. Make sure to compare against "Game Time".', value: { - Bool: generalSettingsState.showManualGameTime !== false, + Bool: generalSettings.showManualGameTime !== false, }, }, ]; let manualGameTimeModeIndex = 0; - if (generalSettingsState.showManualGameTime) { + if (generalSettings.showManualGameTime) { manualGameTimeModeIndex = generalFields.length; generalFields.push({ text: "Manual Game Time Mode", @@ -163,7 +169,7 @@ export function View({ "Determines whether to input the manual game time as segment times or split times.", value: { CustomCombobox: { - value: generalSettingsState.showManualGameTime.mode, + value: generalSettings.showManualGameTime.mode, list: [ MANUAL_GAME_TIME_MODE_SEGMENT_TIMES, MANUAL_GAME_TIME_MODE_SPLIT_TIMES, @@ -180,7 +186,7 @@ export function View({ generalFields.push({ text: "Always On Top", tooltip: "Keeps the window always on top of other windows.", - value: { Bool: generalSettingsState.alwaysOnTop! }, + value: { Bool: generalSettings.alwaysOnTop! }, }); } @@ -217,7 +223,7 @@ export function View({ case 0: if ("String" in value) { setGeneralSettings({ - ...generalSettingsState, + ...generalSettings, frameRate: value.String === FRAME_RATE_MATCH_SCREEN ? FRAME_RATE_MATCH_SCREEN @@ -234,7 +240,7 @@ export function View({ case 1: if ("Bool" in value) { setGeneralSettings({ - ...generalSettingsState, + ...generalSettings, saveOnReset: value.Bool, }); } @@ -242,7 +248,7 @@ export function View({ case 2: if ("Bool" in value) { setGeneralSettings({ - ...generalSettingsState, + ...generalSettings, showControlButtons: value.Bool, }); } @@ -250,7 +256,7 @@ export function View({ case 3: if ("Bool" in value) { setGeneralSettings({ - ...generalSettingsState, + ...generalSettings, showManualGameTime: value.Bool ? MANUAL_GAME_TIME_SETTINGS_DEFAULT : false, @@ -260,7 +266,7 @@ export function View({ default: if (index === alwaysOnTopIndex && "Bool" in value) { setGeneralSettings({ - ...generalSettingsState, + ...generalSettings, alwaysOnTop: value.Bool, }); } else if ( @@ -268,7 +274,7 @@ export function View({ "String" in value ) { setGeneralSettings({ - ...generalSettingsState, + ...generalSettings, showManualGameTime: { mode: value.String, }, @@ -289,7 +295,7 @@ export function View({ tooltip: "Queries the list of games, categories, and the leaderboards from speedrun.com.", value: { - Bool: generalSettingsState.speedrunComIntegration, + Bool: generalSettings.speedrunComIntegration, }, }, { @@ -339,7 +345,7 @@ export function View({ case 0: if ("Bool" in value) { setGeneralSettings({ - ...generalSettingsState, + ...generalSettings, speedrunComIntegration: value.Bool, }); } @@ -360,7 +366,7 @@ export function View({ // It's fine if it fails. } setGeneralSettings({ - ...generalSettingsState, + ...generalSettings, serverUrl: value.String, }); } diff --git a/src/ui/views/TimerView.tsx b/src/ui/views/TimerView.tsx index 73cf798..1d07f92 100644 --- a/src/ui/views/TimerView.tsx +++ b/src/ui/views/TimerView.tsx @@ -8,7 +8,7 @@ import { import * as LiveSplit from "../../livesplit-core"; import { Option, expect } from "../../util/OptionUtil"; import { DragUpload } from "../components/DragUpload"; -import Layout from "../components/Layout"; +import { Layout } from "../components/Layout"; import { UrlCache } from "../../util/UrlCache"; import { WebRenderer } from "../../livesplit-core/livesplit_core"; import { @@ -25,6 +25,7 @@ import { Layers, List, Pause, + PictureInPicture2, Play, Settings, X, @@ -73,6 +74,7 @@ interface Callbacks { ): React.JSX.Element; onServerConnectionClosed(): void; onServerConnectionOpened(serverConnection: LiveSplitServer): void; + popOut(): void; } export function TimerView(props: Props) { @@ -143,6 +145,7 @@ function View({ onResize={(width, height) => callbacks.onResize(width, height) } + window={window} /> @@ -343,6 +346,9 @@ export function SideBar({
+ diff --git a/src/util/AutoRefresh.tsx b/src/util/AutoRefresh.tsx index 0587359..750e77c 100644 --- a/src/util/AutoRefresh.tsx +++ b/src/util/AutoRefresh.tsx @@ -16,10 +16,12 @@ export default function AutoRefresh({ frameRate, update, children, + window, }: { frameRate: FrameRateSetting; update: () => void; children: React.ReactNode; + window: Window; }) { const { current: state } = React.useRef({ previousTime: 0, @@ -30,7 +32,7 @@ export default function AutoRefresh({ state.update = update; const animate = React.useCallback(() => { - state.reqId = requestAnimationFrame(animate); + state.reqId = window.requestAnimationFrame(animate); let frameRate = state.frameRate; if (frameRate === FRAME_RATE_AUTOMATIC) { @@ -58,7 +60,7 @@ export default function AutoRefresh({ return () => { if (state.reqId) { - cancelAnimationFrame(state.reqId); + window.cancelAnimationFrame(state.reqId); } }; }, []); diff --git a/tsconfig.json b/tsconfig.json index e95a508..023e883 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ "moduleResolution": "Node", "lib": [ "ES2022", - "DOM" + "DOM", + "dom.iterable" ] }, "include": [