From bcc1e9f2183db80cd3a4dcc421c91fec4dd8fc56 Mon Sep 17 00:00:00 2001 From: Paul Horn Date: Sat, 18 Jul 2026 23:08:57 +0200 Subject: [PATCH] Add option to share split times to YouTube or LosslessCut --- src/css/ShareTimes.module.css | 114 ++++++++ src/ui/LiveSplit.tsx | 22 ++ src/ui/components/TextBox.tsx | 6 + src/ui/views/ShareTimes.tsx | 313 ++++++++++++++++++++ src/ui/views/SplitsSelection.tsx | 11 + src/util/ShareTimes.ts | 488 +++++++++++++++++++++++++++++++ 6 files changed, 954 insertions(+) create mode 100644 src/css/ShareTimes.module.css create mode 100644 src/ui/views/ShareTimes.tsx create mode 100644 src/util/ShareTimes.ts diff --git a/src/css/ShareTimes.module.css b/src/css/ShareTimes.module.css new file mode 100644 index 00000000..48d34f46 --- /dev/null +++ b/src/css/ShareTimes.module.css @@ -0,0 +1,114 @@ +.shareTimes { + display: flex; + padding: var(--ui-large-margin); + + :global(.is-mobile) & { + width: 100%; + box-sizing: border-box; + } +} + +.form { + display: flex; + flex-direction: column; + gap: var(--ui-large-margin); + width: 100%; + max-width: 480px; +} + +.field { + display: flex; + flex-direction: column; + gap: var(--ui-margin); +} + +.label { + font-weight: bold; +} + +.runSelect { + width: 100%; + height: 40px; + box-sizing: border-box; + padding: 0 6px; + appearance: none; + background: var(--button-middle-color); + border: 1px solid var(--border-color); + border-radius: 5px; + color: var(--button-text-color); + cursor: pointer; + font-family: "fira", sans-serif; + font-size: 20px; + text-align: left; + text-overflow: ellipsis; +} + +.offsetRow { + display: flex; + gap: var(--ui-margin); + align-items: flex-end; + width: 100%; +} + +.offsetCell { + flex: 5 1 0; + min-width: 0; +} + +.fpsCell { + flex: 1 1 0; + min-width: 0; +} + +.error { + color: #e46; + font-size: 0.9em; +} + +.warning { + color: #e9a13b; + font-size: 0.9em; +} + +.timingMethod { + opacity: 0.7; +} + +.hidden { + visibility: hidden; +} + +.activeTab { + box-shadow: inset 3px 0 0 var(--button-text-color); + font-weight: bold; +} + +.buttons { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--ui-margin); + + button { + margin: 0; + } +} + +.youtube { + display: flex; + flex-direction: column; + gap: var(--ui-margin); +} + +.youtubeText { + width: 100%; + box-sizing: border-box; + resize: vertical; + font-family: var(--fira, monospace); + white-space: pre; +} + +.copyButton { + align-self: flex-start; + margin: 0; +} diff --git a/src/ui/LiveSplit.tsx b/src/ui/LiveSplit.tsx index eb87b8e4..1c0ac0d8 100644 --- a/src/ui/LiveSplit.tsx +++ b/src/ui/LiveSplit.tsx @@ -42,6 +42,7 @@ import { import { TimerView } from "./views/TimerView"; import { About } from "./views/About"; import { SplitsSelection, EditingInfo } from "./views/SplitsSelection"; +import { ShareTimes, ShareTimesInfo } from "./views/ShareTimes"; import { LayoutView } from "./views/LayoutView"; import { ToastContainer, toast } from "react-toastify"; import * as Storage from "../storage"; @@ -99,6 +100,7 @@ export enum MenuKind { Timer, Splits, RunEditor, + ShareTimes, Layout, LayoutEditor, MainSettings, @@ -119,6 +121,7 @@ type Menu = | { kind: MenuKind.Timer } | { kind: MenuKind.Splits } | { kind: MenuKind.RunEditor; editor: RunEditor; splitsKey?: number } + | { kind: MenuKind.ShareTimes; run: Run; splitsKey?: number } | { kind: MenuKind.Layout } | { kind: MenuKind.LayoutEditor; editor: LayoutEditor } | { kind: MenuKind.MainSettings; config: HotkeyConfig } @@ -478,6 +481,10 @@ export class LiveSplit extends React.Component { splitsModified={this.state.splitsModified} /> ); + } else if (this.state.menu.kind === MenuKind.ShareTimes) { + view = ( + + ); } else if (this.state.menu.kind === MenuKind.Timer) { view = ( { this.openSplitsView(); } + public openShareTimes({ splitsKey, run }: ShareTimesInfo) { + this.changeMenu({ kind: MenuKind.ShareTimes, run, splitsKey }); + } + + public closeShareTimes() { + if (this.state.menu.kind !== MenuKind.ShareTimes) { + panic( + "No Share Times view to close", + this.state.generalSettings.lang, + ); + } + this.state.menu.run[Symbol.dispose](); + this.openSplitsView(); + } + public setSplitsKey(openedSplitsKey?: number) { this.setState({ openedSplitsKey, diff --git a/src/ui/components/TextBox.tsx b/src/ui/components/TextBox.tsx index c6a36ffb..97b9f76f 100644 --- a/src/ui/components/TextBox.tsx +++ b/src/ui/components/TextBox.tsx @@ -10,6 +10,8 @@ export function TextBox({ label, invalid, list, + disabled, + placeholder, }: { className?: string; value?: string | number | readonly string[]; @@ -18,6 +20,8 @@ export function TextBox({ label: string; invalid?: boolean; list?: [string, string[]]; + disabled?: boolean; + placeholder?: string; }) { let outerClassName = classes.group; if (invalid) { @@ -43,6 +47,8 @@ export function TextBox({ list={name} type="text text-box" required + disabled={disabled} + placeholder={placeholder} className={className} value={value} onChange={onChange} diff --git a/src/ui/views/ShareTimes.tsx b/src/ui/views/ShareTimes.tsx new file mode 100644 index 00000000..24e17a0b --- /dev/null +++ b/src/ui/views/ShareTimes.tsx @@ -0,0 +1,313 @@ +import React, { useMemo, useState } from "react"; +import { toast } from "react-toastify"; +import { ArrowLeft, Clipboard, Download, ListVideo, Scissors } from "lucide-react"; + +import { Run, RunRef, TimingMethod } from "../../livesplit-core"; +import { TextBox } from "../components/TextBox"; +import { exportFile } from "../../util/FileUtil"; +import { + buildRows, + buildSelectionEntries, + chooseTimingMethod, + formatFps, + parseYoutubeOffset, + resolveLosslessCutOffset, + rowsToFramesCsv, + rowsToYoutube, + CsvSource, +} from "../../util/ShareTimes"; + +import classes from "../../css/ShareTimes.module.css"; + +type Tab = "youtube" | "losslesscut"; + +export interface ShareTimesInfo { + splitsKey?: number; + run: Run; +} + +interface Callbacks { + renderViewWithSidebar( + renderedView: React.JSX.Element, + sidebarContent: React.JSX.Element, + ): React.JSX.Element; + closeShareTimes(): void; +} + +export function ShareTimes({ + run, + callbacks, +}: { + run: RunRef; + callbacks: Callbacks; +}) { + const [tab, setTab] = useState("youtube"); + return callbacks.renderViewWithSidebar( + , + , + ); +} + +function View({ run, tab }: { run: RunRef; tab: Tab }) { + const method = useMemo(() => chooseTimingMethod(run), [run]); + const entries = useMemo( + () => buildSelectionEntries(run, method), + [run, method], + ); + + const [selectedValue, setSelectedValue] = useState( + () => entries[0]?.value ?? "", + ); + + const source: CsvSource | undefined = useMemo( + () => entries.find((e) => e.value === selectedValue)?.source, + [entries, selectedValue], + ); + + const runSelector = ( +
+ Run / Time + +
+ ); + + const timingMethod = ( +
+ Timing method:{" "} + {method === TimingMethod.GameTime ? "Game Time" : "Real Time"} +
+ ); + + return ( +
+
+ {runSelector} + {tab === "youtube" ? ( + + ) : ( + + )} +
+
+ ); +} + +function YoutubeTab({ + run, + source, + method, + timingMethod, +}: { + run: RunRef; + source: CsvSource | undefined; + method: TimingMethod; + timingMethod: React.JSX.Element; +}) { + const [offsetInput, setOffsetInput] = useState(""); + const offset = useMemo(() => parseYoutubeOffset(offsetInput), [offsetInput]); + + const youtube = useMemo(() => { + if (source === undefined || !offset.valid) { + return { text: "", adjusted: false }; + } + return rowsToYoutube( + buildRows(run, source, method, offset.offsetSeconds), + ); + }, [run, source, method, offset]); + + const onCopy = async () => { + try { + await navigator.clipboard.writeText(youtube.text); + toast.success("Copied to clipboard."); + } catch { + toast.error("Failed to copy to the clipboard."); + } + }; + + return ( + <> +
+ setOffsetInput(e.target.value)} + /> + {!offset.valid && ( + + The offset couldn't be parsed. + + )} +
+ + {timingMethod} + +
+