Allow Popping Out the Timer Into a Separate Window (#1046)

This adds a new button to the sidebar that allows the user to pop out
the entire layout into a separate child window. This makes it take up
less of the screen and capture it more easily in OBS. I'm not sure if
the button should be in the sidebar or not, but it's good enough for an
initial implementation.

Changelog: You can now pop out the timer into a separate window.
This commit is contained in:
Christopher Serr
2025-04-12 21:45:25 +02:00
committed by GitHub
parent 88b874d8b2
commit a3a08bd8e6
16 changed files with 203 additions and 47 deletions
+1
View File
@@ -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;
}
+1 -2
View File
@@ -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<FontFace>) {
for (const fontFace of document.fonts) {
if (fontFace.family === "timer" || fontFace.family === "fira") {
promises.push(fontFace.load());
}
+5 -1
View File
@@ -2,6 +2,7 @@ import { CommandSinkRef, HotkeyConfig, HotkeySystem } from "../livesplit-core";
import { expect } from "../util/OptionUtil";
export interface HotkeyImplementation {
ptr: number;
config(): Promise<HotkeyConfig> | 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<HotkeyConfig> {
return expect(
+5
View File
@@ -0,0 +1,5 @@
declare module "*.scss";
declare module "*.css";
declare module "*.woff";
declare module "*.svg";
declare module "*.wasm";
-1
View File
@@ -1 +0,0 @@
declare module "*.svg";
-2
View File
@@ -1,2 +0,0 @@
declare module "*.scss";
declare module "*.css";
-1
View File
@@ -1 +0,0 @@
declare module "*.wasm";
+120 -1
View File
@@ -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<Props, State> {
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(
<ShowLayout
getState={() => {
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}
/>,
);
}
+21 -6
View File
@@ -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 (
<AutoRefresh frameRate={generalSettings.frameRate} update={update}>
<AutoRefresh
frameRate={generalSettings.frameRate}
update={update}
window={window}
>
<div style={{ width, height }}>
<div
style={{ width, height }}
ref={(element) => {
element?.appendChild(renderer.element());
if (onScroll) {
element?.addEventListener("wheel", onScroll);
}
}}
/>
{allowResize && (
+2 -1
View File
@@ -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}
/>
</div>
</div>
+1
View File
@@ -67,6 +67,7 @@ interface Callbacks {
saveLayout(): void;
onServerConnectionOpened(serverConnection: LiveSplitServer): void;
onServerConnectionClosed(): void;
popOut(): void;
}
export function LayoutView(props: Props) {
+33 -27
View File
@@ -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(
<View {...props} />,
<View
{...props}
generalSettings={generalSettingsState}
setGeneralSettings={setGeneralSettings}
/>,
<SideBar
callbacks={props.callbacks}
generalSettings={props.generalSettings}
generalSettings={generalSettingsState}
/>,
);
}
@@ -89,14 +98,13 @@ export function View({
commandSink,
allComparisons,
allVariables,
}: Props) {
setGeneralSettings,
}: Props & {
setGeneralSettings: React.Dispatch<React.SetStateAction<GeneralSettings>>;
}) {
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,
});
}
+7 -1
View File
@@ -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}
/>
</div>
</div>
@@ -343,6 +346,9 @@ export function SideBar({
</button>
</div>
<hr />
<button onClick={() => callbacks.popOut()}>
<PictureInPicture2 strokeWidth={2.5} /> Pop Out
</button>
<button onClick={() => callbacks.openMainSettings()}>
<Settings strokeWidth={2.5} /> Settings
</button>
+4 -2
View File
@@ -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<State>({
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);
}
};
}, []);
+2 -1
View File
@@ -18,7 +18,8 @@
"moduleResolution": "Node",
"lib": [
"ES2022",
"DOM"
"DOM",
"dom.iterable"
]
},
"include": [