mirror of
https://github.com/ApfelTeeSaft/LiveSplitOne.git
synced 2026-08-26 19:23:40 +00:00
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:
+1
-1
Submodule livesplit-core updated: 5d2bd50010...78aba86a8b
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module "*.scss";
|
||||
declare module "*.css";
|
||||
declare module "*.woff";
|
||||
declare module "*.svg";
|
||||
declare module "*.wasm";
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
declare module "*.svg";
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
declare module "*.scss";
|
||||
declare module "*.css";
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
declare module "*.wasm";
|
||||
+120
-1
@@ -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}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -67,6 +67,7 @@ interface Callbacks {
|
||||
saveLayout(): void;
|
||||
onServerConnectionOpened(serverConnection: LiveSplitServer): void;
|
||||
onServerConnectionClosed(): void;
|
||||
popOut(): void;
|
||||
}
|
||||
|
||||
export function LayoutView(props: Props) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -18,7 +18,8 @@
|
||||
"moduleResolution": "Node",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
"DOM",
|
||||
"dom.iterable"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
|
||||
Reference in New Issue
Block a user