From e99b31b706c23d7952746e63beef03f9ab2d9fda Mon Sep 17 00:00:00 2001 From: Andres Date: Sat, 10 May 2025 15:32:39 +0200 Subject: [PATCH] add obs control panel --- bun.lock | 5 + package.json | 1 + .../_components/obs-control-panel-client.tsx | 455 ++++++++++++++++++ .../_components/player-selector.tsx | 90 ++++ .../admin/stream/obs-control-panel/page.tsx | 18 + src/app/layout.tsx | 2 + .../[id]/_components/stream-card-client.tsx | 15 +- src/lib/obs-connection.ts | 90 ++++ 8 files changed, 667 insertions(+), 9 deletions(-) create mode 100644 src/app/(home)/admin/stream/obs-control-panel/_components/obs-control-panel-client.tsx create mode 100644 src/app/(home)/admin/stream/obs-control-panel/_components/player-selector.tsx create mode 100644 src/app/(home)/admin/stream/obs-control-panel/page.tsx create mode 100644 src/lib/obs-connection.ts diff --git a/bun.lock b/bun.lock index b3ebcf4..a925d69 100644 --- a/bun.lock +++ b/bun.lock @@ -76,6 +76,7 @@ "superjson": "^2.2.1", "tailwind-merge": "^3.1.0", "tw-animate-css": "^1.2.5", + "usehooks-ts": "^3.1.1", "vaul": "^1.1.2", "zod": "^3.24.2", }, @@ -773,6 +774,8 @@ "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], @@ -1117,6 +1120,8 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], diff --git a/package.json b/package.json index cf33c60..8385ad5 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "superjson": "^2.2.1", "tailwind-merge": "^3.1.0", "tw-animate-css": "^1.2.5", + "usehooks-ts": "^3.1.1", "vaul": "^1.1.2", "zod": "^3.24.2" }, diff --git a/src/app/(home)/admin/stream/obs-control-panel/_components/obs-control-panel-client.tsx b/src/app/(home)/admin/stream/obs-control-panel/_components/obs-control-panel-client.tsx new file mode 100644 index 0000000..8c03898 --- /dev/null +++ b/src/app/(home)/admin/stream/obs-control-panel/_components/obs-control-panel-client.tsx @@ -0,0 +1,455 @@ +'use client' + +import { getPlayerData } from '@/app/stream-card/[id]/_components/stream-card-client' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { OBSController } from '@/lib/obs-connection' +import { RANKED_CHANNEL } from '@/shared/constants' +import { api } from '@/trpc/react' +import { SettingsIcon, X } from 'lucide-react' +import * as React from 'react' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { useLocalStorage } from 'usehooks-ts' +import { PlayerSelector } from './player-selector' + +const obs = new OBSController() + +export function ObsControlPanelClient() { + const [isConnected, setIsConnected] = useState(false) + + const players = api.leaderboard.get_leaderboard.useQuery({ + channel_id: RANKED_CHANNEL, + }) + + const playersForSelect = players.data?.map((player) => ({ + value: player.id, + label: player.name, + })) + const [open, setOpen] = React.useState(false) + const [open2, setOpen2] = React.useState(false) + const [value1, setValue1] = React.useState('') + const [value2, setValue2] = React.useState('') + const form = useForm({ + defaultValues: { + player1Name: '', + player2Name: '', + player1Mmr: '0', + player2Mmr: '0', + player1Games: '0', + player2Games: '0', + player1Wins: '0', + player2Wins: '0', + player1Losses: '0', + player2Losses: '0', + player1Rank: '0', + player2Rank: '0', + player1WinRate: '0', + player2WinRate: '0', + commentator1: '', + commentator2: '', + }, + }) + const { data: player1Games } = api.history.user_games.useQuery( + { + user_id: value1 ?? '', + }, + { enabled: !!value1 } + ) + const { data: player2Games } = api.history.user_games.useQuery( + { + user_id: value2 ?? '', + }, + { enabled: !!value2 } + ) + const { data: player1Info } = api.leaderboard.get_user_rank.useQuery( + { + channel_id: RANKED_CHANNEL, + user_id: value1 ?? '', + }, + { enabled: !!value1 } + ) + const { data: player2Info } = api.leaderboard.get_user_rank.useQuery( + { + channel_id: RANKED_CHANNEL, + user_id: value2 ?? '', + }, + { enabled: !!value2 } + ) + const player1Data = + player1Info && player1Games + ? getPlayerData(player1Info, player1Games) + : null + const player2Data = + player2Info && player2Games + ? getPlayerData(player2Info, player2Games) + : null + let winsVsOpponent = 0 + let lossesVsOpponent = 0 + if (value1 && player1Games && value2) { + for (const game of player1Games) { + if (game.opponentId === value2) { + if (game.result === 'win') { + winsVsOpponent++ + } else if (game.result === 'loss') { + lossesVsOpponent++ + } + } + } + } + useEffect(() => { + // try to connect on mount + obs + .connect() + .then(() => setIsConnected(true)) + .catch(() => setIsConnected(false)) + }, []) + + useEffect(() => { + if (!isConnected) return + if (player1Data?.username) { + form.setValue('player1Name', player1Data.username) + } + if (player2Data?.username) { + form.setValue('player2Name', player2Data.username) + } + if (player1Data?.mmr) { + form.setValue('player1Mmr', player1Data.mmr.toString()) + } + if (player2Data?.mmr) { + form.setValue('player2Mmr', player2Data.mmr.toString()) + } + if (player1Data?.games) { + form.setValue('player1Games', player1Data.games.toString()) + } + if (player2Data?.games) { + form.setValue('player2Games', player2Data.games.toString()) + } + if (player1Data?.wins) { + form.setValue('player1Wins', player1Data.wins.toString()) + } + if (player2Data?.wins) { + form.setValue('player2Wins', player2Data.wins.toString()) + } + if (player1Data?.losses) { + form.setValue('player1Losses', player1Data.losses.toString()) + } + if (player2Data?.losses) { + form.setValue('player2Losses', player2Data.losses.toString()) + } + if (player1Data?.rank) { + form.setValue('player1Rank', player1Data.rank.toString()) + } + if (player2Data?.rank) { + form.setValue('player2Rank', player2Data.rank.toString()) + } + if (player1Data?.winRate) { + form.setValue('player1WinRate', `${player1Data.winRate.toString()}%`) + } + if (player2Data?.winRate) { + form.setValue('player2WinRate', `${player2Data.winRate.toString()}%`) + } + }, [player1Data, player2Data, isConnected]) + + const [mappings] = useLocalStorage('obs-field-mappings', []) + + const onSubmit = form.handleSubmit(async (data) => { + try { + const updates = mappings + .map((mapping) => { + const value = data[mapping.formField as keyof typeof data] + if (!value) return null + + const sanitizedValue = value + .toString() + .replace(/[^\x00-\x7F]/g, '') + .trim() + + return obs.updateText(mapping.obsSource, sanitizedValue) + }) + .filter(Boolean) // remove nulls from skipped empty values + + await Promise.all(updates) + + toast.success('Updated OBS text sources', { + description: `Successfully updated ${updates.length} fields`, + }) + } catch (error) { + toast.error('Failed to update OBS', { + description: + error instanceof Error ? error.message : 'Unknown error occurred', + }) + } + }) + + if (!playersForSelect) { + return
Loading...
+ } + return ( +
+
+ + + + + + + Settings + + Adjust the settings for the OBS controls + + + + + +
+
+ + +
+
+
+
Player 1
+
Player 2
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ ) +} + +type FieldMapping = { + formField: string + obsSource: string +} +type FormField = { + id: string + label: string +} +function Settings() { + const [isLoading, setIsLoading] = useState(true) + const [obsSources, setObsSources] = useState([]) + const [mappings, setMappings] = useLocalStorage( + 'obs-field-mappings', + [] + ) + const formFields: FormField[] = [ + { id: 'player1Name', label: 'Player 1 - Name' }, + { id: 'player2Name', label: 'Player 2 - Name' }, + { id: 'player1Mmr', label: 'Player 1 - MMR' }, + { id: 'player2Mmr', label: 'Player 2 - MMR' }, + { id: 'player1Games', label: 'Player 1 - Games' }, + { id: 'player2Games', label: 'Player 2 - Games' }, + { id: 'player1Wins', label: 'Player 1 - Wins' }, + { id: 'player2Wins', label: 'Player 2 - Wins' }, + { id: 'player1Losses', label: 'Player 1 - Losses' }, + { id: 'player2Losses', label: 'Player 2 - Losses' }, + { id: 'player1Rank', label: 'Player 1 - Rank' }, + { id: 'player2Rank', label: 'Player 2 - Rank' }, + { id: 'player1WinRate', label: 'Player 1 - Win Rate' }, + { id: 'player2WinRate', label: 'Player 2 - Win Rate' }, + ] + useEffect(() => { + async function fetchSources() { + try { + const inputs = await obs.getInputs() + const textSources = inputs + .filter((input) => input.inputKind.includes('text')) + .map((input) => input.inputName) + setObsSources(textSources) + } catch (error) { + console.error('Failed to fetch sources:', error) + } finally { + setIsLoading(false) + } + } + fetchSources() + }, []) + + const addMapping = () => { + setMappings([...mappings, { formField: '', obsSource: '' }]) + } + + const removeMapping = (index: number) => { + setMappings(mappings.filter((_, i) => i !== index)) + } + + const updateMapping = ( + index: number, + field: 'formField' | 'obsSource', + value: string + ) => { + const newMappings = [...mappings] + // @ts-ignore + newMappings[index] = { ...newMappings[index], [field]: value } + setMappings(newMappings) + } + + if (isLoading) { + return
Loading sources...
+ } + + return ( +
+
+

Field Mappings

+ +
+ +
+ {mappings.map((mapping, index) => ( +
+ + + + + +
+ ))} +
+ + {mappings.length === 0 && ( +
+ No mappings configured. Add one to get started. +
+ )} +
+ ) +} diff --git a/src/app/(home)/admin/stream/obs-control-panel/_components/player-selector.tsx b/src/app/(home)/admin/stream/obs-control-panel/_components/player-selector.tsx new file mode 100644 index 0000000..c2c1cd9 --- /dev/null +++ b/src/app/(home)/admin/stream/obs-control-panel/_components/player-selector.tsx @@ -0,0 +1,90 @@ +'use client' + +import { Check, ChevronsUpDown } from 'lucide-react' +import * as React from 'react' + +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { cn } from '@/lib/utils' + +type PlayerSelectorProps = { + open: boolean + onOpenChange: (open: boolean) => void + value: string + onValueChange: (value: string) => void + players: Array<{ label: string; value: string }> +} + +function PlayerSelectorRaw({ + open, + onOpenChange, + value, + players, + onValueChange, +}: PlayerSelectorProps) { + return ( + + + + + + { + if (value.toLowerCase().includes(search.toLowerCase())) return 1 + return 0 + }} + > + + + No players found. + + {players.map((player) => ( + { + onValueChange(currentValue === value ? '' : currentValue) + onOpenChange(false) + }} + > + {player.label} + + + ))} + + + + + + ) +} + +export const PlayerSelector = React.memo(PlayerSelectorRaw) diff --git a/src/app/(home)/admin/stream/obs-control-panel/page.tsx b/src/app/(home)/admin/stream/obs-control-panel/page.tsx new file mode 100644 index 0000000..d797f39 --- /dev/null +++ b/src/app/(home)/admin/stream/obs-control-panel/page.tsx @@ -0,0 +1,18 @@ +import { RANKED_CHANNEL } from '@/shared/constants' +import { HydrateClient, api } from '@/trpc/server' +import { Suspense } from 'react' +import { ObsControlPanelClient } from './_components/obs-control-panel-client' + +export default async function AdminStreamWidgetPage() { + await api.leaderboard.get_leaderboard.prefetch({ + channel_id: RANKED_CHANNEL, + }) + + return ( + + + + + + ) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5306532..92bc604 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import '@/styles/globals.css' +import { Toaster } from '@/components/ui/sonner' import { TRPCReactProvider } from '@/trpc/react' import { Banner } from 'fumadocs-ui/components/banner' import { RootProvider } from 'fumadocs-ui/provider' @@ -51,6 +52,7 @@ export default async function RootLayout({ /> + {/**/} {/* Version 0.2.4 is out!*/} {/* -
-
- {playerData.wins}W -
+
+ {playerData.wins}W
| -
-
- {playerData.losses}L -
+
+ {playerData.losses}L
+
({playerData.winRate}%)
{isInBattle && (
| null = null + + async connect(): Promise { + if (this.ws?.readyState === WebSocket.OPEN) return + + if (this.connectPromise) return this.connectPromise + + this.connectPromise = new Promise((resolve, reject) => { + this.ws = new WebSocket('ws://localhost:4455') + + this.ws.onopen = () => { + console.log('connected to obs') + this.ws?.send( + JSON.stringify({ + op: 1, + d: { rpcVersion: 1 }, + }) + ) + } + + this.ws.onmessage = (event) => { + const data = JSON.parse(event.data) + if (data.op === 2) { + // Identified + resolve() + } + } + + this.ws.onerror = (error) => { + console.error('ws error:', error) + reject(error) + } + + this.ws.onclose = () => { + console.log('disconnected from obs') + this.ws = null + this.connectPromise = null + } + }) + + return this.connectPromise + } + + async updateText(inputName: string, text: string): Promise { + await this.connect() + + this.ws?.send( + JSON.stringify({ + op: 6, + d: { + requestType: 'SetInputSettings', + requestId: 'update', + requestData: { + inputName, + inputSettings: { text }, + }, + }, + }) + ) + } + async getInputs(): Promise<{ inputName: string; inputKind: string }[]> { + await this.connect() + + return new Promise((resolve, reject) => { + const requestId = `get-inputs-${Date.now()}` + + const handleMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data) + if (data.op === 7 && data.d.requestId === requestId) { + this.ws?.removeEventListener('message', handleMessage) + resolve(data.d.responseData.inputs) + } + } + + this.ws?.addEventListener('message', handleMessage) + + this.ws?.send( + JSON.stringify({ + op: 6, + d: { + requestType: 'GetInputList', + requestId, + }, + }) + ) + }) + } +}