Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions desktop/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
*.tsbuildinfo
796 changes: 796 additions & 0 deletions desktop/bun.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions desktop/eslint.config.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions desktop/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>OpenStream Studio</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
1 change: 1 addition & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"openstream-studio-ui","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite","build":"tsc -b && vite build","typecheck":"tsc --noEmit","lint":"eslint .","test":"vitest run"},"dependencies":{"@tailwindcss/vite":"^4.1.0","lucide-react":"^0.468.0","react":"^19.0.0","react-dom":"^19.0.0","react-router-dom":"^7.1.0","tailwindcss":"^4.1.0"},"devDependencies":{"@eslint/js":"^9.17.0","@testing-library/jest-dom":"^6.6.0","@testing-library/react":"^16.1.0","@testing-library/user-event":"^14.5.0","@types/react":"^19.0.0","@types/react-dom":"^19.0.0","@vitejs/plugin-react":"^4.3.0","eslint":"^9.17.0","eslint-plugin-react-hooks":"^5.1.0","eslint-plugin-react-refresh":"^0.4.16","globals":"^15.14.0","jsdom":"^25.0.0","typescript":"~5.7.2","typescript-eslint":"^8.18.0","vite":"^6.0.5","vitest":"^3.2.4"}}
2 changes: 2 additions & 0 deletions desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import{render,screen}from'@testing-library/react';import userEvent from'@testing-library/user-event';import{describe,expect,it}from'vitest';import{App}from'./App';
describe('operator UI',()=>{it('routes between all four destinations',async()=>{const u=userEvent.setup();render(<App/>);for(const name of['Cameras','Audio','Studio','Dashboard']){await u.click(screen.getByRole('link',{name:new RegExp(name)}));expect(screen.getByRole('heading',{level:1,name})).toBeInTheDocument()}});it('exposes unsupported camera state to screen readers',async()=>{const u=userEvent.setup();render(<App/>);await u.click(screen.getByRole('link',{name:/Cameras/}));expect(screen.getAllByText('unsupported').length).toBeGreaterThan(0);expect(screen.getByRole('switch',{name:/Stage Left Torch/})).toBeDisabled()});it('uses explicit Program audio assignment',async()=>{const u=userEvent.setup();render(<App/>);await u.click(screen.getByRole('link',{name:/Audio/}));const buttons=screen.getAllByRole('button',{name:'Assign to Program'});await u.click(buttons[1]);expect(screen.getByRole('button',{name:'Program source'})).toHaveAttribute('aria-pressed','true')});it('raises a text alert and holds Program frame on simulated failure',async()=>{const u=userEvent.setup();render(<App/>);await u.click(screen.getByRole('link',{name:/Studio/}));await u.click(screen.getByRole('button',{name:/Simulate live-camera failure/}));expect(screen.getByText(/Last Program frame held/)).toBeInTheDocument();expect(screen.getByText(/FRAME HELD/)).toBeInTheDocument()})});
4 changes: 4 additions & 0 deletions desktop/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import {useEffect} from 'react';import {BrowserRouter,NavLink,Route,Routes,useNavigate} from 'react-router-dom';import {Activity,Camera,Grid2X2,LayoutDashboard,Mic2} from 'lucide-react';import {Dashboard,Cameras,Audio,Studio} from './pages';import {StudioProvider,useStudio} from './studio/context';import type {StudioAdapter} from './studio/types';import './index.css';
const nav=[['/',Dashboard,LayoutDashboard,'Dashboard'],['/cameras',Cameras,Camera,'Cameras'],['/audio',Audio,Mic2,'Audio'],['/studio',Studio,Grid2X2,'Studio']] as const;
function Shell(){const{snapshot}=useStudio();const navigate=useNavigate();useEffect(()=>{const onKey=(e:KeyboardEvent)=>{if((e.ctrlKey||e.metaKey)&&['1','2','3','4'].includes(e.key)){e.preventDefault();navigate(nav[Number(e.key)-1][0])}};addEventListener('keydown',onKey);return()=>removeEventListener('keydown',onKey)},[navigate]);return <div className="app-shell"><a className="skip" href="#content">Skip to content</a><aside><div className="brand"><Activity aria-hidden/>OpenStream <b>Studio</b></div><nav aria-label="Primary">{nav.map(([path,,Icon,label],i)=><NavLink end={path==='/'} to={path} key={path}><Icon aria-hidden/><span>{label}</span><kbd>⌃{i+1}</kbd></NavLink>)}</nav><div className="engine"><span className={snapshot.connection}/>{snapshot.connection==='connected'?'Simulator connected':'Reconnecting'}<small>Engine owns all media</small></div></aside><section className="workspace"><header className="topbar"><span>OPERATOR CONSOLE</span><span>Snapshot r{snapshot.revision}</span><span>4 × 1080p30</span></header><div id="content"><Routes>{nav.map(([path,Page])=><Route path={path} element={<Page/>} key={path}/>)}</Routes></div></section></div>}
export function App({adapter}:{adapter?:StudioAdapter}){return <BrowserRouter><StudioProvider adapter={adapter}><Shell/></StudioProvider></BrowserRouter>}
6 changes: 6 additions & 0 deletions desktop/src/components.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {AlertTriangle,CheckCircle2,Clock3,Radio,Thermometer,WifiOff} from 'lucide-react';import type {CommandState} from './studio/types';
export function Status({ok,label}:{ok:boolean;label:string}){return <span className={'status '+(ok?'ok':'bad')}><span aria-hidden>{ok?'●':'!'}</span>{label}</span>}
export function StateLabel({state}:{state:CommandState}){const Icon=state==='confirmed'?CheckCircle2:state==='reconnecting'?WifiOff:state==='pending'?Clock3:AlertTriangle;return <span className={'state '+state}><Icon aria-hidden size={13}/>{state}</span>}
export function Meter({level,label}:{level:number;label:string}){const pct=Math.max(4,Math.min(100,(level+60)*1.66));return <div className="meter" role="meter" aria-label={label} aria-valuemin={-60} aria-valuemax={0} aria-valuenow={level}><span style={{width:`${pct}%`}}/></div>}
export function Video({title,program,failed}:{title:string;program?:boolean;failed?:boolean}){return <section className={'video '+(program?'program':'preview')} aria-label={`${program?'Program':'Preview'} monitor`}><header><strong>{program?'PROGRAM':'PREVIEW'}</strong><span>{title}</span></header><div className="frame"><Radio aria-hidden/><span>{failed?'FRAME HELD · CAMERA OFFLINE':'SIMULATED SHARED TEXTURE'}</span></div>{failed&&<p className="failure"><AlertTriangle aria-hidden/>Live camera lost. Last Program frame held.</p>}</section>}
export function Health({temperature,latency}:{temperature:number;latency:number}){return <span className="health"><Thermometer aria-hidden size={14}/>{temperature}°C <Clock3 aria-hidden size={14}/>{latency} ms</span>}
2 changes: 2 additions & 0 deletions desktop/src/index.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions desktop/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import {StrictMode} from 'react';import{createRoot}from'react-dom/client';import{App}from'./App';createRoot(document.getElementById('root')!).render(<StrictMode><App/></StrictMode>);
Loading
Loading