mirror of
https://github.com/ershisan99/www.git
synced 2025-12-18 12:34:17 +00:00
add obs control panel
This commit is contained in:
5
bun.lock
5
bun.lock
@@ -76,6 +76,7 @@
|
|||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
"tailwind-merge": "^3.1.0",
|
"tailwind-merge": "^3.1.0",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
|
"usehooks-ts": "^3.1.1",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.24.2",
|
"zod": "^3.24.2",
|
||||||
},
|
},
|
||||||
@@ -773,6 +774,8 @@
|
|||||||
|
|
||||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
"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.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||||
|
|
||||||
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
"tailwind-merge": "^3.1.0",
|
"tailwind-merge": "^3.1.0",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
|
"usehooks-ts": "^3.1.1",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<FieldMapping[]>('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 <div>Loading...</div>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'mx-auto flex w-[calc(100%-1rem)] max-w-fd-container flex-col gap-4 pt-16'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={'flex w-full justify-end'}>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant={'secondary'}>
|
||||||
|
Settings
|
||||||
|
<SettingsIcon />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Settings</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Adjust the settings for the OBS controls
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Settings />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-2 gap-4'}>
|
||||||
|
<PlayerSelector
|
||||||
|
value={value1}
|
||||||
|
players={playersForSelect}
|
||||||
|
onValueChange={setValue1}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
/>
|
||||||
|
<PlayerSelector
|
||||||
|
value={value2}
|
||||||
|
players={playersForSelect}
|
||||||
|
onValueChange={setValue2}
|
||||||
|
open={open2}
|
||||||
|
onOpenChange={setOpen2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<div className={'grid grid-cols-2 gap-4'}>
|
||||||
|
<div>Player 1</div>
|
||||||
|
<div>Player 2</div>
|
||||||
|
<div className={'grid grid-cols-1 gap-2'}>
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input {...form.register('player1Name')} />
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-1 gap-2'}>
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input {...form.register('player2Name')} />
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-1 gap-2'}>
|
||||||
|
<Label>MMR</Label>
|
||||||
|
<Input {...form.register('player1Mmr')} />
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-1 gap-2'}>
|
||||||
|
<Label>MMR</Label>
|
||||||
|
<Input {...form.register('player2Mmr')} />
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-1 gap-2'}>
|
||||||
|
<Label>Games</Label>
|
||||||
|
<Input {...form.register('player1Games')} />
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-1 gap-2'}>
|
||||||
|
<Label>Games</Label>
|
||||||
|
<Input {...form.register('player2Games')} />
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-1 gap-2'}>
|
||||||
|
<Label>Wins</Label>
|
||||||
|
<Input {...form.register('player1Wins')} />
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-1 gap-2'}>
|
||||||
|
<Label>Wins</Label>
|
||||||
|
<Input {...form.register('player2Wins')} />
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-1 gap-2'}>
|
||||||
|
<Label>Losses</Label>
|
||||||
|
<Input {...form.register('player1Losses')} />
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-1 gap-2'}>
|
||||||
|
<Label>Losses</Label>
|
||||||
|
<Input {...form.register('player2Losses')} />
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-1 gap-2'}>
|
||||||
|
<Label>Rank</Label>
|
||||||
|
<Input {...form.register('player1Rank')} />
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-1 gap-2'}>
|
||||||
|
<Label>Rank</Label>
|
||||||
|
<Input {...form.register('player2Rank')} />
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-1 gap-2'}>
|
||||||
|
<Label>Win rate</Label>
|
||||||
|
<Input {...form.register('player1WinRate')} />
|
||||||
|
</div>
|
||||||
|
<div className={'grid grid-cols-1 gap-2'}>
|
||||||
|
<Label>Win rate</Label>
|
||||||
|
<Input {...form.register('player2WinRate')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type={'submit'} className={'mt-4'}>
|
||||||
|
Ship it
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldMapping = {
|
||||||
|
formField: string
|
||||||
|
obsSource: string
|
||||||
|
}
|
||||||
|
type FormField = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
function Settings() {
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [obsSources, setObsSources] = useState<string[]>([])
|
||||||
|
const [mappings, setMappings] = useLocalStorage<FieldMapping[]>(
|
||||||
|
'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 <div>Loading sources...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<h3 className='font-medium text-lg'>Field Mappings</h3>
|
||||||
|
<Button onClick={addMapping} size='sm'>
|
||||||
|
Add Mapping
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-2'>
|
||||||
|
{mappings.map((mapping, index) => (
|
||||||
|
<div key={index} className='flex items-center gap-2'>
|
||||||
|
<Select
|
||||||
|
value={mapping.formField}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateMapping(index, 'formField', value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className='w-[200px]'>
|
||||||
|
<SelectValue placeholder='Select field'>
|
||||||
|
{formFields.find((f) => f.id === mapping.formField)?.label ||
|
||||||
|
'Select field'}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{formFields.map((field) => (
|
||||||
|
<SelectItem key={field.id} value={field.id}>
|
||||||
|
{field.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={mapping.obsSource}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateMapping(index, 'obsSource', value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className='w-[200px]'>
|
||||||
|
<SelectValue placeholder='Select OBS source' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{obsSources.map((source) => (
|
||||||
|
<SelectItem key={source} value={source}>
|
||||||
|
{source}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant='destructive'
|
||||||
|
size='icon'
|
||||||
|
onClick={() => removeMapping(index)}
|
||||||
|
>
|
||||||
|
<X className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mappings.length === 0 && (
|
||||||
|
<div className='py-4 text-center text-muted-foreground'>
|
||||||
|
No mappings configured. Add one to get started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: <explanation>
|
||||||
|
role='combobox'
|
||||||
|
aria-expanded={open}
|
||||||
|
className='w-[200px] justify-between'
|
||||||
|
>
|
||||||
|
{value
|
||||||
|
? players.find((player) => player.value === value)?.label
|
||||||
|
: 'Select player...'}
|
||||||
|
<ChevronsUpDown className='opacity-50' />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className='w-[200px] p-0'>
|
||||||
|
<Command
|
||||||
|
filter={(value, search) => {
|
||||||
|
if (value.toLowerCase().includes(search.toLowerCase())) return 1
|
||||||
|
return 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CommandInput placeholder='Search players...' className='h-9' />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No players found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{players.map((player) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`${player.value}-2`}
|
||||||
|
value={player.value}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
onValueChange(currentValue === value ? '' : currentValue)
|
||||||
|
onOpenChange(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{player.label}
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'ml-auto',
|
||||||
|
value === player.value ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayerSelector = React.memo(PlayerSelectorRaw)
|
||||||
18
src/app/(home)/admin/stream/obs-control-panel/page.tsx
Normal file
18
src/app/(home)/admin/stream/obs-control-panel/page.tsx
Normal file
@@ -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 (
|
||||||
|
<Suspense>
|
||||||
|
<HydrateClient>
|
||||||
|
<ObsControlPanelClient />
|
||||||
|
</HydrateClient>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import '@/styles/globals.css'
|
import '@/styles/globals.css'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { TRPCReactProvider } from '@/trpc/react'
|
import { TRPCReactProvider } from '@/trpc/react'
|
||||||
import { Banner } from 'fumadocs-ui/components/banner'
|
import { Banner } from 'fumadocs-ui/components/banner'
|
||||||
import { RootProvider } from 'fumadocs-ui/provider'
|
import { RootProvider } from 'fumadocs-ui/provider'
|
||||||
@@ -51,6 +52,7 @@ export default async function RootLayout({
|
|||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className={'flex min-h-screen flex-col'}>
|
<body className={'flex min-h-screen flex-col'}>
|
||||||
|
<Toaster />
|
||||||
{/*<Banner id={'v0.2.4'} variant={'rainbow'}>*/}
|
{/*<Banner id={'v0.2.4'} variant={'rainbow'}>*/}
|
||||||
{/* Version 0.2.4 is out!*/}
|
{/* Version 0.2.4 is out!*/}
|
||||||
{/* <a*/}
|
{/* <a*/}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Swords } from 'lucide-react'
|
|||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { type ComponentPropsWithoutRef, useEffect, useState } from 'react'
|
import { type ComponentPropsWithoutRef, useEffect, useState } from 'react'
|
||||||
|
|
||||||
function getPlayerData(
|
export function getPlayerData(
|
||||||
playerLeaderboardEntry: LeaderboardEntry,
|
playerLeaderboardEntry: LeaderboardEntry,
|
||||||
games: SelectGames[]
|
games: SelectGames[]
|
||||||
) {
|
) {
|
||||||
@@ -256,17 +256,14 @@ function PlayerInfo({
|
|||||||
isReverse && 'border-r'
|
isReverse && 'border-r'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className='flex items-center'>
|
<div className='ml-0.5 font-bold text-emerald-400'>
|
||||||
<div className='ml-0.5 font-bold text-emerald-400'>
|
{playerData.wins}W
|
||||||
{playerData.wins}W
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
|
||||||
<div className='flex items-center'>
|
<div className='ml-0.5 font-bold text-rose-400'>
|
||||||
<div className='ml-0.5 font-bold text-rose-400'>
|
{playerData.losses}L
|
||||||
{playerData.losses}L
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className='ml-0.5 '>({playerData.winRate}%)</div>
|
||||||
</div>
|
</div>
|
||||||
{isInBattle && (
|
{isInBattle && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
90
src/lib/obs-connection.ts
Normal file
90
src/lib/obs-connection.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
export class OBSController {
|
||||||
|
private ws: WebSocket | null = null
|
||||||
|
private connectPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user