├── Resources ├── wwwroot │ ├── .gitkeep │ ├── favicon.ico │ ├── _next │ │ └── static │ │ │ ├── SB7w5PHMyUKfCLcK0MvSP │ │ │ ├── _ssgManifest.js │ │ │ └── _buildManifest.js │ │ │ ├── media │ │ │ ├── 26a46d62cd723877-s.woff2 │ │ │ ├── 55c55f0601d81cf3-s.woff2 │ │ │ ├── 581909926a08bbc8-s.woff2 │ │ │ ├── 6d93bde91c0c2823-s.woff2 │ │ │ ├── 97e0cb1ae144a2a9-s.woff2 │ │ │ ├── df0a9ae256c0569c-s.woff2 │ │ │ └── a34f9d1faa5f3315-s.p.woff2 │ │ │ └── chunks │ │ │ ├── app │ │ │ ├── page-e90ebb3e0970a48f.js │ │ │ ├── layout-f30153a48172701f.js │ │ │ └── _not-found │ │ │ │ └── page-eaa53ae320539b27.js │ │ │ ├── pages │ │ │ ├── _error-7ba65e1336b92748.js │ │ │ └── _app-72b849fbd24ac258.js │ │ │ ├── main-app-7adda32f7f76f69b.js │ │ │ └── webpack-03c87f68c10117d4.js │ ├── vercel.svg │ ├── next.svg │ └── 404.html ├── icon.ico ├── error.wav ├── start.wav └── bookmark.wav ├── icon.ico ├── icon.png ├── Frontend ├── bun.lockb ├── .prettierignore ├── tsconfig.json ├── src │ ├── vite-env.d.ts │ ├── Utils │ │ ├── FileUtils.ts │ │ └── MessageUtils.ts │ ├── Hooks │ │ ├── useClipping.ts │ │ ├── useUserProfile.ts │ │ └── useAuth.tsx │ ├── Pages │ │ ├── replay-buffer.tsx │ │ ├── clips.tsx │ │ ├── highlights.tsx │ │ ├── sessions.tsx │ │ └── settings.tsx │ ├── lib │ │ └── supabase │ │ │ └── client.ts │ ├── Components │ │ ├── UnavailableDeviceCard.tsx │ │ ├── AnimatedCard.tsx │ │ ├── CloudBadge.tsx │ │ ├── AiBadge.tsx │ │ ├── UploadCard.tsx │ │ ├── ConfirmationModal.tsx │ │ ├── GenericModal.tsx │ │ ├── CircularProgress.tsx │ │ ├── GameCard.tsx │ │ ├── SelectionCard.tsx │ │ ├── UpdateCard.tsx │ │ ├── ClippingCard.tsx │ │ ├── ImportCard.tsx │ │ ├── GameListManager.tsx │ │ ├── Settings │ │ │ ├── CaptureModeSection.tsx │ │ │ ├── StorageSettingsSection.tsx │ │ │ └── KeybindingsSection.tsx │ │ ├── RenameModal.tsx │ │ ├── UploadModal.tsx │ │ ├── CustomGameModal.tsx │ │ └── DropdownSelect.tsx │ ├── App.css │ ├── Context │ │ ├── SelectedVideoContext.tsx │ │ ├── ScrollContext.tsx │ │ ├── SelectedMenuContext.tsx │ │ ├── SelectionsContext.tsx │ │ ├── UploadContext.tsx │ │ ├── AiHighlightsContext.tsx │ │ ├── CompressionContext.tsx │ │ ├── ImportContext.tsx │ │ ├── ModalContext.tsx │ │ ├── ClippingContext.tsx │ │ ├── WebSocketContext.tsx │ │ └── SettingsContext.tsx │ ├── main.tsx │ ├── index.css │ ├── Models │ │ └── WebSocketMessages.ts │ ├── globals.css │ └── App.tsx ├── .prettierrc ├── .gitignore ├── tailwind.config.js ├── index.html ├── tsconfig.node.json ├── tsconfig.app.json ├── vite.config.ts ├── eslint.config.js ├── package.json └── README.md ├── .husky ├── pre-commit └── pre-push ├── .gitmodules ├── obs.zip ├── Obs ├── OBS 30.1.1.zip ├── OBS 32.0.0.zip └── OBS 32.0.1.zip ├── Properties ├── launchSettings.json ├── Settings.settings ├── Settings.Designer.cs └── Resources.Designer.cs ├── Backend ├── Games │ ├── Integration.cs │ └── GameIconUtils.cs ├── Core │ └── Models │ │ ├── OBSVersion.cs │ │ ├── KeybindSettings.cs │ │ ├── Display.cs │ │ ├── Codec.cs │ │ └── Bookmark.cs ├── Windows │ ├── Watchers │ │ ├── DisplayWatcher.cs │ │ └── AudioDeviceWatcher.cs │ └── Power │ │ └── PowerModeMonitor.cs ├── Obs │ └── OBSWindow.cs ├── Services │ ├── GameIntegrationService.cs │ ├── AiService.cs │ └── PresetsService.cs ├── App │ └── StartupService.cs ├── Shared │ └── VelopackUtils.cs └── Media │ └── CompressionService.cs ├── package.json ├── .editorconfig ├── lint-staged.config.js ├── Segra.sln ├── .gitattributes ├── CONTRIBUTING.md ├── README.md └── Segra.csproj /Resources/wwwroot/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/icon.ico -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/icon.png -------------------------------------------------------------------------------- /Frontend/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/Frontend/bun.lockb -------------------------------------------------------------------------------- /Resources/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/Resources/icon.ico -------------------------------------------------------------------------------- /Resources/error.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/Resources/error.wav -------------------------------------------------------------------------------- /Resources/start.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/Resources/start.wav -------------------------------------------------------------------------------- /Resources/bookmark.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/Resources/bookmark.wav -------------------------------------------------------------------------------- /Frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | .next 5 | .vite 6 | coverage 7 | out 8 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | # Run lint-staged via Bun, limited to Frontend staged files 2 | bunx lint-staged 3 | 4 | -------------------------------------------------------------------------------- /Resources/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/Resources/wwwroot/favicon.ico -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "libobs-sharp"] 2 | path = libobs-sharp 3 | url = https://github.com/Segergren/libobs-sharp.git -------------------------------------------------------------------------------- /Frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] 4 | } 5 | -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/SB7w5PHMyUKfCLcK0MvSP/_ssgManifest.js: -------------------------------------------------------------------------------- 1 | self.__SSG_MANIFEST=new Set([]);self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB() -------------------------------------------------------------------------------- /obs.zip: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:72e63d7f915f21c698f12c950330f83ee0ba1cb7ff77ac35f64d69f2b21416fb 3 | size 176617279 4 | -------------------------------------------------------------------------------- /Frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // Global variables defined in vite.config.ts 4 | declare const __APP_VERSION__: string; 5 | -------------------------------------------------------------------------------- /Obs/OBS 30.1.1.zip: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b51f2f3a85037fc656d702509517b447448bcc7340591c496016deadb15911cd 3 | size 147555711 4 | -------------------------------------------------------------------------------- /Obs/OBS 32.0.0.zip: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:ac7cf0ceeb6cf25f50c83ccd487f3d438cfe484bdfaed6705e9494cf74f9c868 3 | size 170611614 4 | -------------------------------------------------------------------------------- /Obs/OBS 32.0.1.zip: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:96ff001b3919a12bfccbfd8af05d3ce5855a094f68b8a2c13d138cb6ba18e1f2 3 | size 170458146 4 | -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/media/26a46d62cd723877-s.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/Resources/wwwroot/_next/static/media/26a46d62cd723877-s.woff2 -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/media/55c55f0601d81cf3-s.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/Resources/wwwroot/_next/static/media/55c55f0601d81cf3-s.woff2 -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/media/581909926a08bbc8-s.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/Resources/wwwroot/_next/static/media/581909926a08bbc8-s.woff2 -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/media/6d93bde91c0c2823-s.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/Resources/wwwroot/_next/static/media/6d93bde91c0c2823-s.woff2 -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/media/97e0cb1ae144a2a9-s.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/Resources/wwwroot/_next/static/media/97e0cb1ae144a2a9-s.woff2 -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/media/df0a9ae256c0569c-s.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/Resources/wwwroot/_next/static/media/df0a9ae256c0569c-s.woff2 -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/media/a34f9d1faa5f3315-s.p.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Segergren/Segra/HEAD/Resources/wwwroot/_next/static/media/a34f9d1faa5f3315-s.p.woff2 -------------------------------------------------------------------------------- /Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Segra": { 4 | "commandName": "Project", 5 | "commandLineArgs": "--debug", 6 | "nativeDebugging": true 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /Backend/Games/Integration.cs: -------------------------------------------------------------------------------- 1 | namespace Segra.Backend.Games 2 | { 3 | public abstract class Integration 4 | { 5 | public abstract Task Start(); 6 | public abstract Task Shutdown(); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "bracketSpacing": true, 8 | "arrowParens": "always", 9 | "endOfLine": "crlf" 10 | } 11 | -------------------------------------------------------------------------------- /Frontend/src/Utils/FileUtils.ts: -------------------------------------------------------------------------------- 1 | import { sendMessageToBackend } from './MessageUtils'; 2 | 3 | export const openFileLocation = (filePath: string) => { 4 | if (!filePath) return; 5 | sendMessageToBackend('OpenFileLocation', { FilePath: filePath }); 6 | }; 7 | -------------------------------------------------------------------------------- /Properties/Settings.settings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/chunks/app/page-e90ebb3e0970a48f.js: -------------------------------------------------------------------------------- 1 | (self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[931],{5178:function(n,e,u){Promise.resolve().then(u.t.bind(u,5878,23))}},function(n){n.O(0,[878,971,117,744],function(){return n(n.s=5178)}),_N_E=n.O()}]); -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/SB7w5PHMyUKfCLcK0MvSP/_buildManifest.js: -------------------------------------------------------------------------------- 1 | self.__BUILD_MANIFEST={__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},"/_error":["static/chunks/pages/_error-7ba65e1336b92748.js"],sortedPages:["/_app","/_error"]},self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB(); -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/chunks/pages/_error-7ba65e1336b92748.js: -------------------------------------------------------------------------------- 1 | (self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[820],{1981:function(n,_,u){(window.__NEXT_P=window.__NEXT_P||[]).push(["/_error",function(){return u(8529)}])}},function(n){n.O(0,[888,774,179],function(){return n(n.s=1981)}),_N_E=n.O()}]); -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/chunks/pages/_app-72b849fbd24ac258.js: -------------------------------------------------------------------------------- 1 | (self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[888],{1597:function(n,_,u){(window.__NEXT_P=window.__NEXT_P||[]).push(["/_app",function(){return u(8141)}])}},function(n){var _=function(_){return n(n.s=_)};n.O(0,[774,179],function(){return _(1597),_(7253)}),_N_E=n.O()}]); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "segra-repo-tools", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "prepare": "husky", 8 | "precommit:lint-staged": "lint-staged" 9 | }, 10 | "devDependencies": { 11 | "husky": "^9.0.11", 12 | "lint-staged": "^15.2.10" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = crlf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [*.cs] 12 | indent_size = 4 13 | end_of_line = crlf 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | 18 | [Makefile] 19 | indent_style = tab 20 | -------------------------------------------------------------------------------- /Frontend/src/Hooks/useClipping.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { ClippingContext } from '../Context/ClippingContext'; 3 | 4 | export function useClipping() { 5 | const context = useContext(ClippingContext); 6 | if (!context) { 7 | throw new Error('useClipping must be used within a ClippingProvider'); 8 | } 9 | return context; 10 | } 11 | -------------------------------------------------------------------------------- /Frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /Frontend/src/Pages/replay-buffer.tsx: -------------------------------------------------------------------------------- 1 | import { MdReplay30 } from 'react-icons/md'; 2 | import ContentPage from '../Components/ContentPage'; 3 | 4 | export default function ReplayBuffer() { 5 | return ( 6 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | # Verify C# code is formatted per .editorconfig 2 | if command -v dotnet >/dev/null 2>&1; then 3 | echo "Running dotnet format check..." 4 | dotnet format --no-restore --verify-no-changes Segra.sln --exclude libobs-sharp || { 5 | echo "dotnet format found issues. Run 'dotnet format' to fix." >&2 6 | exit 1 7 | } 8 | else 9 | echo "dotnet CLI not found; skipping C# format check" >&2 10 | fi 11 | -------------------------------------------------------------------------------- /Backend/Core/Models/OBSVersion.cs: -------------------------------------------------------------------------------- 1 | namespace Segra.Backend.Core.Models 2 | { 3 | public class OBSVersion 4 | { 5 | public required string Version { get; set; } 6 | public bool IsBeta { get; set; } 7 | public string? AvailableSince { get; set; } 8 | public string? SupportsFrom { get; set; } 9 | public string? SupportsTo { get; set; } 10 | public required string Url { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/chunks/app/layout-f30153a48172701f.js: -------------------------------------------------------------------------------- 1 | (self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[185],{8486:function(n,e,t){Promise.resolve().then(t.t.bind(t,911,23)),Promise.resolve().then(t.t.bind(t,7960,23))},7960:function(){},911:function(n){n.exports={style:{fontFamily:"'__Inter_d65c78', '__Inter_Fallback_d65c78'",fontStyle:"normal"},className:"__className_d65c78"}}},function(n){n.O(0,[944,971,117,744],function(){return n(n.s=8486)}),_N_E=n.O()}]); -------------------------------------------------------------------------------- /Frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./src/**/*.{vue,js,ts,tsx}'], 4 | theme: { 5 | extend: { 6 | borderColor: { 7 | custom: '#2e3640', 8 | primary: '#49515b', 9 | primaryYellow: '#fecb00', 10 | }, 11 | outlineColor: { 12 | custom: '#2e3640', 13 | primary: '#49515b', 14 | primaryYellow: '#fecb00', 15 | }, 16 | }, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /Frontend/src/Utils/MessageUtils.ts: -------------------------------------------------------------------------------- 1 | export const sendMessageToBackend = (method: string, parameters?: any) => { 2 | const message = { Method: method, Parameters: parameters }; 3 | if ((window as any).external && typeof (window as any).external.sendMessage === 'function') { 4 | const messageString = JSON.stringify(message); 5 | (window as any).external.sendMessage(messageString); 6 | } else { 7 | console.error('window.external.sendMessage is not available.'); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/chunks/main-app-7adda32f7f76f69b.js: -------------------------------------------------------------------------------- 1 | (self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[744],{5343:function(e,n,t){Promise.resolve().then(t.t.bind(t,2846,23)),Promise.resolve().then(t.t.bind(t,9107,23)),Promise.resolve().then(t.t.bind(t,1060,23)),Promise.resolve().then(t.t.bind(t,4707,23)),Promise.resolve().then(t.t.bind(t,80,23)),Promise.resolve().then(t.t.bind(t,6423,23))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[971,117],function(){return n(4278),n(5343)}),_N_E=e.O()}]); -------------------------------------------------------------------------------- /Resources/wwwroot/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 'Frontend/**/*.{ts,tsx,js,jsx,css,md,json,html}': (files) => { 3 | const quoted = files.map((f) => `"${f}"`).join(' '); 4 | return [ 5 | `Frontend/node_modules/.bin/prettier --write ${quoted}`, 6 | `Frontend/node_modules/.bin/eslint --config Frontend/eslint.config.js --fix ${quoted}`, 7 | ]; 8 | }, 9 | '!(libobs-sharp)/**/*.cs': (files) => { 10 | const quoted = files.map((f) => `"${f}"`).join(' '); 11 | // Run dotnet format on the whole solution - more reliable than per-file 12 | return [`dotnet format Segra.sln --exclude libobs-sharp`]; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /Frontend/src/lib/supabase/client.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js'; 2 | 3 | const SUPABASE_URL = 'https://ponthqrnesnanivsatps.supabase.co'; 4 | const SUPABASE_ANON_KEY = 5 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBvbnRocXJuZXNuYW5pdnNhdHBzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Mzc2NzMzMjgsImV4cCI6MjA1MzI0OTMyOH0.k8pLDkDgKV0ZLjZjAZ6eUHa40rot5qWa7iJDQKWy1FA'; 6 | 7 | export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { 8 | auth: { 9 | flowType: 'pkce', 10 | autoRefreshToken: true, 11 | persistSession: true, 12 | detectSessionInUrl: false, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /Frontend/src/Components/UnavailableDeviceCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MdError } from 'react-icons/md'; 3 | 4 | const UnavailableDeviceCard: React.FC = () => { 5 | return ( 6 |
7 |
8 |
9 | 10 |

Some selected audio devices are unavailable

11 |
12 |
13 |
14 | ); 15 | }; 16 | 17 | export default UnavailableDeviceCard; 18 | -------------------------------------------------------------------------------- /Backend/Windows/Watchers/DisplayWatcher.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Win32; 2 | 3 | namespace Segra.Backend.Windows.Watchers 4 | { 5 | public class DisplayWatcher : IDisposable 6 | { 7 | public event Action? DisplaysChanged; 8 | 9 | public DisplayWatcher() 10 | { 11 | SystemEvents.DisplaySettingsChanged += OnDisplaySettingsChanged; 12 | } 13 | 14 | private void OnDisplaySettingsChanged(object? sender, EventArgs e) 15 | { 16 | DisplaysChanged?.Invoke(); 17 | } 18 | 19 | public void Dispose() 20 | { 21 | SystemEvents.DisplaySettingsChanged -= OnDisplaySettingsChanged; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Frontend/src/Components/AnimatedCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import { motion } from 'framer-motion'; 3 | 4 | interface AnimatedCardProps { 5 | children: ReactNode; 6 | } 7 | 8 | const AnimatedCard: React.FC = ({ children }) => { 9 | return ( 10 | 22 | {children} 23 | 24 | ); 25 | }; 26 | 27 | export default AnimatedCard; 28 | -------------------------------------------------------------------------------- /Frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Segra 11 | 12 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "target": "ES2022", 6 | "lib": ["ES2023"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /Frontend/src/Components/CloudBadge.tsx: -------------------------------------------------------------------------------- 1 | import { MdOutlineCloud } from 'react-icons/md'; 2 | 3 | type TooltipSide = 'top' | 'right' | 'bottom' | 'left'; 4 | 5 | interface CloudBadgeProps { 6 | tip?: string; 7 | side?: TooltipSide; 8 | className?: string; 9 | iconClassName?: string; 10 | } 11 | 12 | export default function CloudBadge({ 13 | tip = 'Uses internet', 14 | side = 'top', 15 | className = '', 16 | iconClassName = '', 17 | }: CloudBadgeProps) { 18 | return ( 19 |
24 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /Frontend/src/Components/AiBadge.tsx: -------------------------------------------------------------------------------- 1 | import { HiOutlineSparkles } from 'react-icons/hi'; 2 | 3 | type TooltipSide = 'top' | 'right' | 'bottom' | 'left'; 4 | 5 | interface AiBadgeProps { 6 | tip?: string; 7 | side?: TooltipSide; 8 | className?: string; 9 | iconClassName?: string; 10 | } 11 | 12 | export default function AiBadge({ 13 | tip = 'Powered by Segra AI', 14 | side = 'top', 15 | className = '', 16 | iconClassName = '', 17 | }: AiBadgeProps) { 18 | return ( 19 |
24 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /Frontend/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /Frontend/src/Hooks/useUserProfile.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { supabase } from '../lib/supabase/client'; 3 | import { useAuth } from './useAuth.tsx'; 4 | 5 | export function useProfile() { 6 | const { user } = useAuth(); 7 | return useQuery({ 8 | queryKey: ['profile', user?.id], 9 | queryFn: async () => { 10 | if (!user) return null; 11 | 12 | const { data: profile, error } = await supabase 13 | .from('profiles') 14 | .select('username, avatar_url') 15 | .eq('id', user.id) 16 | .single(); 17 | 18 | if (error) throw error; 19 | return profile; 20 | }, 21 | enabled: !!user, 22 | staleTime: 1000 * 60 * 5, 23 | gcTime: 1000 * 60 * 30, 24 | placeholderData: (previousData) => previousData, 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /Frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "useDefineForClassFields": true, 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "isolatedModules": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true 25 | }, 26 | "include": ["src"] 27 | } 28 | -------------------------------------------------------------------------------- /Frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import { version } from './package.json'; 4 | import tailwindcss from '@tailwindcss/vite'; 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), tailwindcss()], 9 | server: { 10 | port: 2882, 11 | }, 12 | define: { 13 | __APP_VERSION__: JSON.stringify(version), 14 | }, 15 | build: { 16 | // Add cache busting for assets with content hashing 17 | rollupOptions: { 18 | output: { 19 | entryFileNames: 'assets/[name].[hash].js', 20 | chunkFileNames: 'assets/[name].[hash].js', 21 | assetFileNames: 'assets/[name].[hash].[ext]', 22 | }, 23 | }, 24 | // Ensure no caching issues by generating proper cache headers 25 | manifest: true, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /Backend/Core/Models/KeybindSettings.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Segra.Backend.Core.Models 4 | { 5 | public class Keybind 6 | { 7 | [JsonPropertyName("action")] 8 | [JsonConverter(typeof(JsonStringEnumConverter))] 9 | public KeybindAction Action { get; set; } 10 | 11 | [JsonPropertyName("enabled")] 12 | public bool Enabled { get; set; } 13 | 14 | [JsonPropertyName("keys")] 15 | public List Keys { get; set; } 16 | 17 | public Keybind(List keys, KeybindAction action, bool enabled = true) 18 | { 19 | Keys = keys; 20 | Action = action; 21 | Enabled = enabled; 22 | } 23 | } 24 | 25 | [JsonConverter(typeof(JsonStringEnumConverter))] 26 | public enum KeybindAction 27 | { 28 | CreateBookmark, 29 | SaveReplayBuffer 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Frontend/src/Pages/clips.tsx: -------------------------------------------------------------------------------- 1 | import { MdOutlineContentCut } from 'react-icons/md'; 2 | import { useClipping } from '../Context/ClippingContext'; 3 | import ContentPage from '../Components/ContentPage'; 4 | import ContentCard from '../Components/ContentCard'; 5 | 6 | export default function Clips() { 7 | const { clippingProgress } = useClipping(); 8 | 9 | // Pre-render the progress card element 10 | const progressCardElement = 11 | Object.keys(clippingProgress).length > 0 ? ( 12 | 13 | ) : null; 14 | 15 | return ( 16 | 0} 23 | progressCardElement={progressCardElement} 24 | /> 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /Frontend/src/Pages/highlights.tsx: -------------------------------------------------------------------------------- 1 | import { HiOutlineSparkles } from 'react-icons/hi'; 2 | import { useAiHighlights } from '../Context/AiHighlightsContext'; 3 | import ContentPage from '../Components/ContentPage'; 4 | import AiContentCard from '../Components/AiContentCard'; 5 | 6 | export default function Highlights() { 7 | const { aiProgress } = useAiHighlights(); 8 | 9 | // Pre-render the progress card element 10 | const progressCardElement = 11 | Object.keys(aiProgress).length > 0 ? ( 12 | 13 | ) : null; 14 | 15 | return ( 16 | 0} 23 | progressCardElement={progressCardElement} 24 | /> 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /Backend/Obs/OBSWindow.cs: -------------------------------------------------------------------------------- 1 | namespace Segra.Backend.Obs 2 | { 3 | internal class OBSWindow : Form 4 | { 5 | public OBSWindow() 6 | { 7 | // Hide the form 8 | ShowInTaskbar = false; 9 | FormBorderStyle = FormBorderStyle.None; 10 | Opacity = 0; 11 | 12 | // Initialize OBS utils asynchronously 13 | Task.Run(() => OBSService.InitializeAsync()); 14 | } 15 | 16 | protected override void OnLoad(EventArgs e) 17 | { 18 | base.OnLoad(e); 19 | Hide(); // Ensure the form is hidden on load 20 | } 21 | 22 | protected override CreateParams CreateParams 23 | { 24 | get 25 | { 26 | var cp = base.CreateParams; 27 | cp.ExStyle |= 0x80; // WS_EX_TOOLWINDOW to prevent from showing in Alt+Tab 28 | return cp; 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Frontend/src/Context/SelectedVideoContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, ReactNode } from 'react'; 2 | import { Content } from '../Models/types'; 3 | 4 | interface SelectedVideoContextProps { 5 | selectedVideo: Content | null; 6 | setSelectedVideo: (video: Content | null) => void; 7 | } 8 | 9 | const SelectedVideoContext = createContext(undefined); 10 | 11 | export const SelectedVideoProvider = ({ children }: { children: ReactNode }) => { 12 | const [selectedVideo, setSelectedVideo] = useState(null); 13 | 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | }; 20 | 21 | export const useSelectedVideo = () => { 22 | const context = useContext(SelectedVideoContext); 23 | if (!context) { 24 | throw new Error('useSelectedVideo must be used within a SelectedVideoProvider'); 25 | } 26 | return context; 27 | }; 28 | -------------------------------------------------------------------------------- /Frontend/src/Pages/sessions.tsx: -------------------------------------------------------------------------------- 1 | import { MdOutlineVideoSettings } from 'react-icons/md'; 2 | import ContentPage from '../Components/ContentPage'; 3 | import { useSettings } from '../Context/SettingsContext'; 4 | import ContentCard from '../Components/ContentCard'; 5 | 6 | export default function Sessions() { 7 | const { state } = useSettings(); 8 | const { recording } = state; 9 | 10 | // Pre-render the progress card element 11 | const isRecordingFinishing = recording && recording.endTime !== null; 12 | const progressCardElement = isRecordingFinishing ? ( 13 | 14 | ) : null; 15 | 16 | return ( 17 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /Properties/Settings.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace Segra.Properties { 12 | 13 | 14 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 15 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.12.0.0")] 16 | internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { 17 | 18 | private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); 19 | 20 | public static Settings Default { 21 | get { 22 | return defaultInstance; 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Backend/Core/Models/Display.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Segra.Backend.Core.Models 4 | { 5 | public class Display : IEquatable 6 | { 7 | [JsonPropertyName("deviceName")] 8 | public required string DeviceName { get; set; } 9 | 10 | [JsonPropertyName("deviceId")] 11 | public required string DeviceId { get; set; } 12 | 13 | [JsonPropertyName("isPrimary")] 14 | public required bool IsPrimary { get; set; } 15 | 16 | public bool Equals(Display? other) 17 | { 18 | if (other == null) return false; 19 | 20 | return DeviceName == other.DeviceName && 21 | DeviceId == other.DeviceId && 22 | IsPrimary == other.IsPrimary; 23 | } 24 | 25 | public override bool Equals(object? obj) 26 | { 27 | if (obj is Display display) 28 | { 29 | return Equals(display); 30 | } 31 | return false; 32 | } 33 | 34 | public override int GetHashCode() 35 | { 36 | return HashCode.Combine(DeviceName, DeviceId, IsPrimary); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Backend/Windows/Power/PowerModeMonitor.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Win32; 2 | using Serilog; 3 | 4 | namespace Segra.Backend.Windows.Power 5 | { 6 | internal static class PowerModeMonitor 7 | { 8 | private static bool _isMonitoring = false; 9 | 10 | public static void StartMonitoring() 11 | { 12 | if (_isMonitoring) 13 | { 14 | Log.Warning("PowerModeMonitor is already running"); 15 | return; 16 | } 17 | 18 | SystemEvents.PowerModeChanged += OnPowerModeChanged; 19 | _isMonitoring = true; 20 | } 21 | 22 | private static void OnPowerModeChanged(object sender, PowerModeChangedEventArgs e) 23 | { 24 | switch (e.Mode) 25 | { 26 | case PowerModes.Resume: 27 | Log.Information("Power Mode: RESUME - System woke up from sleep/hibernation"); 28 | break; 29 | case PowerModes.Suspend: 30 | Log.Information("Power Mode: SUSPEND - System is entering sleep/hibernation"); 31 | break; 32 | default: 33 | break; 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Backend/Core/Models/Codec.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Segra.Backend.Core.Models 4 | { 5 | public class Codec : IEquatable 6 | { 7 | [JsonPropertyName("friendlyName")] 8 | public required string FriendlyName { get; set; } 9 | 10 | [JsonPropertyName("internalEncoderId")] 11 | public required string InternalEncoderId { get; set; } 12 | 13 | [JsonPropertyName("isHardwareEncoder")] 14 | public required bool IsHardwareEncoder { get; set; } 15 | 16 | public bool Equals(Codec? other) 17 | { 18 | if (other == null) return false; 19 | 20 | return FriendlyName == other.FriendlyName && 21 | InternalEncoderId == other.InternalEncoderId && 22 | IsHardwareEncoder == other.IsHardwareEncoder; 23 | } 24 | 25 | public override bool Equals(object? obj) 26 | { 27 | if (obj is Codec codec) 28 | { 29 | return Equals(codec); 30 | } 31 | return false; 32 | } 33 | 34 | public override int GetHashCode() 35 | { 36 | return HashCode.Combine(FriendlyName, InternalEncoderId, IsHardwareEncoder); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Frontend/src/Components/UploadCard.tsx: -------------------------------------------------------------------------------- 1 | import type { UploadProgress } from '../Context/UploadContext'; 2 | 3 | interface UploadCardProps { 4 | upload: UploadProgress; 5 | } 6 | 7 | export default function UploadCard({ upload }: UploadCardProps) { 8 | const getStatusText = () => { 9 | switch (upload.status) { 10 | case 'uploading': 11 | return 'Uploading'; 12 | case 'processing': 13 | return 'Processing...'; 14 | case 'done': 15 | return 'Upload Complete'; 16 | case 'error': 17 | return upload.message || 'Upload Failed'; 18 | default: 19 | return 'Uploading...'; 20 | } 21 | }; 22 | 23 | return ( 24 |
25 |
26 |
27 | {/* Progress */} 28 | 29 | 30 | {/* Upload Details */} 31 |
32 |
{getStatusText()}
33 |
{upload.title}
34 |
35 |
36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /Frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 4 | import { supabase } from './lib/supabase/client'; 5 | import './globals.css'; 6 | import App from './App.tsx'; 7 | import { SelectedVideoProvider } from './Context/SelectedVideoContext.tsx'; 8 | import { SelectedMenuProvider } from './Context/SelectedMenuContext'; 9 | import { AuthProvider } from './Hooks/useAuth.tsx'; 10 | 11 | // Create a React Query client 12 | const queryClient = new QueryClient({ 13 | defaultOptions: { 14 | queries: { 15 | staleTime: 1000 * 60 * 5, // 5 minutes 16 | gcTime: 1000 * 60 * 30, // 30 minutes 17 | }, 18 | }, 19 | }); 20 | 21 | // Initialize auth listener 22 | supabase.auth.onAuthStateChange((event) => { 23 | if (event === 'SIGNED_OUT') { 24 | queryClient.clear(); 25 | } 26 | }); 27 | 28 | createRoot(document.getElementById('root')!).render( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | , 40 | ); 41 | -------------------------------------------------------------------------------- /Resources/wwwroot/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Frontend/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #1a1a1a; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | @media (prefers-color-scheme: light) { 53 | :root { 54 | color: #213547; 55 | background-color: #ffffff; 56 | } 57 | a:hover { 58 | color: #747bff; 59 | } 60 | button { 61 | background-color: #f9f9f9; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Frontend/src/Context/ScrollContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState } from 'react'; 2 | 3 | type ScrollPositions = { 4 | sessions: number; 5 | clips: number; 6 | highlights: number; 7 | replayBuffer: number; 8 | }; 9 | 10 | interface ScrollContextType { 11 | scrollPositions: ScrollPositions; 12 | setScrollPosition: (page: keyof ScrollPositions, position: number) => void; 13 | } 14 | 15 | const ScrollContext = createContext(undefined); 16 | 17 | export function ScrollProvider({ children }: { children: React.ReactNode }) { 18 | const [scrollPositions, setScrollPositions] = useState({ 19 | sessions: 0, 20 | clips: 0, 21 | highlights: 0, 22 | replayBuffer: 0, 23 | }); 24 | 25 | const setScrollPosition = (page: keyof ScrollPositions, position: number) => { 26 | setScrollPositions((prev) => ({ 27 | ...prev, 28 | [page]: position, 29 | })); 30 | }; 31 | 32 | return ( 33 | 34 | {children} 35 | 36 | ); 37 | } 38 | 39 | export function useScroll() { 40 | const context = useContext(ScrollContext); 41 | if (context === undefined) { 42 | throw new Error('useScroll must be used within a ScrollProvider'); 43 | } 44 | return context; 45 | } 46 | -------------------------------------------------------------------------------- /Frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import globals from 'globals'; 3 | import reactHooks from 'eslint-plugin-react-hooks'; 4 | import reactRefresh from 'eslint-plugin-react-refresh'; 5 | import tseslint from 'typescript-eslint'; 6 | import eslintConfigPrettier from 'eslint-config-prettier'; 7 | import prettierPlugin from 'eslint-plugin-prettier'; 8 | 9 | export default tseslint.config( 10 | { ignores: ['dist', 'node_modules', 'build', '.next', 'coverage'] }, 11 | { 12 | extends: [js.configs.recommended, ...tseslint.configs.recommended, eslintConfigPrettier], 13 | files: ['**/*.{ts,tsx}'], 14 | languageOptions: { 15 | ecmaVersion: 2020, 16 | globals: globals.browser, 17 | }, 18 | plugins: { 19 | 'react-hooks': reactHooks, 20 | 'react-refresh': reactRefresh, 21 | prettier: prettierPlugin, 22 | }, 23 | rules: { 24 | ...reactHooks.configs.recommended.rules, 25 | 'prettier/prettier': 'error', 26 | // Temporarily allow the following rules 27 | 'react-refresh/only-export-components': 'off', 28 | 'react-hooks/rules-of-hooks': 'off', 29 | 'react-hooks/exhaustive-deps': 'off', 30 | 'react-hooks/immutability': 'off', 31 | 'react-hooks/set-state-in-effect': 'off', 32 | 'react-hooks/preserve-manual-memoization': 'off', 33 | '@typescript-eslint/no-explicit-any': 'off', 34 | '@typescript-eslint/no-unused-vars': 'off', 35 | }, 36 | }, 37 | ); 38 | -------------------------------------------------------------------------------- /Segra.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.10.35013.160 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Segra", "Segra.csproj", "{754FBF17-B609-4B2C-A214-317632AB6AAB}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Debug|x64 = Debug|x64 12 | Release|Any CPU = Release|Any CPU 13 | Release|x64 = Release|x64 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {754FBF17-B609-4B2C-A214-317632AB6AAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {754FBF17-B609-4B2C-A214-317632AB6AAB}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {754FBF17-B609-4B2C-A214-317632AB6AAB}.Debug|x64.ActiveCfg = Debug|x64 19 | {754FBF17-B609-4B2C-A214-317632AB6AAB}.Debug|x64.Build.0 = Debug|x64 20 | {754FBF17-B609-4B2C-A214-317632AB6AAB}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {754FBF17-B609-4B2C-A214-317632AB6AAB}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {754FBF17-B609-4B2C-A214-317632AB6AAB}.Release|x64.ActiveCfg = Release|x64 23 | {754FBF17-B609-4B2C-A214-317632AB6AAB}.Release|x64.Build.0 = Release|x64 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ExtensibilityGlobals) = postSolution 29 | SolutionGuid = {C27ADC7A-5C6E-495F-AE38-AAFB2AC23437} 30 | EndGlobalSection 31 | EndGlobal 32 | -------------------------------------------------------------------------------- /Frontend/src/Context/SelectedMenuContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, ReactNode, useCallback } from 'react'; 2 | 3 | interface SelectedMenuContextValue { 4 | selectedMenu: string; 5 | setSelectedMenu: (menu: string) => void; 6 | } 7 | 8 | const SelectedMenuContext = createContext(undefined); 9 | 10 | const defaultMenu = 'Full Sessions'; 11 | 12 | const getInitialMenu = () => { 13 | if (typeof window === 'undefined') { 14 | return defaultMenu; 15 | } 16 | 17 | const stored = (window as typeof window & { __selectedMenu?: string }).__selectedMenu; 18 | return stored ?? defaultMenu; 19 | }; 20 | 21 | export const SelectedMenuProvider = ({ children }: { children: ReactNode }) => { 22 | const [selectedMenuState, setSelectedMenuState] = useState(getInitialMenu); 23 | 24 | const setSelectedMenu = useCallback((menu: string) => { 25 | setSelectedMenuState(menu); 26 | if (typeof window !== 'undefined') { 27 | (window as typeof window & { __selectedMenu?: string }).__selectedMenu = menu; 28 | } 29 | }, []); 30 | 31 | return ( 32 | 33 | {children} 34 | 35 | ); 36 | }; 37 | 38 | export const useSelectedMenu = () => { 39 | const context = useContext(SelectedMenuContext); 40 | if (!context) { 41 | throw new Error('useSelectedMenu must be used within a SelectedMenuProvider'); 42 | } 43 | 44 | return context; 45 | }; 46 | -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/chunks/app/_not-found/page-eaa53ae320539b27.js: -------------------------------------------------------------------------------- 1 | (self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[409],{7589:function(e,t,n){(window.__NEXT_P=window.__NEXT_P||[]).push(["/_not-found/page",function(){return n(3634)}])},3634:function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"default",{enumerable:!0,get:function(){return s}}),n(7043);let i=n(7437);n(2265);let o={fontFamily:'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',height:"100vh",textAlign:"center",display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center"},l={display:"inline-block"},r={display:"inline-block",margin:"0 20px 0 0",padding:"0 23px 0 0",fontSize:24,fontWeight:500,verticalAlign:"top",lineHeight:"49px"},d={fontSize:14,fontWeight:400,lineHeight:"49px",margin:0};function s(){return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)("title",{children:"404: This page could not be found."}),(0,i.jsx)("div",{style:o,children:(0,i.jsxs)("div",{children:[(0,i.jsx)("style",{dangerouslySetInnerHTML:{__html:"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}),(0,i.jsx)("h1",{className:"next-error-h1",style:r,children:"404"}),(0,i.jsx)("div",{style:l,children:(0,i.jsx)("h2",{style:d,children:"This page could not be found."})})]})})]})}("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),e.exports=t.default)}},function(e){e.O(0,[971,117,744],function(){return e(e.s=7589)}),_N_E=e.O()}]); -------------------------------------------------------------------------------- /Frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "Developer Preview", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "lint:fix": "eslint . --fix", 11 | "format": "prettier . --write", 12 | "format:check": "prettier . --check", 13 | "preview": "vite preview" 14 | }, 15 | "dependencies": { 16 | "@supabase/supabase-js": "^2.83.0", 17 | "@tailwindcss/vite": "^4.1.17", 18 | "@tanstack/react-query": "^5.90.10", 19 | "@types/semver": "^7.7.1", 20 | "autoprefixer": "^10.4.22", 21 | "framer-motion": "^12.23.24", 22 | "markdown-to-jsx": "^8.0.0", 23 | "react": "^19.2.0", 24 | "react-dnd": "^16.0.1", 25 | "react-dnd-html5-backend": "^16.0.1", 26 | "react-dom": "^19.2.0", 27 | "react-icons": "^5.5.0", 28 | "react-use-websocket": "^4.13.0", 29 | "semver": "^7.7.3", 30 | "theme-change": "^2.5.0", 31 | "wavesurfer.js": "^7.11.1" 32 | }, 33 | "devDependencies": { 34 | "@eslint/js": "^9.39.1", 35 | "@tailwindcss/postcss": "^4.1.17", 36 | "@types/react": "^19.2.6", 37 | "@types/react-dom": "^19.2.3", 38 | "@vitejs/plugin-react": "^5.1.1", 39 | "daisyui": "^5.5.5", 40 | "eslint": "^9.39.1", 41 | "eslint-config-prettier": "^10.1.8", 42 | "eslint-plugin-prettier": "^5.5.4", 43 | "eslint-plugin-react-hooks": "^7.0.1", 44 | "eslint-plugin-react-refresh": "^0.4.24", 45 | "globals": "^16.5.0", 46 | "tailwindcss": "^4.1.17", 47 | "prettier": "^3.6.2", 48 | "typescript": "~5.9.3", 49 | "typescript-eslint": "^8.47.0", 50 | "vite": "^7.2.2" 51 | }, 52 | "trustedDependencies": [ 53 | "@tailwindcss/oxide" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /Backend/Windows/Watchers/AudioDeviceWatcher.cs: -------------------------------------------------------------------------------- 1 | using NAudio.CoreAudioApi.Interfaces; 2 | using NAudio.CoreAudioApi; 3 | 4 | namespace Segra.Backend.Windows.Watchers 5 | { 6 | public class AudioDeviceWatcher : IMMNotificationClient, IDisposable 7 | { 8 | private MMDeviceEnumerator? _deviceEnumerator; 9 | 10 | public event Action? DevicesChanged; 11 | 12 | public AudioDeviceWatcher() 13 | { 14 | _deviceEnumerator = new MMDeviceEnumerator(); 15 | _deviceEnumerator.RegisterEndpointNotificationCallback(this); 16 | } 17 | 18 | // IMMNotificationClient implementation 19 | public void OnDeviceStateChanged(string deviceId, DeviceState newState) 20 | { 21 | DevicesChanged?.Invoke(); 22 | } 23 | 24 | public void OnDeviceAdded(string pwstrDeviceId) 25 | { 26 | DevicesChanged?.Invoke(); 27 | } 28 | 29 | public void OnDeviceRemoved(string deviceId) 30 | { 31 | DevicesChanged?.Invoke(); 32 | } 33 | 34 | public void OnDefaultDeviceChanged(DataFlow flow, Role role, string defaultDeviceId) 35 | { 36 | DevicesChanged?.Invoke(); 37 | } 38 | 39 | public void OnPropertyValueChanged(string pwstrDeviceId, PropertyKey key) 40 | { 41 | // Not needed for this purpose 42 | } 43 | 44 | // IDisposable implementation 45 | public void Dispose() 46 | { 47 | if (_deviceEnumerator != null) 48 | { 49 | _deviceEnumerator.UnregisterEndpointNotificationCallback(this); 50 | _deviceEnumerator.Dispose(); 51 | _deviceEnumerator = null; 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Backend/Core/Models/Bookmark.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Segra.Backend.Core.Models 4 | { 5 | public class Bookmark 6 | { 7 | private static readonly Random random = new Random(); 8 | public int Id { get; set; } = random.Next(1, int.MaxValue); 9 | [JsonConverter(typeof(JsonStringEnumConverter))] 10 | public BookmarkType Type { get; set; } 11 | [JsonConverter(typeof(JsonStringEnumConverter))] 12 | public BookmarkSubtype? Subtype { get; set; } 13 | public TimeSpan Time { get; set; } 14 | // TODO (os): Set this rating from the ai analysis 15 | public int? AiRating { get; set; } 16 | } 17 | 18 | [JsonConverter(typeof(JsonStringEnumConverter))] 19 | public enum BookmarkType 20 | { 21 | Manual, 22 | [IncludeInHighlight] Kill, 23 | [IncludeInHighlight] Goal, 24 | Assist, 25 | Death 26 | } 27 | 28 | /// 29 | /// Marks a BookmarkType as one that should be included in auto-generated highlights. 30 | /// 31 | [AttributeUsage(AttributeTargets.Field)] 32 | public class IncludeInHighlightAttribute : Attribute { } 33 | 34 | public static class BookmarkTypeExtensions 35 | { 36 | /// 37 | /// Returns true if this bookmark type should be included in auto-generated highlights. 38 | /// 39 | public static bool IncludeInHighlight(this BookmarkType type) => 40 | typeof(BookmarkType).GetField(type.ToString())! 41 | .GetCustomAttributes(typeof(IncludeInHighlightAttribute), false).Length > 0; 42 | } 43 | 44 | [JsonConverter(typeof(JsonStringEnumConverter))] 45 | public enum BookmarkSubtype 46 | { 47 | Headshot 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Frontend/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }); 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react'; 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }); 50 | ``` 51 | -------------------------------------------------------------------------------- /Frontend/src/Components/ConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import { MdWarning } from 'react-icons/md'; 2 | 3 | export interface ConfirmationModalProps { 4 | title: string; 5 | description: string; 6 | confirmText?: string; 7 | cancelText?: string; 8 | onConfirm: () => void; 9 | onCancel: () => void; 10 | } 11 | 12 | export default function ConfirmationModal({ 13 | title, 14 | description, 15 | confirmText = 'Confirm', 16 | cancelText = 'Cancel', 17 | onConfirm, 18 | onCancel, 19 | }: ConfirmationModalProps) { 20 | return ( 21 | <> 22 | {/* Header */} 23 |
24 |
25 | 26 | 27 | 28 |

{title}

29 |
30 | 36 |
37 | 38 |
39 |
{description}
40 |
41 | 42 | {/* Footer with buttons */} 43 |
44 | 50 | 56 |
57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /Frontend/src/Components/GenericModal.tsx: -------------------------------------------------------------------------------- 1 | import { MdInfo, MdWarning, MdError } from 'react-icons/md'; 2 | 3 | export interface ModalProps { 4 | title: string; 5 | subtitle?: string; 6 | description: string; 7 | type: 'info' | 'warning' | 'error'; 8 | onClose: () => void; 9 | } 10 | 11 | export default function GenericModal({ title, subtitle, description, type, onClose }: ModalProps) { 12 | // Define icon and colors based on type 13 | const getTypeStyles = () => { 14 | switch (type) { 15 | case 'info': 16 | return { 17 | icon: , 18 | titleColor: 'text-white', 19 | }; 20 | case 'warning': 21 | return { 22 | icon: , 23 | titleColor: 'text-warning', 24 | }; 25 | case 'error': 26 | return { 27 | icon: , 28 | titleColor: 'text-error', 29 | }; 30 | default: 31 | return { 32 | icon: , 33 | titleColor: 'text-white', 34 | }; 35 | } 36 | }; 37 | 38 | const { icon, titleColor } = getTypeStyles(); 39 | 40 | return ( 41 | <> 42 | {/* Header */} 43 |
44 |
45 | {icon} 46 |

{title}

47 |
48 | {subtitle &&

{subtitle}

} 49 | 55 |
56 | 57 |
58 |
{description}
59 |
60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /Frontend/src/Pages/settings.tsx: -------------------------------------------------------------------------------- 1 | import { useSettings, useSettingsUpdater } from '../Context/SettingsContext'; 2 | import { useUpdate } from '../Context/UpdateContext'; 3 | import AccountSection from '../Components/Settings/AccountSection'; 4 | import CaptureModeSection from '../Components/Settings/CaptureModeSection'; 5 | import VideoSettingsSection from '../Components/Settings/VideoSettingsSection'; 6 | import StorageSettingsSection from '../Components/Settings/StorageSettingsSection'; 7 | import ClipSettingsSection from '../Components/Settings/ClipSettingsSection'; 8 | import AudioDevicesSection from '../Components/Settings/AudioDevicesSection'; 9 | import KeybindingsSection from '../Components/Settings/KeybindingsSection'; 10 | import GameDetectionSection from '../Components/Settings/GameDetectionSection'; 11 | import UISettingsSection from '../Components/Settings/UISettingsSection'; 12 | 13 | export default function Settings() { 14 | const { openReleaseNotesModal, checkForUpdates } = useUpdate(); 15 | const settings = useSettings(); 16 | const updateSettings = useSettingsUpdater(); 17 | 18 | return ( 19 |
20 |

Settings

21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /Backend/Services/GameIntegrationService.cs: -------------------------------------------------------------------------------- 1 | using Segra.Backend.Core.Models; 2 | using Segra.Backend.Games; 3 | using Segra.Backend.Games.CounterStrike2; 4 | using Segra.Backend.Games.LeagueOfLegends; 5 | using Segra.Backend.Games.Pubg; 6 | using Segra.Backend.Games.RocketLeague; 7 | using Serilog; 8 | 9 | namespace Segra.Backend.Services 10 | { 11 | public static class GameIntegrationService 12 | { 13 | private const string PUBG = "PLAYERUNKNOWN'S BATTLEGROUNDS"; 14 | private const string LOL = "League of Legends"; 15 | private const string CS2 = "Counter-Strike 2"; 16 | private const string ROCKET_LEAGUE = "Rocket League"; 17 | private static Integration? _gameIntegration; 18 | public static Integration? GameIntegration => _gameIntegration; 19 | 20 | public static async Task Start(string gameName) 21 | { 22 | if (_gameIntegration != null) 23 | { 24 | Log.Information("Active game integration already exists! Shutting down before starting"); 25 | await _gameIntegration.Shutdown(); 26 | } 27 | 28 | _gameIntegration = gameName switch 29 | { 30 | PUBG => new PubgIntegration(), 31 | LOL => new LeagueOfLegendsIntegration(), 32 | CS2 => new CounterStrike2Integration(), 33 | ROCKET_LEAGUE => Settings.Instance.EnableRocketLeagueIntegration ? new RocketLeagueIntegration() : null, 34 | _ => null, 35 | }; 36 | 37 | if (_gameIntegration == null) 38 | { 39 | return; 40 | } 41 | 42 | Log.Information($"Starting game integration for: {gameName}"); 43 | _ = _gameIntegration.Start(); 44 | } 45 | 46 | public static async Task Shutdown() 47 | { 48 | if (_gameIntegration == null) 49 | { 50 | return; 51 | } 52 | 53 | Log.Information("Shutting down game integration"); 54 | await _gameIntegration.Shutdown(); 55 | _gameIntegration = null; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Frontend/src/Context/SelectionsContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, ReactNode } from 'react'; 2 | import { Selection } from '../Models/types'; 3 | 4 | interface SelectionsContextType { 5 | selections: Selection[]; 6 | addSelection: (sel: Selection) => void; 7 | updateSelection: (sel: Selection) => void; 8 | updateSelectionsArray: (sel: Selection[]) => void; 9 | removeSelection: (id: number) => void; 10 | clearSelectionsForVideo: (fileName: string) => void; 11 | clearAllSelections: () => void; 12 | } 13 | 14 | const SelectionsContext = createContext(undefined); 15 | 16 | export const SelectionsProvider = ({ children }: { children: ReactNode }) => { 17 | const [selections, setSelections] = useState([]); 18 | 19 | const addSelection = (sel: Selection) => { 20 | setSelections((prev) => [...prev, sel]); 21 | }; 22 | 23 | const updateSelection = (updatedSel: Selection) => { 24 | setSelections((prev) => prev.map((sel) => (sel.id === updatedSel.id ? updatedSel : sel))); 25 | }; 26 | 27 | const updateSelectionsArray = (newSelections: Selection[]) => { 28 | setSelections(newSelections); 29 | }; 30 | 31 | const removeSelection = (id: number) => { 32 | setSelections((prev) => prev.filter((sel) => sel.id !== id)); 33 | }; 34 | 35 | const clearSelectionsForVideo = (fileName: string) => { 36 | setSelections((prev) => prev.filter((sel) => sel.fileName !== fileName)); 37 | }; 38 | 39 | const clearAllSelections = () => { 40 | setSelections(() => []); 41 | }; 42 | 43 | return ( 44 | 55 | {children} 56 | 57 | ); 58 | }; 59 | 60 | export const useSelections = (): SelectionsContextType => { 61 | const context = useContext(SelectionsContext); 62 | if (!context) { 63 | throw new Error('useSelections must be used within a SelectionsProvider'); 64 | } 65 | return context; 66 | }; 67 | -------------------------------------------------------------------------------- /Frontend/src/Context/UploadContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, ReactNode, useState, useEffect } from 'react'; 2 | 3 | export interface UploadProgress { 4 | title: string; 5 | fileName: string; 6 | progress: number; 7 | status: 'uploading' | 'processing' | 'done' | 'error'; 8 | message?: string; 9 | } 10 | 11 | interface UploadContextType { 12 | uploads: Record; 13 | removeUpload: (fileName: string) => void; 14 | } 15 | 16 | const UploadContext = createContext(undefined); 17 | 18 | export function UploadProvider({ children }: { children: ReactNode }) { 19 | const [uploads, setUploads] = useState>({}); 20 | 21 | useEffect(() => { 22 | const handleWebSocketMessage = (event: CustomEvent) => { 23 | const data = event.detail; 24 | 25 | if (data.method === 'UploadProgress') { 26 | const { title, fileName, progress, status, message } = data.content; 27 | setUploads((prev) => ({ 28 | ...prev, 29 | [fileName]: { title, fileName, progress, status, message }, 30 | })); 31 | 32 | if (status === 'done' || status === 'error') { 33 | setUploads((prev) => { 34 | const newUploads = { ...prev }; 35 | delete newUploads[fileName]; 36 | return newUploads; 37 | }); 38 | } 39 | } 40 | }; 41 | 42 | window.addEventListener('websocket-message', handleWebSocketMessage as EventListener); 43 | 44 | return () => { 45 | window.removeEventListener('websocket-message', handleWebSocketMessage as EventListener); 46 | }; 47 | }, []); 48 | 49 | const removeUpload = (fileName: string) => { 50 | setUploads((prev) => { 51 | const newUploads = { ...prev }; 52 | delete newUploads[fileName]; 53 | return newUploads; 54 | }); 55 | }; 56 | 57 | return ( 58 | {children} 59 | ); 60 | } 61 | 62 | export function useUploads() { 63 | const context = useContext(UploadContext); 64 | if (!context) { 65 | throw new Error('useUploads must be used within an UploadProvider'); 66 | } 67 | return context; 68 | } 69 | -------------------------------------------------------------------------------- /Frontend/src/Context/AiHighlightsContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, ReactNode, useState, useEffect } from 'react'; 2 | import { AiProgress } from '../Models/types'; 3 | 4 | interface AiHighlightsContextType { 5 | aiProgress: Record; 6 | removeAiHighlight: (id: string) => void; 7 | } 8 | 9 | const AiHighlightsContext = createContext(undefined); 10 | 11 | export function AiHighlightsProvider({ children }: { children: ReactNode }) { 12 | const [aiProgress, setAiProgress] = useState>({}); 13 | 14 | useEffect(() => { 15 | const handleWebSocketMessage = (event: CustomEvent<{ method: string; content: any }>) => { 16 | const { method, content } = event.detail; 17 | 18 | if (method === 'AiProgress') { 19 | const progress = content as AiProgress; 20 | setAiProgress((prev) => ({ 21 | ...prev, 22 | [progress.id]: progress, 23 | })); 24 | 25 | if (progress.status === 'done') { 26 | setAiProgress((prev) => { 27 | const { [progress.id]: _, ...rest } = prev; 28 | return rest; 29 | }); 30 | } else if (progress.progress < 0) { 31 | // This is an error, remove error after 5 seconds so user can see the message 32 | setTimeout(() => { 33 | setAiProgress((prev) => { 34 | const { [progress.id]: _, ...rest } = prev; 35 | return rest; 36 | }); 37 | }, 5000); 38 | } 39 | } 40 | }; 41 | 42 | window.addEventListener('websocket-message', handleWebSocketMessage as EventListener); 43 | return () => { 44 | window.removeEventListener('websocket-message', handleWebSocketMessage as EventListener); 45 | }; 46 | }, []); 47 | 48 | const removeAiHighlight = (id: string) => { 49 | setAiProgress((prev) => { 50 | const { [id]: _, ...rest } = prev; 51 | return rest; 52 | }); 53 | }; 54 | 55 | return ( 56 | 57 | {children} 58 | 59 | ); 60 | } 61 | 62 | export function useAiHighlights() { 63 | const context = useContext(AiHighlightsContext); 64 | if (!context) { 65 | throw new Error('useAiHighlights must be used within an AiHighlightsProvider'); 66 | } 67 | return context; 68 | } 69 | -------------------------------------------------------------------------------- /Frontend/src/Context/CompressionContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, ReactNode, useState, useEffect } from 'react'; 2 | 3 | interface CompressionProgress { 4 | filePath: string; 5 | progress: number; 6 | status: 'compressing' | 'done' | 'error' | 'skipped'; 7 | message?: string; 8 | } 9 | 10 | interface CompressionContextType { 11 | compressionProgress: Record; 12 | isCompressing: (filePath: string) => boolean; 13 | } 14 | 15 | const CompressionContext = createContext(undefined); 16 | 17 | export function CompressionProvider({ children }: { children: ReactNode }) { 18 | const [compressionProgress, setCompressionProgress] = useState< 19 | Record 20 | >({}); 21 | 22 | useEffect(() => { 23 | const handleWebSocketMessage = (event: CustomEvent<{ method: string; content: any }>) => { 24 | const { method, content } = event.detail; 25 | 26 | if (method === 'CompressionProgress') { 27 | const progress = content as CompressionProgress; 28 | 29 | if ( 30 | progress.status === 'done' || 31 | progress.status === 'error' || 32 | progress.status === 'skipped' 33 | ) { 34 | setTimeout(() => { 35 | setCompressionProgress((prev) => { 36 | const { [progress.filePath]: _, ...rest } = prev; 37 | return rest; 38 | }); 39 | }, 2000); 40 | } 41 | 42 | setCompressionProgress((prev) => ({ 43 | ...prev, 44 | [progress.filePath]: progress, 45 | })); 46 | } 47 | }; 48 | 49 | window.addEventListener('websocket-message', handleWebSocketMessage as EventListener); 50 | return () => { 51 | window.removeEventListener('websocket-message', handleWebSocketMessage as EventListener); 52 | }; 53 | }, []); 54 | 55 | const isCompressing = (filePath: string) => { 56 | const progress = compressionProgress[filePath]; 57 | return progress?.status === 'compressing'; 58 | }; 59 | 60 | return ( 61 | 62 | {children} 63 | 64 | ); 65 | } 66 | 67 | export function useCompression() { 68 | const context = useContext(CompressionContext); 69 | if (context === undefined) { 70 | throw new Error('useCompression must be used within a CompressionProvider'); 71 | } 72 | return context; 73 | } 74 | -------------------------------------------------------------------------------- /Frontend/src/Context/ImportContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, ReactNode, useState, useEffect } from 'react'; 2 | 3 | export interface ImportProgress { 4 | id: string; 5 | fileName: string; 6 | progress: number; 7 | status: 'importing' | 'done' | 'error'; 8 | totalFiles: number; 9 | currentFileIndex: number; 10 | message?: string; 11 | } 12 | 13 | interface ImportContextType { 14 | imports: Record; 15 | removeImport: (id: string) => void; 16 | } 17 | 18 | const ImportContext = createContext(undefined); 19 | 20 | export function ImportProvider({ children }: { children: ReactNode }) { 21 | const [imports, setImports] = useState>({}); 22 | 23 | useEffect(() => { 24 | const handleWebSocketMessage = (event: CustomEvent) => { 25 | const data = event.detail; 26 | 27 | if (data.method === 'ImportProgress') { 28 | const { id, fileName, progress, status, totalFiles, currentFileIndex, message } = 29 | data.content; 30 | setImports((prev) => ({ 31 | ...prev, 32 | [id]: { 33 | id, 34 | fileName, 35 | progress, 36 | status, 37 | totalFiles, 38 | currentFileIndex, 39 | message, 40 | }, 41 | })); 42 | 43 | if (status === 'done' || status === 'error') { 44 | setTimeout(() => { 45 | setImports((prev) => { 46 | const newImports = { ...prev }; 47 | delete newImports[id]; 48 | return newImports; 49 | }); 50 | }, 3000); // Remove after 3 seconds 51 | } 52 | } 53 | }; 54 | 55 | window.addEventListener('websocket-message', handleWebSocketMessage as EventListener); 56 | 57 | return () => { 58 | window.removeEventListener('websocket-message', handleWebSocketMessage as EventListener); 59 | }; 60 | }, []); 61 | 62 | const removeImport = (id: string) => { 63 | setImports((prev) => { 64 | const newImports = { ...prev }; 65 | delete newImports[id]; 66 | return newImports; 67 | }); 68 | }; 69 | 70 | return ( 71 | {children} 72 | ); 73 | } 74 | 75 | export function useImports() { 76 | const context = useContext(ImportContext); 77 | if (!context) { 78 | throw new Error('useImports must be used within an ImportProvider'); 79 | } 80 | return context; 81 | } 82 | -------------------------------------------------------------------------------- /Frontend/src/Components/CircularProgress.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | interface CircularProgressProps { 4 | progress: number; 5 | size?: number; 6 | strokeWidth?: number; 7 | duration?: number; 8 | className?: string; 9 | showText?: boolean; 10 | } 11 | 12 | export default function CircularProgress({ 13 | progress, 14 | size = 24, 15 | strokeWidth = 2, 16 | duration = 700, 17 | className = '', 18 | showText = false, 19 | }: CircularProgressProps) { 20 | const radius = (size - strokeWidth) / 2; 21 | const circumference = 2 * Math.PI * radius; 22 | const offset = circumference - (progress / 100) * circumference; 23 | 24 | const [displayProgress, setDisplayProgress] = useState(0); 25 | 26 | useEffect(() => { 27 | if (!showText) return; 28 | 29 | if (progress > 95) { 30 | setDisplayProgress(progress); 31 | return; 32 | } 33 | 34 | const timer = setInterval(() => { 35 | setDisplayProgress((prev) => { 36 | const diff = progress - prev; 37 | if (Math.abs(diff) < 0.1) return progress; 38 | return prev + diff * 0.15; 39 | }); 40 | }, 50); 41 | 42 | return () => clearInterval(timer); 43 | }, [progress, showText]); 44 | 45 | const fontSize = size * 0.3; 46 | 47 | return ( 48 |
52 | 53 | 62 | 78 | 79 | {showText && ( 80 | 81 | {Math.round(displayProgress)}% 82 | 83 | )} 84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /Backend/Games/GameIconUtils.cs: -------------------------------------------------------------------------------- 1 | using System.Drawing.Imaging; 2 | using System.Runtime.InteropServices; 3 | using Serilog; 4 | 5 | namespace Segra.Backend.Games 6 | { 7 | public static class GameIconUtils 8 | { 9 | [DllImport("Shell32.dll", EntryPoint = "ExtractIconExW", CharSet = CharSet.Unicode, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)] 10 | private static extern int ExtractIconEx(string sFile, int iIndex, out IntPtr piLargeVersion, out IntPtr piSmallVersion, int amountIcons); 11 | 12 | public static string? ExtractIconAsBase64(string executablePath) 13 | { 14 | if (string.IsNullOrEmpty(executablePath) || !File.Exists(executablePath)) 15 | { 16 | Log.Warning($"Cannot extract icon: File does not exist at path {executablePath}"); 17 | return null; 18 | } 19 | 20 | try 21 | { 22 | IntPtr large; 23 | IntPtr small; 24 | 25 | int iconCount = ExtractIconEx(executablePath, 0, out large, out small, 1); 26 | 27 | // Only extract if there is an icon and it's large 28 | if (iconCount > 0 && large != IntPtr.Zero) 29 | { 30 | using (Icon icon = Icon.FromHandle(large)) 31 | { 32 | using (Bitmap bitmap = icon.ToBitmap()) 33 | { 34 | using (MemoryStream stream = new MemoryStream()) 35 | { 36 | bitmap.Save(stream, ImageFormat.Png); 37 | byte[] imageBytes = stream.ToArray(); 38 | string base64String = Convert.ToBase64String(imageBytes); 39 | return base64String; 40 | } 41 | } 42 | } 43 | } 44 | else 45 | { 46 | Log.Warning($"No icons found in executable: {executablePath}"); 47 | } 48 | 49 | // Free the icon resources 50 | if (large != IntPtr.Zero) 51 | DestroyIcon(large); 52 | if (small != IntPtr.Zero) 53 | DestroyIcon(small); 54 | } 55 | catch (Exception ex) 56 | { 57 | Log.Error($"Error extracting icon from {executablePath}: {ex.Message}"); 58 | } 59 | 60 | return null; 61 | } 62 | 63 | [DllImport("User32.dll")] 64 | private static extern bool DestroyIcon(IntPtr hIcon); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Frontend/src/Models/WebSocketMessages.ts: -------------------------------------------------------------------------------- 1 | import { Settings, Game } from './types'; 2 | 3 | export interface ModalMessage { 4 | title: string; 5 | subtitle?: string; 6 | description: string; 7 | type: 'info' | 'warning' | 'error'; 8 | } 9 | 10 | export interface UploadProgressMessage { 11 | fileName: string; 12 | progress: number; 13 | status: 'uploading' | 'processing' | 'done' | 'error'; 14 | message?: string; 15 | } 16 | 17 | export interface ImportProgressMessage { 18 | id: string; 19 | fileName: string; 20 | progress: number; 21 | status: 'importing' | 'done' | 'error'; 22 | totalFiles: number; 23 | currentFileIndex: number; 24 | message?: string; 25 | } 26 | 27 | export interface StorageWarningMessage { 28 | warningId: string; 29 | title: string; 30 | description: string; 31 | confirmText: string; 32 | cancelText: string; 33 | action: 'import'; 34 | actionData: any; 35 | } 36 | 37 | export interface SettingsMessage { 38 | settings: Settings; 39 | } 40 | 41 | export interface UpdateProgressMessage { 42 | version: string; 43 | progress: number; 44 | status: 'downloading' | 'downloaded' | 'ready' | 'error'; 45 | message: string; 46 | } 47 | 48 | export interface ReleaseNote { 49 | version: string; 50 | base64Markdown: string; 51 | releaseDate: string; 52 | } 53 | 54 | export interface ReleaseNotesMessage { 55 | releaseNotesList: ReleaseNote[]; 56 | } 57 | 58 | export interface SelectedGameExecutableMessage { 59 | game: Game; 60 | } 61 | 62 | export interface WebSocketMessage { 63 | method: string; 64 | parameters: T; 65 | } 66 | 67 | export type WebSocketMessageType = 68 | | 'uploadProgress' 69 | | 'importProgress' 70 | | 'settings' 71 | | 'UpdateProgress' 72 | | 'ReleaseNotes' 73 | | 'ShowModal' 74 | | 'SelectedGameExecutable'; 75 | 76 | export function isUpdateProgressMessage(message: WebSocketMessage): boolean { 77 | return message.method === 'UpdateProgress'; 78 | } 79 | 80 | export function isReleaseNotesMessage(message: WebSocketMessage): boolean { 81 | return message.method === 'ReleaseNotes'; 82 | } 83 | 84 | export function isShowReleaseNotesMessage(message: WebSocketMessage): boolean { 85 | return message.method === 'ShowReleaseNotes'; 86 | } 87 | 88 | export function isShowModalMessage(message: WebSocketMessage): boolean { 89 | return message.method === 'ShowModal'; 90 | } 91 | 92 | export function isSelectedGameExecutableMessage(message: WebSocketMessage): boolean { 93 | return message.method === 'SelectedGameExecutable'; 94 | } 95 | 96 | export function isStorageWarningMessage(message: WebSocketMessage): boolean { 97 | return message.method === 'StorageWarning'; 98 | } 99 | -------------------------------------------------------------------------------- /Frontend/src/Components/GameCard.tsx: -------------------------------------------------------------------------------- 1 | import { Game } from '../Models/types'; 2 | import { MdClose } from 'react-icons/md'; 3 | 4 | interface GameCardProps { 5 | game: Game; 6 | allPaths?: string[]; // Optional: all executable paths for this game (used for pending games with multiple exes) 7 | type: 'allowed' | 'blocked' | 'pending'; 8 | onAllow: (game: Game) => void; 9 | onBlock: (game: Game) => void; 10 | onRemove: (game: Game) => void; 11 | } 12 | 13 | export default function GameCard({ 14 | game, 15 | allPaths, 16 | type, 17 | onAllow, 18 | onBlock, 19 | onRemove, 20 | }: GameCardProps) { 21 | const isAllowed = type === 'allowed'; 22 | const isBlocked = type === 'blocked'; 23 | 24 | return ( 25 |
26 | 32 |
33 |
36 | {isAllowed ? 'ALLOWED' : isBlocked ? 'BLOCKED' : '\u00A0'} 37 |
38 |
{game.name}
39 | {(() => { 40 | const paths = allPaths || game.paths || []; 41 | if (paths.length === 0) return null; 42 | 43 | const truncatePath = (path: string) => { 44 | const parts = path.replace(/\\/g, '/').split('/'); 45 | return parts.slice(-2).join('\\'); 46 | }; 47 | 48 | const displayPaths = paths.map(truncatePath).join(', '); 49 | return
{displayPaths}
; 50 | })()} 51 |
52 | 59 | 66 |
67 |
68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | ############################################################################### 6 | # Set default behavior for command prompt diff. 7 | # 8 | # This is need for earlier builds of msysgit that does not have it on by 9 | # default for csharp files. 10 | # Note: This is only used by command line 11 | ############################################################################### 12 | #*.cs diff=csharp 13 | ############################################################################### 14 | # Set the merge driver for project and solution files 15 | # 16 | # Merging from the command prompt will add diff markers to the files if there 17 | # are conflicts (Merging from VS is not affected by the settings below, in VS 18 | # the diff markers are never inserted). Diff markers may cause the following 19 | # file extensions to fail to load in VS. An alternative would be to treat 20 | # these files as binary and thus will always conflict and require user 21 | # intervention with every merge. To do so, just uncomment the entries below 22 | ############################################################################### 23 | #*.sln merge=binary 24 | #*.csproj merge=binary 25 | #*.vbproj merge=binary 26 | #*.vcxproj merge=binary 27 | #*.vcproj merge=binary 28 | #*.dbproj merge=binary 29 | #*.fsproj merge=binary 30 | #*.lsproj merge=binary 31 | #*.wixproj merge=binary 32 | #*.modelproj merge=binary 33 | #*.sqlproj merge=binary 34 | #*.wwaproj merge=binary 35 | ############################################################################### 36 | # behavior for image files 37 | # 38 | # image files are treated as binary by default. 39 | ############################################################################### 40 | #*.jpg binary 41 | #*.png binary 42 | #*.gif binary 43 | ############################################################################### 44 | # diff behavior for common document formats 45 | # 46 | # Convert binary document formats to text before diffing them. This feature 47 | # is only available from the command line. Turn it on by uncommenting the 48 | # entries below. 49 | ############################################################################### 50 | #*.doc diff=astextplain 51 | #*.DOC diff=astextplain 52 | #*.docx diff=astextplain 53 | #*.DOCX diff=astextplain 54 | #*.dot diff=astextplain 55 | #*.DOT diff=astextplain 56 | #*.pdf diff=astextplain 57 | #*.PDF diff=astextplain 58 | #*.rtf diff=astextplain 59 | #*.RTF diff=astextplain 60 | obs.zip filter=lfs diff=lfs merge=lfs -text 61 | Obs/*.zip filter=lfs diff=lfs merge=lfs -text 62 | -------------------------------------------------------------------------------- /Frontend/src/Components/SelectionCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SelectionCardProps } from '../Models/types'; 3 | import { useDrag, useDrop } from 'react-dnd'; 4 | 5 | const DRAG_TYPE = 'SELECTION_CARD'; 6 | 7 | const SelectionCard: React.FC = ({ 8 | selection, 9 | index, 10 | moveCard, 11 | formatTime, 12 | isHovered, 13 | setHoveredSelectionId, 14 | removeSelection, 15 | }) => { 16 | const [{ isDragging }, dragRef] = useDrag( 17 | () => ({ 18 | type: DRAG_TYPE, 19 | item: { index }, 20 | collect: (monitor) => ({ 21 | isDragging: monitor.isDragging(), 22 | }), 23 | }), 24 | [index], 25 | ); 26 | 27 | const [, dropRef] = useDrop( 28 | () => ({ 29 | accept: DRAG_TYPE, 30 | hover: (item: { index: number }) => { 31 | if (item.index !== index) { 32 | moveCard(item.index, index); 33 | item.index = index; 34 | } 35 | }, 36 | }), 37 | [index, moveCard], 38 | ); 39 | 40 | const dragDropRef = (node: HTMLDivElement | null) => { 41 | dragRef(node); 42 | dropRef(node); 43 | }; 44 | 45 | const { startTime, endTime, thumbnailDataUrl, isLoading } = selection; 46 | 47 | return ( 48 |
setHoveredSelectionId(selection.id)} 53 | onMouseLeave={() => setHoveredSelectionId(null)} 54 | onContextMenu={(e) => { 55 | e.preventDefault(); 56 | removeSelection(selection.id); 57 | }} 58 | > 59 | {isLoading ? ( 60 |
61 | 62 |
63 | {formatTime(startTime)} - {formatTime(endTime)} 64 |
65 |
66 | ) : thumbnailDataUrl ? ( 67 |
68 | Selection 69 |
70 | {formatTime(startTime)} - {formatTime(endTime)} 71 |
72 |
73 | ) : ( 74 |
75 | No thumbnail 76 |
77 | )} 78 |
79 | ); 80 | }; 81 | 82 | export { DRAG_TYPE }; 83 | export default SelectionCard; 84 | -------------------------------------------------------------------------------- /Backend/App/StartupService.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Serilog; 3 | 4 | namespace Segra.Backend.App 5 | { 6 | internal static class StartupService 7 | { 8 | public static void SetStartupStatus(bool enable) 9 | { 10 | try 11 | { 12 | string exePath = Path.ChangeExtension(Assembly.GetExecutingAssembly().Location, ".exe"); 13 | if (exePath == null) 14 | { 15 | Log.Error("Failed to get executable path"); 16 | return; 17 | } 18 | string startupFolder = Environment.GetFolderPath(Environment.SpecialFolder.Startup); 19 | string linkPath = Path.Combine(startupFolder, "Segra.lnk"); 20 | if (enable && !File.Exists(linkPath)) 21 | { 22 | Type shellType = Type.GetTypeFromProgID("WScript.Shell")!; 23 | object shell = Activator.CreateInstance(shellType)!; 24 | object shortcut = shellType.InvokeMember("CreateShortcut", BindingFlags.InvokeMethod, null, shell, new object[] { linkPath })!; 25 | shortcut.GetType().InvokeMember("TargetPath", BindingFlags.SetProperty, null, shortcut, new object[] { exePath }); 26 | shortcut.GetType().InvokeMember("Arguments", BindingFlags.SetProperty, null, shortcut, new object[] { "--from-startup" }); 27 | string? workingDir = Path.GetDirectoryName(exePath); 28 | if (workingDir == null) 29 | { 30 | Log.Error("Failed to get working directory"); 31 | return; 32 | } 33 | shortcut.GetType().InvokeMember("WorkingDirectory", BindingFlags.SetProperty, null, shortcut, new object[] { workingDir }); 34 | shortcut.GetType().InvokeMember("Save", BindingFlags.InvokeMethod, null, shortcut, null); 35 | Log.Information("Added Segra to startup"); 36 | } 37 | else if (!enable && File.Exists(linkPath)) 38 | { 39 | File.Delete(linkPath); 40 | Log.Information("Removed Segra from startup"); 41 | } 42 | } 43 | catch (Exception ex) 44 | { 45 | Log.Error(ex.Message); 46 | } 47 | } 48 | 49 | public static bool GetStartupStatus() 50 | { 51 | try 52 | { 53 | string linkPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Startup), "Segra.lnk"); 54 | return File.Exists(linkPath); 55 | } 56 | catch (Exception ex) 57 | { 58 | Log.Error(ex.Message); 59 | return false; 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Frontend/src/Context/ModalContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useRef, useState, ReactNode } from 'react'; 2 | 3 | interface ModalContextType { 4 | openModal: (content: ReactNode) => void; 5 | closeModal: () => void; 6 | } 7 | 8 | const ModalContext = createContext(undefined); 9 | 10 | export const ModalProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 11 | const modalRef = useRef(null); 12 | const [modalContent, setModalContent] = useState(null); 13 | // Track if the initial mousedown started on the backdrop 14 | const backdropMouseDownRef = useRef(false); 15 | 16 | const openModal = (content: ReactNode) => { 17 | setModalContent(content); 18 | if (modalRef.current) { 19 | modalRef.current.showModal(); 20 | } 21 | }; 22 | 23 | const closeModal = () => { 24 | setModalContent(null); 25 | if (modalRef.current) { 26 | modalRef.current.close(); 27 | } 28 | }; 29 | 30 | return ( 31 | 32 | {children} 33 | { 37 | // Only mark as backdrop interaction if the mousedown started on the dialog backdrop 38 | backdropMouseDownRef.current = e.target === modalRef.current; 39 | }} 40 | onClick={(e) => { 41 | // Close only if both mousedown and click occurred on the backdrop 42 | if (e.target === modalRef.current && backdropMouseDownRef.current) { 43 | backdropMouseDownRef.current = false; 44 | closeModal(); 45 | } else { 46 | backdropMouseDownRef.current = false; 47 | } 48 | }} 49 | > 50 |
e.stopPropagation()}> 51 | {modalContent} 52 |
53 |
{ 57 | // Mark that interaction started on the backdrop overlay 58 | backdropMouseDownRef.current = true; 59 | }} 60 | onClick={() => { 61 | // Close only if interaction started on backdrop (prevents drag-out closes) 62 | if (backdropMouseDownRef.current) { 63 | backdropMouseDownRef.current = false; 64 | closeModal(); 65 | } 66 | }} 67 | > 68 | 69 |
70 |
71 |
72 | ); 73 | }; 74 | 75 | export const useModal = (): ModalContextType => { 76 | const context = useContext(ModalContext); 77 | if (!context) { 78 | throw new Error('useModal must be used within a ModalProvider'); 79 | } 80 | return context; 81 | }; 82 | -------------------------------------------------------------------------------- /Frontend/src/Components/UpdateCard.tsx: -------------------------------------------------------------------------------- 1 | import { useUpdate } from '../Context/UpdateContext'; 2 | import { FaDownload, FaCheck, FaExclamationTriangle } from 'react-icons/fa'; 3 | import { sendMessageToBackend } from '../Utils/MessageUtils'; 4 | import { SiGithub } from 'react-icons/si'; 5 | 6 | export default function UpdateCard() { 7 | const { updateInfo, openReleaseNotesModal, clearUpdateInfo } = useUpdate(); 8 | 9 | if (!updateInfo) return null; 10 | 11 | const getStatusIcon = () => { 12 | switch (updateInfo.status) { 13 | case 'downloading': 14 | return ; 15 | case 'downloaded': 16 | case 'ready': 17 | return ; 18 | case 'error': 19 | return ; 20 | default: 21 | return ; 22 | } 23 | }; 24 | 25 | const handleInstallClick = () => { 26 | // Send a message to the backend to restart the application and install the update 27 | sendMessageToBackend('ApplyUpdate'); 28 | clearUpdateInfo(); 29 | }; 30 | 31 | // Compact version for the sidebar 32 | return ( 33 |
34 |
35 | {/* Header with status and version */} 36 |
37 |
38 |
39 | {getStatusIcon()} 40 |
41 |
42 |

43 | {updateInfo.status === 'downloading' ? 'Update in Progress' : 'Update Available'} 44 |

45 |

Version {updateInfo.version}

46 |
47 |
48 |
49 | 50 | {/* Action Buttons */} 51 |
52 | 60 | 67 |
68 |
69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /Backend/Services/AiService.cs: -------------------------------------------------------------------------------- 1 | using Serilog; 2 | using Segra.Backend.Core.Models; 3 | using Segra.Backend.Media; 4 | using Segra.Backend.App; 5 | 6 | namespace Segra.Backend.Services 7 | { 8 | internal class AiService 9 | { 10 | public static async Task CreateHighlight(string fileName) 11 | { 12 | string highlightId = Guid.NewGuid().ToString(); 13 | Content? content = null; 14 | 15 | try 16 | { 17 | Log.Information($"Starting highlight creation for: {fileName}"); 18 | 19 | content = Settings.Instance.State.Content.FirstOrDefault(x => x.FileName == fileName); 20 | if (content == null) 21 | { 22 | Log.Warning($"No content found matching fileName: {fileName}"); 23 | return; 24 | } 25 | 26 | int momentCount = content.Bookmarks.Count(b => b.Type.IncludeInHighlight()); 27 | if (momentCount == 0) 28 | { 29 | Log.Information($"No highlight bookmarks found for: {fileName}"); 30 | await SendProgress(highlightId, -1, "error", "No highlight moments found in this session", content); 31 | return; 32 | } 33 | 34 | await SendProgress(highlightId, 0, "processing", $"Found {momentCount} moments", content); 35 | 36 | await HighlightService.CreateHighlightFromBookmarks(fileName, async (progress, message) => 37 | { 38 | string status = progress < 0 ? "error" : progress >= 100 ? "done" : "processing"; 39 | await SendProgress(highlightId, progress, status, message, content); 40 | }); 41 | } 42 | catch (Exception ex) 43 | { 44 | Log.Error(ex, $"Error creating highlight for {fileName}"); 45 | if (content != null) 46 | { 47 | await SendProgress(highlightId, -1, "error", $"Error: {ex.Message}", content); 48 | } 49 | } 50 | } 51 | 52 | private static async Task SendProgress(string id, int progress, string status, string message, Content content) 53 | { 54 | var progressMessage = new HighlightProgressMessage 55 | { 56 | Id = id, 57 | Progress = progress, 58 | Status = status, 59 | Message = message, 60 | Content = content 61 | }; 62 | 63 | await MessageService.SendFrontendMessage("AiProgress", progressMessage); 64 | } 65 | } 66 | 67 | public class HighlightProgressMessage 68 | { 69 | public required string Id { get; set; } 70 | public required int Progress { get; set; } 71 | public required string Status { get; set; } 72 | public required string Message { get; set; } 73 | public required Content Content { get; set; } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Frontend/src/Components/ClippingCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useClipping } from '../Hooks/useClipping'; 3 | import { MdClose } from 'react-icons/md'; 4 | import CircularProgress from './CircularProgress'; 5 | 6 | import { ClippingProgress } from '../Context/ClippingContext'; 7 | 8 | interface ClippingCardProps { 9 | clipping: ClippingProgress; 10 | } 11 | 12 | const ClippingCard: React.FC = ({ clipping }) => { 13 | const { cancelClip } = useClipping(); 14 | const [displayProgress, setDisplayProgress] = useState(0); 15 | const [isCancelling, setIsCancelling] = useState(false); 16 | 17 | useEffect(() => { 18 | if (clipping.progress > 95) { 19 | setDisplayProgress(clipping.progress); 20 | return; 21 | } 22 | 23 | const timer = setInterval(() => { 24 | setDisplayProgress((prev) => { 25 | const diff = clipping.progress - prev; 26 | if (Math.abs(diff) < 0.1) return clipping.progress; 27 | return prev + diff * 0.15; 28 | }); 29 | }, 50); 30 | 31 | return () => clearInterval(timer); 32 | }, [clipping.progress]); 33 | 34 | const handleCancel = () => { 35 | setIsCancelling(true); 36 | cancelClip(clipping.id); 37 | }; 38 | 39 | const isError = clipping.progress === -1; 40 | 41 | return ( 42 |
43 |
46 |
47 | {/* Progress */} 48 | {isError ? ( 49 |
50 | ) : clipping.progress < 100 ? ( 51 | 52 | ) : ( 53 |
54 | )} 55 | 56 | {/* Clipping Details */} 57 |
58 | {clipping.progress >= 0 && clipping.progress < 100 && ( 59 | 67 | )} 68 |
71 | {isError ? 'Clip Failed' : 'Creating Clip'} 72 |
73 |
74 | {isError ? clipping.error || 'Unknown error' : `${Math.round(displayProgress)}%`} 75 |
76 |
77 |
78 |
79 |
80 | ); 81 | }; 82 | 83 | export default ClippingCard; 84 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Segra 2 | 3 | A quick, practical guide to get you developing on both the backend (C#/.NET) and the frontend (React/Vite). 4 | 5 | ## Requirements 6 | - Windows 10 (build 17763 / 1809) or newer 7 | - .NET SDK 9.0.x (Windows targeting) 8 | - Git 9 | - Bun v1.1+ (for frontend tooling and git hooks) 10 | - Node.js 18+ (only if you want the backend to auto-start the frontend dev server via `npm run dev`) 11 | - IDEs (pick what you like): 12 | - Visual Studio 2022 (17.12+) or VS Code + C# Dev Kit 13 | 14 | ## Repo Layout 15 | - `Segra.sln` — solution root 16 | - `Backend/` — app services, models, utils 17 | - `Frontend/` — React + Vite app (TypeScript, Tailwind, DaisyUI) 18 | - `libobs-sharp/` — vendored OBS interop 19 | 20 | ## First-Time Setup 21 | 1. Clone the repo 22 | - `git clone && cd Segra` 23 | 2. Install root dev tools (husky/lint-staged for hooks) 24 | - `bun install` 25 | - `bun run prepare` 26 | 3. Install frontend deps 27 | - `cd Frontend && bun install && cd ..` 28 | 4. Ensure .NET SDK 9 is on PATH 29 | - `dotnet --info` should show `Version: 9.x` and `OS: Windows` 30 | 31 | ## Developing 32 | There are two parts running during development: the backend (Photino.NET desktop app) and the frontend (Vite dev server on port 2882). 33 | 34 | ### Start the Frontend (Vite) 35 | - Using Bun (recommended): 36 | - `cd Frontend && bun run dev` (serves on http://localhost:2882) 37 | - Using Node/npm (optional): 38 | - `cd Frontend && npm run dev` 39 | 40 | ### Start the Backend (.NET) 41 | - From the repo root: 42 | - `dotnet run --project Segra.csproj` 43 | - Notes: 44 | - In Debug mode the app expects the frontend on `http://localhost:2882`. 45 | - If Node/npm is installed, the backend attempts to auto-run `npm run dev` in `Frontend/` if nothing is listening on 2882. 46 | 47 | ## Building 48 | - Backend (Release): `dotnet build -c Release` 49 | - Backend publish (self-contained optional): `dotnet publish -c Release` 50 | - Frontend (bundle): `cd Frontend && bun run build` 51 | 52 | ## Linting & Formatting 53 | - EditorConfig is enforced across the repo: 54 | - Global: CRLF line endings and 2-space indent 55 | - C#: CRLF line endings, 4-space indent 56 | - C# formatting (via `dotnet format`): 57 | - Pre-commit: formats staged `*.cs` files 58 | - Pre-push: verifies no formatting drift in the solution 59 | - `libobs-sharp/` is excluded from formatting 60 | - Frontend (in `Frontend/`): 61 | - Prettier + ESLint with Bun 62 | - Scripts: 63 | - `bun run format` / `bun run format:check` 64 | - `bun run lint` / `bun run lint:fix` 65 | 66 | ## Git Hooks (Husky + lint-staged) 67 | - Installed at repo root via Bun. 68 | - Pre-commit: 69 | - Prettier + ESLint on staged files in `Frontend/` 70 | - `dotnet format` on staged `*.cs` (excludes `libobs-sharp`) 71 | - Pre-push: 72 | - `dotnet format --verify-no-changes` on the solution (excludes `libobs-sharp`) 73 | 74 | If hooks don't run: 75 | - Ensure Bun is on PATH for your Git shell 76 | - Re-run: `bun install && bun run prepare` 77 | 78 | ## Pull Requests 79 | - Keep PRs focused and small 80 | - Run format and lint before pushing 81 | - Avoid changing files under `libobs-sharp/` 82 | 83 | Thanks for contributing ❤️ 84 | -------------------------------------------------------------------------------- /Resources/wwwroot/_next/static/chunks/webpack-03c87f68c10117d4.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";var e,t,r,n,o,u,i,c,f,a={},l={};function d(e){var t=l[e];if(void 0!==t)return t.exports;var r=l[e]={exports:{}},n=!0;try{a[e](r,r.exports,d),n=!1}finally{n&&delete l[e]}return r.exports}d.m=a,e=[],d.O=function(t,r,n,o){if(r){o=o||0;for(var u=e.length;u>0&&e[u-1][2]>o;u--)e[u]=e[u-1];e[u]=[r,n,o];return}for(var i=1/0,u=0;u=o&&Object.keys(d.O).every(function(e){return d.O[e](r[f])})?r.splice(f--,1):(c=!1,o { 9 | switch (importItem.status) { 10 | case 'importing': 11 | return `Importing ${importItem.currentFileIndex} of ${importItem.totalFiles}`; 12 | case 'done': 13 | return 'Import Complete'; 14 | case 'error': 15 | return importItem.message || 'Import Error'; 16 | default: 17 | return 'Importing...'; 18 | } 19 | }; 20 | 21 | const getProgressPercentage = () => { 22 | return Math.min(importItem.progress, 100); 23 | }; 24 | 25 | return ( 26 |
27 |
28 |
29 | {/* Progress Spinner */} 30 | {importItem.status === 'importing' && ( 31 | 32 | )} 33 | {importItem.status === 'done' && ( 34 |
35 | 36 | 41 | 42 |
43 | )} 44 | {importItem.status === 'error' && ( 45 |
46 | 47 | 52 | 53 |
54 | )} 55 | 56 | {/* Import Details */} 57 |
58 |
{getStatusText()}
59 |
{importItem.fileName}
60 | {/* Progress Bar */} 61 |
62 |
72 |
73 | {/* Progress Percentage */} 74 |
{getProgressPercentage().toFixed(0)}%
75 |
76 |
77 |
78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **Segra** is a powerful recording software built on Open Broadcaster Software (OBS), designed for gamers and content creators. Record, clip, and upload gameplay highlights effortlessly, with smart automation and deep game integration. 4 | 5 | ### ✂️ Clip Editor 6 | 7 | ![image](https://github.com/user-attachments/assets/beed0524-35f1-48be-9dd8-c2455959d2f9) 8 | 9 | ### 🔥 Highlights 10 | 11 | ![image](https://github.com/user-attachments/assets/481cc9fa-3efb-412d-b668-8be7d11b9851) 12 | 13 | 14 | ### ⚙️ Settings 15 | 16 | ![image](https://github.com/user-attachments/assets/de300431-1b63-4ed2-a022-110f8f828d1a) 17 | 18 | 19 | --- 20 | 21 | ## ✨ Features 22 | - **Auto-Start Recording**: Begin recording automatically when your game launches. 23 | - **Instant Clipping**: Save key moments with a hotkey. 24 | - **Direct Upload**: Share clips to **[Segra.tv](https://segra.tv)** instantly. 25 | - **Game Integration**: Tracks in-game stats (kills, deaths, assists) to auto-generate highlights, powered by AI. 26 | - **Lightweight & Fast**: Built on OBS for 4K with 144 FPS capture with minimal performance impact. 27 | - **Customizable Settings**: Adjust recording quality (NVENC/AMD VCE), hotkeys, storage paths, etc. 28 | 29 | --- 30 | 31 | ## Why "Segra"? 32 | **Segra** (pronounced *"say-grah"*) means **"to win"** in Swedish. We built Segra to help you **preserve those moments**: the chaotic fun with friends, the clutch plays, and the wins (*segra!*) that deserve their own highlight reel. 33 | 34 | --- 35 | 36 | ## 🛠 Installation 37 | 1. **Download**: Get `Segra-win-Setup.exe` from [[latest release](https://github.com/Segergren/Segra/releases/latest)]. 38 | 2. **Install**: Run the setup. 39 | 3. **Configure**: 40 | - Set recording directory and video quality. 41 | - Assign hotkeys for clipping/uploading. 42 | - Connect your Segra.tv account. 43 | 44 | ## 🔄 Uninstallation 45 | 1. Open `Windows Settings` 46 | 2. Go to `Apps` -> `Installed apps` 47 | 3. Search for `Segra` 48 | 4. Click `Uninstall` 49 | 50 | ## 🤝 Contributing 51 | See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, dependencies, and dev workflow. 52 | Help improve Segra by: 53 | - Report bugs or suggest features 54 | - Submit pull requests 55 | 56 | --- 57 | 58 | ## 📜 License 59 | Segra is **GPLv2 licensed**. 60 | 61 | --- 62 | 63 | ## 🔐 Code Signing Policy 64 | Free code signing provided by **[SignPath.io](https://signpath.io)**, certificate by **[SignPath Foundation](https://signpath.org)**. 65 | 66 | **Team roles** 67 | 68 | | Role | Person | 69 | |-----------|--------| 70 | | Authors | @Segergren | 71 | | Reviewers | @Segergren | 72 | | Approvers | @Segergren | 73 | 74 | See our [Privacy Policy](https://segra.tv/privacy). 75 | 76 | ## Star History 77 | 78 | 79 | 80 | 81 | 82 | Star History Chart 83 | 84 | 85 | 86 | ## Acknowledgments 87 | - **[OBS Studio](https://obsproject.com)**: The backbone of Segra’s recording engine. 88 | - [**Lulzsun**](https://github.com/lulzsun): Creator of **[libobs-sharp](https://github.com/lulzsun/libobs-sharp)**, the critical C#/OBS bridge that powers Segra’s core functionality. 89 | - **[FFmpeg](https://github.com/FFmpeg/FFmpeg)**: for video and image encoding. 90 | -------------------------------------------------------------------------------- /Frontend/src/Components/GameListManager.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Game } from '../Models/types'; 3 | import { useSettings } from '../Context/SettingsContext'; 4 | import { sendMessageToBackend } from '../Utils/MessageUtils'; 5 | 6 | interface GameListManagerProps { 7 | listType: 'whitelist' | 'blacklist'; 8 | } 9 | 10 | export const GameListManager: React.FC = ({ listType }) => { 11 | const settings = useSettings(); 12 | 13 | const gameList = listType === 'whitelist' ? settings.whitelist : settings.blacklist; 14 | const listTitle = listType === 'whitelist' ? 'Allow List' : 'Block List'; 15 | const listDescription = 16 | listType === 'whitelist' 17 | ? 'Games in your allow list are forced to be detected and recorded.' 18 | : 'Games in your block list are prevented from being recorded.'; 19 | const emptyListLabel = listType === 'whitelist' ? 'allow list' : 'block list'; 20 | 21 | const handleRemoveGame = (game: Game) => { 22 | sendMessageToBackend(listType === 'whitelist' ? 'RemoveFromWhitelist' : 'RemoveFromBlacklist', { 23 | game, 24 | }); 25 | }; 26 | 27 | return ( 28 |
29 |
30 |

{listTitle}

31 |

{listDescription}

32 |
33 | 34 |
35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | {gameList.length === 0 ? ( 46 | 47 | 53 | 54 | ) : ( 55 | gameList.map((game, index) => ( 56 | 57 | 58 | 72 | 80 | 81 | )) 82 | )} 83 | 84 |
GameExecutable Path
51 | No games in {emptyListLabel} 52 |
{game.name} 59 | {game.paths && game.paths.length > 1 ? ( 60 |
61 |
{game.paths.length} executables
62 | {game.paths.map((path, idx) => ( 63 |
64 | • {path} 65 |
66 | ))} 67 |
68 | ) : ( 69 |
{game.paths?.[0] || ''}
70 | )} 71 |
73 | 79 |
85 |
86 |
87 |
88 | ); 89 | }; 90 | 91 | export default GameListManager; 92 | -------------------------------------------------------------------------------- /Frontend/src/globals.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @plugin "daisyui" { 3 | themes: false; 4 | } 5 | 6 | @plugin "daisyui/theme" { 7 | name: 'segra'; 8 | default: true; 9 | color-scheme: dark; 10 | /* Dina färger (oförändrade) */ 11 | --color-primary: #fecb00; 12 | --color-secondary: #171f2a; 13 | --color-accent: #fecb00; 14 | --color-neutral: #142638; 15 | --color-base-100: #132733; 16 | --color-base-200: #15232f; 17 | --color-base-300: #171f2a; 18 | --color-base-content: #ffffff; 19 | --color-info: #005293; 20 | --color-success: #36d399; 21 | --color-warning: #ffd481; 22 | --color-error: #f87272; 23 | --color-primary-content: var(--color-base-100); 24 | --card-p: 1rem; 25 | /* DaisyUI v5 tokens – matcha v4-defaults */ 26 | --radius-box: 1rem; /* v4: --rounded-box: 1rem */ 27 | --radius-field: 0.5rem; /* v4: --rounded-btn: 0.5rem */ 28 | --radius-selector: 0.45rem; /* v4: --rounded-badge: 1.9rem */ 29 | /* Storleksskala (px, skriv som siffra utan enhet) – rimlig v4-känsla */ 30 | --size-selector: 0.25rem; 31 | --size-field: 0.3rem; 32 | /* Kantlinje & effekter */ 33 | --border: 1px; /* v4 hade 1px på knappar/flikar */ 34 | --depth: 0; /* v4 hade inget djup */ 35 | --noise: 0; /* v4 hade ingen noise */ 36 | } 37 | 38 | @layer components { 39 | .checkbox { 40 | color: var(--color-base-300) !important; 41 | } 42 | .tooltip[data-tip]::before { 43 | color: var(--color-base-300) !important; 44 | } 45 | .btn:is(:disabled, [disabled], .btn-disabled):not(.btn-link, .btn-ghost) { 46 | background-color: var(--btn-bg) !important; 47 | box-shadow: var(--btn-shadow) !important; 48 | } 49 | @media (hover: hover) { 50 | .btn:is(:disabled, [disabled], .btn-disabled):hover { 51 | background-color: var(--btn-bg) !important; 52 | } 53 | } 54 | } 55 | 56 | @theme { 57 | --color-base-400: #49515b; 58 | --color-custom: #2e3640; 59 | --color-primary-yellow: #fecb00; 60 | } 61 | 62 | :root { 63 | --foreground-rgb: 0, 0, 0; 64 | --background-start-rgb: 214, 219, 220; 65 | --background-end-rgb: 255, 255, 255; 66 | } 67 | 68 | @media (prefers-color-scheme: dark) { 69 | :root { 70 | --foreground-rgb: 255, 255, 255; 71 | --background-start-rgb: 0, 0, 0; 72 | --background-end-rgb: 0, 0, 0; 73 | } 74 | } 75 | 76 | @layer utilities { 77 | .text-balance { 78 | text-wrap: balance; 79 | } 80 | } 81 | 82 | /* Highlight card gradient border animation */ 83 | @keyframes highlightBorderAnimation { 84 | 0% { 85 | background-position: 0% 50%; 86 | } 87 | 50% { 88 | background-position: 200% 50%; 89 | } 90 | 100% { 91 | background-position: 0% 50%; 92 | } 93 | } 94 | 95 | .highlight-card { 96 | overflow: hidden; 97 | } 98 | 99 | .highlight-border { 100 | background: linear-gradient(-450deg, #7928ca, #ff0080, #1c64f2, #00d4ff, #7928ca); 101 | background-size: 300% 300%; 102 | animation: highlightBorderAnimation 8s ease infinite; 103 | z-index: 1; 104 | } 105 | 106 | /* Shockwave animation for bookmark creation */ 107 | @keyframes shockwave { 108 | 0% { 109 | width: 0; 110 | height: 0; 111 | opacity: 0.6; 112 | } 113 | 100% { 114 | width: 400px; 115 | height: 400px; 116 | opacity: 0; 117 | } 118 | } 119 | 120 | .animate-shockwave { 121 | animation: shockwave 0.6s ease-out forwards; 122 | } 123 | 124 | /* Remove all focus rings globally */ 125 | *, 126 | *:focus, 127 | *:focus-visible, 128 | *:focus-within, 129 | dialog, 130 | dialog:focus, 131 | .modal, 132 | .modal-box { 133 | outline: none !important; 134 | outline-width: 0 !important; 135 | outline-style: none !important; 136 | outline-color: transparent !important; 137 | outline-offset: 0 !important; 138 | --tw-ring-offset-width: 0px !important; 139 | --tw-ring-width: 0px !important; 140 | --tw-ring-color: transparent !important; 141 | --tw-ring-offset-color: transparent !important; 142 | box-shadow: none !important; 143 | } 144 | -------------------------------------------------------------------------------- /Properties/Resources.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace Segra.Properties { 12 | using System; 13 | 14 | 15 | /// 16 | /// A strongly-typed resource class, for looking up localized strings, etc. 17 | /// 18 | // This class was auto-generated by the StronglyTypedResourceBuilder 19 | // class via a tool like ResGen or Visual Studio. 20 | // To add or remove a member, edit your .ResX file then rerun ResGen 21 | // with the /str option, or rebuild your VS project. 22 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] 23 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 24 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 25 | internal class Resources { 26 | 27 | private static global::System.Resources.ResourceManager resourceMan; 28 | 29 | private static global::System.Globalization.CultureInfo resourceCulture; 30 | 31 | [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] 32 | internal Resources() { 33 | } 34 | 35 | /// 36 | /// Returns the cached ResourceManager instance used by this class. 37 | /// 38 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 39 | internal static global::System.Resources.ResourceManager ResourceManager { 40 | get { 41 | if (object.ReferenceEquals(resourceMan, null)) { 42 | global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Segra.Properties.Resources", typeof(Resources).Assembly); 43 | resourceMan = temp; 44 | } 45 | return resourceMan; 46 | } 47 | } 48 | 49 | /// 50 | /// Overrides the current thread's CurrentUICulture property for all 51 | /// resource lookups using this strongly typed resource class. 52 | /// 53 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 54 | internal static global::System.Globalization.CultureInfo Culture { 55 | get { 56 | return resourceCulture; 57 | } 58 | set { 59 | resourceCulture = value; 60 | } 61 | } 62 | 63 | /// 64 | /// Looks up a localized resource of type System.Byte[]. 65 | /// 66 | internal static byte[] bookmark { 67 | get { 68 | object obj = ResourceManager.GetObject("bookmark", resourceCulture); 69 | return ((byte[])(obj)); 70 | } 71 | } 72 | 73 | /// 74 | /// Looks up a localized resource of type System.IO.UnmanagedMemoryStream similar to System.IO.MemoryStream. 75 | /// 76 | internal static System.IO.UnmanagedMemoryStream error { 77 | get { 78 | return ResourceManager.GetStream("error", resourceCulture); 79 | } 80 | } 81 | 82 | /// 83 | /// Looks up a localized resource of type System.IO.UnmanagedMemoryStream similar to System.IO.MemoryStream. 84 | /// 85 | internal static System.IO.UnmanagedMemoryStream start { 86 | get { 87 | return ResourceManager.GetStream("start", resourceCulture); 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Frontend/src/Components/Settings/CaptureModeSection.tsx: -------------------------------------------------------------------------------- 1 | import { Settings as SettingsType } from '../../Models/types'; 2 | 3 | interface CaptureModeSectionProps { 4 | settings: SettingsType; 5 | updateSettings: (updates: Partial) => void; 6 | } 7 | 8 | export default function CaptureModeSection({ settings, updateSettings }: CaptureModeSectionProps) { 9 | const isRecording = settings.state.recording || settings.state.preRecording != null; 10 | 11 | return ( 12 |
13 |

Capture Mode

14 |
15 |
!isRecording && updateSettings({ recordingMode: 'Hybrid' })} 18 | > 19 |
20 |
Hybrid (Session + Buffer)
21 |
22 |
23 |

24 | Record the full session while keeping a replay buffer. Save short highlights with a 25 | hotkey without stopping the session. 26 |

27 |
28 | • Clip without ending the session recording 29 |
• Full game integration features 30 |
• Access to AI-generated highlights 31 |
• Access to Bookmarks 32 |
33 |
34 |
35 |
36 |
37 |
!isRecording && updateSettings({ recordingMode: 'Session' })} 40 | > 41 |
Session Recording
42 |
43 |

44 | Records your entire gaming session from start to finish. Ideal for content creators 45 | who want complete gameplay recordings. 46 |

47 |
48 | • Uses more storage space 49 |
50 | • Full game integration features 51 |
52 | • Access to AI-generated highlights 53 |
• Access to Bookmarks 54 |
55 |
56 |
57 |
!isRecording && updateSettings({ recordingMode: 'Buffer' })} 60 | > 61 |
62 |
Replay Buffer
63 |
64 |
65 |

66 | Continuously records in the background. Save only your best moments with a hotkey 67 | press. 68 |

69 |
70 | • Efficient storage usage 71 |
72 | • No game integration 73 |
• No bookmarks 74 |
75 |
76 |
77 |
78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /Frontend/src/Context/ClippingContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, useEffect, useRef, type ReactNode } from 'react'; 2 | import { sendMessageToBackend } from '../Utils/MessageUtils'; 3 | import { useSelections } from './SelectionsContext'; 4 | import { useSettings } from './SettingsContext'; 5 | import { Selection } from '../Models/types'; 6 | 7 | export interface ClippingProgress { 8 | id: number; 9 | progress: number; 10 | selections: Selection[]; 11 | error?: string; 12 | } 13 | 14 | export interface ClippingContextType { 15 | clippingProgress: Record; 16 | removeClipping: (id: number) => void; 17 | cancelClip: (id: number) => void; 18 | } 19 | 20 | export const ClippingContext = createContext(undefined); 21 | 22 | export function ClippingProvider({ children }: { children: ReactNode }) { 23 | const [clippingProgress, setClippingProgress] = useState>({}); 24 | const suppressedIds = useRef>(new Set()); 25 | const { removeSelection } = useSelections(); 26 | const settings = useSettings(); 27 | 28 | useEffect(() => { 29 | const handleWebSocketMessage = (event: CustomEvent<{ method: string; content: any }>) => { 30 | const { method, content } = event.detail; 31 | 32 | if (method === 'ClipProgress') { 33 | const progress = content as ClippingProgress; 34 | 35 | // Suppress messages for cancelled clips 36 | if (suppressedIds.current.has(progress.id)) { 37 | return; 38 | } 39 | 40 | setClippingProgress((prev) => ({ 41 | ...prev, 42 | [progress.id]: progress, 43 | })); 44 | 45 | if (progress.progress === 100) { 46 | // If setting is enabled, remove all selections that were in the clip 47 | if ( 48 | settings.clipClearSelectionsAfterCreatingClip && 49 | progress.selections && 50 | progress.selections.length > 0 51 | ) { 52 | // Remove each selection that was included in the clip 53 | progress.selections.forEach((selection) => { 54 | removeSelection(selection.id); 55 | }); 56 | } 57 | 58 | setClippingProgress((prev) => { 59 | const { [progress.id]: _, ...rest } = prev; 60 | return rest; 61 | }); 62 | } else if (progress.progress === -1) { 63 | // Error occurred - keep in progress list briefly to show error, then remove 64 | console.error('Clip creation failed:', progress.error); 65 | setTimeout(() => { 66 | setClippingProgress((prev) => { 67 | const { [progress.id]: _, ...rest } = prev; 68 | return rest; 69 | }); 70 | }, 5000); // Remove after 5 seconds so user can see the error 71 | } 72 | } 73 | }; 74 | 75 | window.addEventListener('websocket-message', handleWebSocketMessage as EventListener); 76 | return () => { 77 | window.removeEventListener('websocket-message', handleWebSocketMessage as EventListener); 78 | }; 79 | }, [settings.clipClearSelectionsAfterCreatingClip, removeSelection]); 80 | 81 | const removeClipping = (id: number) => { 82 | setClippingProgress((prev) => { 83 | const { [id]: _, ...rest } = prev; 84 | return rest; 85 | }); 86 | }; 87 | 88 | const cancelClip = (id: number) => { 89 | suppressedIds.current.add(id); 90 | sendMessageToBackend('CancelClip', { id }); 91 | setClippingProgress((prev) => { 92 | const { [id]: _, ...rest } = prev; 93 | return rest; 94 | }); 95 | }; 96 | 97 | return ( 98 | 99 | {children} 100 | 101 | ); 102 | } 103 | 104 | export function useClipping() { 105 | const context = useContext(ClippingContext); 106 | if (!context) { 107 | throw new Error('useClipping must be used within a ClippingProvider'); 108 | } 109 | return context; 110 | } 111 | -------------------------------------------------------------------------------- /Frontend/src/Components/RenameModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | import { Content } from '../Models/types'; 3 | import { MdDriveFileRenameOutline } from 'react-icons/md'; 4 | 5 | interface RenameModalProps { 6 | content: Content; 7 | onRename: (newName: string) => void; 8 | onClose: () => void; 9 | } 10 | 11 | export default function RenameModal({ content, onRename, onClose }: RenameModalProps) { 12 | // Use the same logic as ContentCard: title || game || "Untitled" 13 | const displayedTitle = content.title || content.game || 'Untitled'; 14 | const actualTitle = content.title || ''; 15 | const [newName, setNewName] = useState(actualTitle); 16 | const [nameError, setNameError] = useState(false); 17 | const nameInputRef = useRef(null); 18 | 19 | useEffect(() => { 20 | const timer = setTimeout(() => { 21 | nameInputRef.current?.focus(); 22 | nameInputRef.current?.select(); 23 | }, 100); 24 | 25 | return () => clearTimeout(timer); 26 | }, []); 27 | 28 | const handleRename = () => { 29 | const trimmedName = newName.trim(); 30 | 31 | // Check if name contains invalid characters (only if not empty) 32 | const invalidChars = /[<>:"/\\|?*]/; 33 | if (trimmedName && invalidChars.test(trimmedName)) { 34 | setNameError(true); 35 | nameInputRef.current?.focus(); 36 | return; 37 | } 38 | 39 | setNameError(false); 40 | onRename(trimmedName); 41 | onClose(); 42 | }; 43 | 44 | const handleKeyPress = (e: React.KeyboardEvent) => { 45 | if (e.key === 'Enter') { 46 | e.preventDefault(); 47 | handleRename(); 48 | } else if (e.key === 'Escape') { 49 | e.preventDefault(); 50 | onClose(); 51 | } 52 | }; 53 | 54 | return ( 55 | <> 56 |
57 |
58 | 64 |
65 |
66 |
67 | 68 |
69 |

Rename

70 |

71 | Enter a new title for this {content.type.toLowerCase()} 72 |

73 |
74 |
75 | 76 |
77 | { 82 | setNewName(e.target.value); 83 | setNameError(false); 84 | }} 85 | onKeyDown={handleKeyPress} 86 | className={`input input-bordered bg-base-300 w-full ${nameError ? 'input-error' : ''}`} 87 | placeholder={displayedTitle} 88 | /> 89 | {nameError && ( 90 | 95 | )} 96 |
97 |
98 |
99 | 105 | 112 |
113 |
114 | 115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /Frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, createContext } from 'react'; 2 | import Settings from './Pages/settings'; 3 | import Menu from './menu'; 4 | import Sessions from './Pages/sessions'; 5 | import Clips from './Pages/clips'; 6 | import ReplayBuffer from './Pages/replay-buffer'; 7 | import Highlights from './Pages/highlights'; 8 | import { SettingsProvider } from './Context/SettingsContext'; 9 | import Video from './Pages/video'; 10 | import { useSelectedVideo } from './Context/SelectedVideoContext'; 11 | import { useSelectedMenu } from './Context/SelectedMenuContext'; 12 | import { themeChange } from 'theme-change'; 13 | import { HTML5Backend } from 'react-dnd-html5-backend'; 14 | import { DndProvider } from 'react-dnd'; 15 | import { SelectionsProvider } from './Context/SelectionsContext'; 16 | import { UploadProvider } from './Context/UploadContext'; 17 | import { ImportProvider } from './Context/ImportContext'; 18 | import { WebSocketProvider } from './Context/WebSocketContext'; 19 | import { ClippingProvider } from './Context/ClippingContext'; 20 | import { AiHighlightsProvider } from './Context/AiHighlightsContext'; 21 | import { CompressionProvider } from './Context/CompressionContext'; 22 | import { UpdateProvider } from './Context/UpdateContext'; 23 | import { ReleaseNote } from './Models/WebSocketMessages'; 24 | import { ScrollProvider } from './Context/ScrollContext'; 25 | import { ModalProvider } from './Context/ModalContext'; 26 | 27 | // Create a context for release notes that can be accessed globally 28 | export const ReleaseNotesContext = createContext<{ 29 | releaseNotes: ReleaseNote[]; 30 | setReleaseNotes: (notes: ReleaseNote[]) => void; 31 | }>({ 32 | releaseNotes: [], 33 | setReleaseNotes: () => {}, 34 | }); 35 | 36 | function App() { 37 | useEffect(() => { 38 | themeChange(false); 39 | }, []); 40 | 41 | const { selectedVideo, setSelectedVideo } = useSelectedVideo(); 42 | const { selectedMenu, setSelectedMenu } = useSelectedMenu(); 43 | 44 | const handleMenuSelection = (menu: any) => { 45 | setSelectedVideo(null); 46 | setSelectedMenu(menu); 47 | }; 48 | 49 | const renderContent = () => { 50 | if (selectedVideo) { 51 | return ( 52 | 53 | 55 | ); 56 | } 57 | 58 | switch (selectedMenu) { 59 | case 'Full Sessions': 60 | return ; 61 | case 'Replay Buffer': 62 | return ; 63 | case 'Clips': 64 | return ; 65 | case 'Highlights': 66 | return ; 67 | case 'Settings': 68 | return ; 69 | default: 70 | return ; 71 | } 72 | }; 73 | 74 | return ( 75 |
76 |
77 | 78 |
79 |
{renderContent()}
80 |
81 | ); 82 | } 83 | 84 | export default function AppWrapper() { 85 | const [releaseNotes, setReleaseNotes] = useState([]); 86 | 87 | return ( 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /Frontend/src/Components/Settings/StorageSettingsSection.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { Settings as SettingsType } from '../../Models/types'; 3 | import { sendMessageToBackend } from '../../Utils/MessageUtils'; 4 | import { useModal } from '../../Context/ModalContext'; 5 | import ConfirmationModal from '../ConfirmationModal'; 6 | 7 | interface StorageSettingsSectionProps { 8 | settings: SettingsType; 9 | updateSettings: (updates: Partial) => void; 10 | } 11 | 12 | export default function StorageSettingsSection({ 13 | settings, 14 | updateSettings, 15 | }: StorageSettingsSectionProps) { 16 | const [localStorageLimit, setLocalStorageLimit] = useState(String(settings.storageLimit)); 17 | const { openModal, closeModal } = useModal(); 18 | 19 | useEffect(() => { 20 | setLocalStorageLimit(String(settings.storageLimit)); 21 | }, [settings.storageLimit]); 22 | 23 | const handleBrowseClick = () => { 24 | sendMessageToBackend('SetVideoLocation'); 25 | }; 26 | 27 | const handleStorageLimitBlur = () => { 28 | const currentFolderSizeGb = settings.state.currentFolderSizeGb; 29 | const numericLimit = Number(localStorageLimit) || 1; // Default to 1 if empty/invalid 30 | 31 | // Update display if empty/invalid 32 | if (!localStorageLimit || isNaN(Number(localStorageLimit))) { 33 | setLocalStorageLimit('1'); 34 | } 35 | 36 | // Check if the new limit is below the current folder size 37 | if (numericLimit < currentFolderSizeGb) { 38 | openModal( 39 | { 45 | updateSettings({ storageLimit: numericLimit }); 46 | closeModal(); 47 | }} 48 | onCancel={() => { 49 | // Reset to the previous value 50 | setLocalStorageLimit(String(settings.storageLimit)); 51 | closeModal(); 52 | }} 53 | />, 54 | ); 55 | } else { 56 | updateSettings({ storageLimit: numericLimit }); 57 | } 58 | }; 59 | 60 | return ( 61 |
62 |

Storage Settings

63 |
64 | {/* Recording Path */} 65 |
66 | 69 |
70 |
71 | updateSettings({ contentFolder: e.target.value })} 76 | placeholder="Enter or select folder path" 77 | className="input input-bordered flex-1 bg-base-200 join-item" 78 | /> 79 | 85 |
86 |
87 |
88 | 89 | {/* Storage Limit */} 90 |
91 | 94 | 95 | setLocalStorageLimit(e.target.value)} 100 | onBlur={handleStorageLimitBlur} 101 | placeholder="Set maximum storage in GB" 102 | min="1" 103 | className="input input-bordered bg-base-200 w-full block outline-none focus:border-base-400" 104 | /> 105 |
106 |
107 |
108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /Frontend/src/Context/WebSocketContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, ReactNode, useCallback, useEffect, useRef } from 'react'; 2 | import useWebSocket, { ReadyState } from 'react-use-websocket'; 3 | import { sendMessageToBackend } from '../Utils/MessageUtils'; 4 | import { useAuth } from '../Hooks/useAuth.tsx'; 5 | 6 | interface WebSocketContextType { 7 | sendMessage: (message: string) => void; 8 | isConnected: boolean; 9 | connectionState: ReadyState; 10 | } 11 | 12 | const WebSocketContext = createContext(undefined); 13 | 14 | interface WebSocketMessage { 15 | method: string; 16 | content: any; 17 | } 18 | 19 | export function WebSocketProvider({ children }: { children: ReactNode }) { 20 | // Get the auth session to properly handle authentication 21 | const { session } = useAuth(); 22 | // Ref to track if we've already handled a version mismatch (prevent multiple reloads) 23 | const versionCheckHandled = useRef(false); 24 | // Ref to track if this is a reconnection (not initial connection) 25 | const hasConnectedBefore = useRef(false); 26 | 27 | // Log when the WebSocket provider mounts or session changes 28 | useEffect(() => { 29 | console.log('WebSocketProvider: Session state changed:', !!session); 30 | }, [session]); 31 | 32 | // Configure WebSocket with reconnection and heartbeat 33 | const { readyState } = useWebSocket('ws://localhost:5000/', { 34 | onOpen: () => { 35 | // Check if this is a reconnection 36 | if (hasConnectedBefore.current) { 37 | console.log('WebSocket reconnected after disconnect - resyncing state'); 38 | } else { 39 | console.log('WebSocket connected for the first time'); 40 | hasConnectedBefore.current = true; 41 | } 42 | 43 | sendMessageToBackend('NewConnection'); 44 | 45 | // If we already have a session when connecting, ensure we're logged in 46 | if (session) { 47 | console.log('WebSocket connected with active session, ensuring login state'); 48 | sendMessageToBackend('Login', { 49 | accessToken: session.access_token, 50 | refreshToken: session.refresh_token, 51 | }); 52 | } 53 | }, 54 | onClose: (event) => { 55 | console.warn('WebSocket closed:', event.code, event.reason); 56 | }, 57 | onError: (event) => { 58 | console.error('WebSocket error:', event); 59 | }, 60 | onMessage: (event) => { 61 | try { 62 | const data: WebSocketMessage = JSON.parse(event.data); 63 | console.log('WebSocket message received:', data); 64 | 65 | // Handle version check 66 | if (data.method === 'AppVersion' && !versionCheckHandled.current) { 67 | versionCheckHandled.current = true; 68 | const backendVersion = data.content?.version; 69 | 70 | if (backendVersion && backendVersion !== __APP_VERSION__) { 71 | console.log( 72 | `Version mismatch: Backend ${backendVersion}, Frontend ${__APP_VERSION__}. Reloading...`, 73 | ); 74 | // Store the old version before reloading 75 | localStorage.setItem('oldAppVersion', __APP_VERSION__); 76 | window.location.reload(); 77 | return; 78 | } 79 | } 80 | 81 | // Dispatch the message to all listeners 82 | window.dispatchEvent( 83 | new CustomEvent('websocket-message', { 84 | detail: data, 85 | }), 86 | ); 87 | } catch (error) { 88 | console.error('Failed to parse WebSocket message:', error); 89 | } 90 | }, 91 | shouldReconnect: () => { 92 | console.log('WebSocket closed, will attempt to reconnect'); 93 | return true; 94 | }, 95 | reconnectAttempts: Infinity, 96 | reconnectInterval: 3000, 97 | heartbeat: { 98 | message: 'ping', 99 | returnMessage: 'pong', 100 | timeout: 30000, 101 | interval: 15000, 102 | }, 103 | }); 104 | 105 | const contextValue = { 106 | sendMessage: useCallback((message: string) => { 107 | sendMessageToBackend(message); 108 | }, []), 109 | isConnected: readyState === ReadyState.OPEN, 110 | connectionState: readyState, 111 | }; 112 | 113 | return {children}; 114 | } 115 | 116 | export function useWebSocketContext() { 117 | const context = useContext(WebSocketContext); 118 | if (!context) { 119 | throw new Error('useWebSocketContext must be used within a WebSocketProvider'); 120 | } 121 | return context; 122 | } 123 | -------------------------------------------------------------------------------- /Frontend/src/Context/SettingsContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | useContext, 4 | useState, 5 | ReactNode, 6 | useEffect, 7 | useCallback, 8 | useRef, 9 | } from 'react'; 10 | import { Settings, initialSettings, initialState } from '../Models/types'; 11 | import { useWebSocketContext } from './WebSocketContext'; 12 | import { sendMessageToBackend } from '../Utils/MessageUtils'; 13 | 14 | type SettingsContextType = Settings; 15 | type SettingsUpdateContextType = (newSettings: Partial, fromBackend?: boolean) => void; 16 | 17 | const SettingsContext = createContext(initialSettings); 18 | const SettingsUpdateContext = createContext(() => {}); 19 | 20 | export function useSettings(): SettingsContextType { 21 | return useContext(SettingsContext); 22 | } 23 | 24 | export function useSettingsUpdater(): SettingsUpdateContextType { 25 | return useContext(SettingsUpdateContext); 26 | } 27 | 28 | interface SettingsProviderProps { 29 | children: ReactNode; 30 | } 31 | 32 | export function SettingsProvider({ children }: SettingsProviderProps) { 33 | const STORAGE_KEY = 'segra.settings.v1'; 34 | 35 | const loadCachedSettings = (): Settings | null => { 36 | try { 37 | const raw = localStorage.getItem(STORAGE_KEY); 38 | if (!raw) return null; 39 | const cached = JSON.parse(raw); 40 | 41 | // Merge cached settings with defaults 42 | const revived: Settings = { 43 | ...initialSettings, 44 | ...cached, 45 | state: { 46 | ...initialState, 47 | ...cached.state, 48 | }, 49 | }; 50 | 51 | // Do not restore ongoing recording/preRecording or hasLoadedObs from cache 52 | revived.state.recording = undefined; 53 | revived.state.preRecording = undefined; 54 | revived.state.hasLoadedObs = false; 55 | 56 | return revived; 57 | } catch { 58 | return null; 59 | } 60 | }; 61 | 62 | const saveCachedSettings = (value: Settings) => { 63 | try { 64 | localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); 65 | } catch { 66 | // ignore caching errors 67 | } 68 | }; 69 | 70 | const [settings, setSettings] = useState(() => loadCachedSettings() ?? initialSettings); 71 | useWebSocketContext(); 72 | 73 | // Track pending backend updates 74 | const pendingBackendUpdateRef = useRef(null); 75 | 76 | const updateSettings = useCallback( 77 | (newSettings, fromBackend = false) => { 78 | setSettings((prev) => { 79 | const updatedSettings: Settings = { 80 | ...prev, 81 | ...newSettings, 82 | state: { 83 | ...prev.state, 84 | ...newSettings.state, 85 | }, 86 | }; 87 | 88 | // Persist stable settings for faster startup rendering before backend connects 89 | saveCachedSettings(updatedSettings); 90 | 91 | // Queue for backend update if this change originated from frontend 92 | if (!fromBackend) { 93 | pendingBackendUpdateRef.current = updatedSettings; 94 | } 95 | 96 | return updatedSettings; 97 | }); 98 | }, 99 | [], 100 | ); 101 | 102 | // Send pending updates to backend after React finishes state updates 103 | useEffect(() => { 104 | if (pendingBackendUpdateRef.current !== null) { 105 | const settingsToSend = pendingBackendUpdateRef.current; 106 | pendingBackendUpdateRef.current = null; 107 | 108 | // Send to backend on next tick to ensure state update is complete 109 | queueMicrotask(() => { 110 | sendMessageToBackend('UpdateSettings', settingsToSend); 111 | }); 112 | } 113 | }, [settings]); 114 | 115 | useEffect(() => { 116 | const handleWebSocketMessage = (event: CustomEvent) => { 117 | const data = event.detail; 118 | 119 | if (data.method === 'Settings') { 120 | updateSettings(data.content, true); 121 | } else if (data.method === 'GameList') { 122 | // Update the game list in state 123 | setSettings((prev) => ({ 124 | ...prev, 125 | state: { 126 | ...prev.state, 127 | gameList: data.content, 128 | }, 129 | })); 130 | } 131 | }; 132 | 133 | window.addEventListener('websocket-message', handleWebSocketMessage as EventListener); 134 | 135 | return () => { 136 | window.removeEventListener('websocket-message', handleWebSocketMessage as EventListener); 137 | }; 138 | }, [updateSettings]); 139 | 140 | return ( 141 | 142 | 143 | {children} 144 | 145 | 146 | ); 147 | } 148 | -------------------------------------------------------------------------------- /Frontend/src/Components/UploadModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from 'react'; 2 | import { Content } from '../Models/types'; 3 | import { useSettings, useSettingsUpdater } from '../Context/SettingsContext'; 4 | import { useAuth } from '../Hooks/useAuth.tsx'; 5 | import { MdOutlineFileUpload } from 'react-icons/md'; 6 | 7 | interface UploadModalProps { 8 | video: Content; 9 | onUpload: (title: string, visibility: 'Public' | 'Unlisted') => void; 10 | onClose: () => void; 11 | } 12 | 13 | export default function UploadModal({ video, onUpload, onClose }: UploadModalProps) { 14 | const { contentFolder, clipShowInBrowserAfterUpload } = useSettings(); 15 | const updateSettings = useSettingsUpdater(); 16 | const { session } = useAuth(); 17 | const [title, setTitle] = useState(video.title || ''); 18 | const [visibility] = useState<'Public' | 'Unlisted'>('Public'); 19 | const [titleError, setTitleError] = useState(false); 20 | const titleInputRef = useRef(null); 21 | 22 | // Focus on title input when modal opens (hacky but works) 23 | useEffect(() => { 24 | const timer = setTimeout(() => { 25 | const el = titleInputRef.current; 26 | if (!el) return; 27 | el.focus(); 28 | el.select(); 29 | }, 100); 30 | return () => clearTimeout(timer); 31 | }, [video.fileName]); 32 | 33 | const handleUpload = () => { 34 | if (!title.trim()) { 35 | setTitleError(true); 36 | titleInputRef.current?.focus(); 37 | return; 38 | } 39 | setTitleError(false); 40 | onUpload(title, visibility); 41 | onClose(); 42 | }; 43 | 44 | const handleKeyPress = (e: React.KeyboardEvent) => { 45 | if (e.key === 'Enter') { 46 | e.preventDefault(); 47 | handleUpload(); 48 | } 49 | }; 50 | 51 | const getVideoPath = (): string => { 52 | const contentFileName = `${contentFolder}/${video.type.toLowerCase()}s/${video.fileName}.mp4`; 53 | return `http://localhost:2222/api/content?input=${encodeURIComponent(contentFileName)}&type=${video.type.toLowerCase()}s`; 54 | }; 55 | 56 | return ( 57 | <> 58 |
59 |
60 | 66 |
67 |
68 |
69 |
77 | 78 |
79 | 82 | { 87 | setTitle(e.target.value); 88 | setTitleError(false); 89 | }} 90 | onKeyDown={handleKeyPress} 91 | className={`input input-bordered bg-base-300 w-full focus:outline-none ${titleError ? 'input-error' : ''}`} 92 | /> 93 | {titleError && ( 94 | 97 | )} 98 |
99 | 100 |
101 | 110 |
111 |
112 |
113 | 121 |
122 |
123 | 124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /Segra.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WinExe 5 | net9.0-windows10.0.17763.0 6 | x64 7 | enable 8 | enable 9 | DEBUG;TRACE;WINDOWS 10 | true 11 | 12 | 13 | false 14 | false 15 | false 16 | false 17 | false 18 | false 19 | false 20 | false 21 | 22 | 23 | true 24 | true 25 | icon.png 26 | icon.ico 27 | Segra 28 | Segra 29 | true 30 | 10.0.17763.0 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | Always 87 | 88 | 89 | 90 | 91 | 92 | True 93 | True 94 | Resources.resx 95 | 96 | 97 | True 98 | True 99 | Settings.settings 100 | 101 | 102 | 103 | 104 | 105 | ResXFileCodeGenerator 106 | Resources.Designer.cs 107 | 108 | 109 | 110 | 111 | 112 | SettingsSingleFileGenerator 113 | Settings.Designer.cs 114 | 115 | 116 | 117 | 118 | 119 | PreserveNewest 120 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /Frontend/src/Components/CustomGameModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { Game } from '../Models/types'; 3 | import { sendMessageToBackend } from '../Utils/MessageUtils'; 4 | import { isSelectedGameExecutableMessage } from '../Models/WebSocketMessages'; 5 | import { MdFolderOpen, MdAdd } from 'react-icons/md'; 6 | 7 | interface CustomGameModalProps { 8 | onSave: (game: Game) => void; 9 | onClose: () => void; 10 | } 11 | 12 | export default function CustomGameModal({ onSave, onClose }: CustomGameModalProps) { 13 | const [customGameName, setCustomGameName] = useState(''); 14 | const [customGamePaths, setCustomGamePaths] = useState([]); 15 | const [isSelectingFile, setIsSelectingFile] = useState(false); 16 | 17 | useEffect(() => { 18 | const handleWebSocketMessage = (event: CustomEvent) => { 19 | const message = event.detail; 20 | 21 | if (isSelectingFile && isSelectedGameExecutableMessage(message)) { 22 | const selectedGame = message.content as Game; 23 | const newPath = selectedGame.paths?.[0] || ''; 24 | if (newPath && !customGamePaths.includes(newPath)) { 25 | setCustomGamePaths([...customGamePaths, newPath]); 26 | if (!customGameName) { 27 | setCustomGameName(selectedGame.name); 28 | } 29 | } 30 | setIsSelectingFile(false); 31 | } 32 | }; 33 | 34 | window.addEventListener('websocket-message', handleWebSocketMessage as EventListener); 35 | return () => 36 | window.removeEventListener('websocket-message', handleWebSocketMessage as EventListener); 37 | }, [isSelectingFile, customGamePaths, customGameName]); 38 | 39 | const handleBrowseExecutable = () => { 40 | setIsSelectingFile(true); 41 | sendMessageToBackend('SelectGameExecutable'); 42 | }; 43 | 44 | const handleRemoveCustomPath = (pathToRemove: string) => { 45 | setCustomGamePaths(customGamePaths.filter((p) => p !== pathToRemove)); 46 | }; 47 | 48 | const handleSave = () => { 49 | if (!customGameName.trim() || customGamePaths.length === 0) return; 50 | 51 | const newGame: Game = { 52 | name: customGameName.trim(), 53 | paths: customGamePaths, 54 | }; 55 | 56 | onSave(newGame); 57 | onClose(); 58 | }; 59 | 60 | return ( 61 | <> 62 |
63 |
64 | 70 |
71 |
72 |

Add Custom Game

73 | 74 |
75 | 78 | setCustomGameName(e.target.value)} 84 | /> 85 |
86 | 87 |
88 | 91 | 99 | 100 | {customGamePaths.length > 0 && ( 101 |
102 | {customGamePaths.map((path, idx) => ( 103 |
107 | {path} 108 | 114 |
115 | ))} 116 |
117 | )} 118 |
119 |
120 |
121 | 129 |
130 |
131 | 132 | ); 133 | } 134 | -------------------------------------------------------------------------------- /Frontend/src/Components/Settings/KeybindingsSection.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { Settings as SettingsType, KeybindAction } from '../../Models/types'; 3 | interface KeybindingsSectionProps { 4 | settings: SettingsType; 5 | updateSettings: (updates: Partial) => void; 6 | } 7 | 8 | const getKeyName = (keyCode: number): string => { 9 | // Function keys F1-F24 10 | if (keyCode >= 112 && keyCode <= 135) return `F${keyCode - 111}`; 11 | 12 | // Special keys 13 | const keyMap: Record = { 14 | 8: 'Backspace', 15 | 9: 'Tab', 16 | 13: 'Enter', 17 | 16: 'Shift', 18 | 17: 'Ctrl', 19 | 18: 'Alt', 20 | 27: 'Esc', 21 | 32: 'Space', 22 | 33: 'PgUp', 23 | 34: 'PgDn', 24 | 35: 'End', 25 | 36: 'Home', 26 | 37: '←', 27 | 38: '↑', 28 | 39: '→', 29 | 40: '↓', 30 | 45: 'Insert', 31 | 46: 'Delete', 32 | 91: 'Win', 33 | 144: 'Num Lock', 34 | 186: ';', 35 | 187: '=', 36 | 188: ',', 37 | 189: '-', 38 | 190: '.', 39 | 191: '/', 40 | 192: '`', 41 | 219: '[', 42 | 220: '\\', 43 | 221: ']', 44 | 222: "'", 45 | }; 46 | 47 | if (keyCode >= 48 && keyCode <= 57) return String.fromCharCode(keyCode); // 0-9 48 | if (keyCode >= 65 && keyCode <= 90) return String.fromCharCode(keyCode); // A-Z 49 | 50 | return keyMap[keyCode] || `Key(${keyCode})`; 51 | }; 52 | 53 | const getActionLabel = (action: KeybindAction): string => { 54 | return action === KeybindAction.CreateBookmark ? 'Create Bookmark' : 'Save Replay Buffer'; 55 | }; 56 | 57 | export default function KeybindingsSection({ settings, updateSettings }: KeybindingsSectionProps) { 58 | const [capturing, setCapturing] = useState(null); 59 | const [pressedKeys, setPressedKeys] = useState([]); 60 | 61 | useEffect(() => { 62 | if (capturing === null) return; 63 | 64 | const handleKeyDown = (e: KeyboardEvent) => { 65 | e.preventDefault(); 66 | 67 | const keys: number[] = []; 68 | if (e.ctrlKey) keys.push(17); 69 | if (e.altKey) keys.push(18); 70 | if (e.shiftKey) keys.push(16); 71 | 72 | // Add the main key if it's not a modifier 73 | if (e.keyCode !== 16 && e.keyCode !== 17 && e.keyCode !== 18) { 74 | keys.push(e.keyCode); 75 | } 76 | 77 | setPressedKeys(keys); 78 | }; 79 | 80 | const handleKeyUp = (e: KeyboardEvent) => { 81 | e.preventDefault(); 82 | 83 | // Cancel on Escape 84 | if (e.keyCode === 27) { 85 | setCapturing(null); 86 | setPressedKeys([]); 87 | return; 88 | } 89 | 90 | // Save keybind if we have keys and released a non-modifier key 91 | if (pressedKeys.length > 0 && e.keyCode !== 16 && e.keyCode !== 17 && e.keyCode !== 18) { 92 | const updatedKeybindings = [...settings.keybindings]; 93 | updatedKeybindings[capturing] = { 94 | ...updatedKeybindings[capturing], 95 | keys: pressedKeys, 96 | }; 97 | updateSettings({ keybindings: updatedKeybindings }); 98 | setCapturing(null); 99 | setPressedKeys([]); 100 | } 101 | }; 102 | 103 | window.addEventListener('keydown', handleKeyDown); 104 | window.addEventListener('keyup', handleKeyUp); 105 | 106 | return () => { 107 | window.removeEventListener('keydown', handleKeyDown); 108 | window.removeEventListener('keyup', handleKeyUp); 109 | }; 110 | }, [capturing, pressedKeys, settings.keybindings, updateSettings]); 111 | 112 | return ( 113 |
114 |

Keybindings

115 |
116 | {settings.keybindings.map((keybind, index) => ( 117 |
121 | 137 | 138 | 149 |
150 | ))} 151 |
152 |
153 | ); 154 | } 155 | -------------------------------------------------------------------------------- /Frontend/src/Hooks/useAuth.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, createContext, useContext, ReactNode } from 'react'; 2 | import { Session, User } from '@supabase/supabase-js'; 3 | import { supabase } from '../lib/supabase/client'; 4 | import { sendMessageToBackend } from '../Utils/MessageUtils'; 5 | 6 | // Create a context to store authentication state 7 | interface AuthContextType { 8 | user: User | null; 9 | session: Session | null; 10 | authError: string | null; 11 | isAuthenticating: boolean; 12 | clearAuthError: () => void; 13 | signOut: () => Promise; 14 | } 15 | 16 | const AuthContext = createContext(null); 17 | 18 | // Provider component that wraps the app 19 | export function AuthProvider({ children }: { children: ReactNode }) { 20 | const [session, setSession] = useState(null); 21 | const [user, setUser] = useState(null); 22 | const [authError, setAuthError] = useState(null); 23 | const [isAuthenticating, setIsAuthenticating] = useState(false); 24 | const [hasInitialized, setHasInitialized] = useState(false); 25 | 26 | // Custom signOut function that ensures UI is updated 27 | const handleSignOut = async () => { 28 | try { 29 | // Sign out from Supabase 30 | const { error } = await supabase.auth.signOut({ scope: 'local' }); 31 | 32 | if (error) throw error; 33 | 34 | // Manually update state since local signOut might not trigger the event 35 | setSession(null); 36 | setUser(null); 37 | 38 | // Notify backend about logout 39 | sendMessageToBackend('Logout'); 40 | 41 | console.log('User signed out manually'); 42 | } catch (err) { 43 | console.error('Sign out error:', err); 44 | setAuthError(err instanceof Error ? err.message : 'Sign out failed'); 45 | } 46 | }; 47 | 48 | // Handle OAuth callback 49 | useEffect(() => { 50 | const handleAuthCallback = async () => { 51 | try { 52 | const urlParams = new URLSearchParams(window.location.search); 53 | const code = urlParams.get('code'); 54 | 55 | if (code) { 56 | setIsAuthenticating(true); 57 | const { 58 | data: { session }, 59 | error, 60 | } = await supabase.auth.exchangeCodeForSession(code); 61 | 62 | if (error) throw error; 63 | 64 | const { error: profileError } = await supabase 65 | .from('profiles') 66 | .select('username, avatar_url') 67 | .eq('id', session?.user?.id) 68 | .single(); 69 | 70 | if (profileError && profileError.code === 'PGRST116') { 71 | console.error('Profile error, user may not have a profile:', profileError); 72 | handleSignOut(); 73 | setAuthError('Please register at Segra.tv before logging into the app.'); 74 | } 75 | 76 | // Clean URL after successful login 77 | window.history.replaceState({}, document.title, window.location.pathname); 78 | } 79 | } catch (err) { 80 | setAuthError(err instanceof Error ? err.message : 'Authentication failed'); 81 | } finally { 82 | setIsAuthenticating(false); 83 | } 84 | }; 85 | 86 | handleAuthCallback(); 87 | }, []); 88 | 89 | // Listen for auth state changes 90 | useEffect(() => { 91 | const { data: authListener } = supabase.auth.onAuthStateChange((event, currentSession) => { 92 | console.log('Auth state changed:', event, !!currentSession); 93 | setSession(currentSession); 94 | setUser(currentSession?.user ?? null); 95 | 96 | if (event === 'SIGNED_IN' && currentSession) { 97 | console.log('User signed in, sending login to backend'); 98 | sendMessageToBackend('Login', { 99 | accessToken: currentSession.access_token, 100 | refreshToken: currentSession.refresh_token, 101 | }); 102 | } else if (event === 'SIGNED_OUT') { 103 | console.log('User signed out, sending logout to backend'); 104 | sendMessageToBackend('Logout'); 105 | } 106 | }); 107 | 108 | return () => { 109 | authListener?.subscription.unsubscribe(); 110 | }; 111 | }, []); 112 | 113 | // Get initial session once on mount 114 | useEffect(() => { 115 | if (hasInitialized) return; 116 | 117 | supabase.auth.getSession().then(({ data: { session: initialSession } }) => { 118 | console.log('Initial session retrieved:', !!initialSession); 119 | setSession(initialSession); 120 | setUser(initialSession?.user ?? null); 121 | 122 | if (initialSession) { 123 | console.log('Sending initial login credentials to backend'); 124 | sendMessageToBackend('Login', { 125 | accessToken: initialSession.access_token, 126 | refreshToken: initialSession.refresh_token, 127 | }); 128 | } 129 | 130 | setHasInitialized(true); 131 | }); 132 | }, [hasInitialized]); 133 | 134 | const value = { 135 | user, 136 | session, 137 | authError, 138 | isAuthenticating, 139 | clearAuthError: () => setAuthError(null), 140 | signOut: handleSignOut, 141 | }; 142 | 143 | return {children}; 144 | } 145 | 146 | // Hook for components to get authentication context 147 | export function useAuth() { 148 | const context = useContext(AuthContext); 149 | if (context === null) { 150 | throw new Error('useAuth must be used within an AuthProvider'); 151 | } 152 | return context; 153 | } 154 | -------------------------------------------------------------------------------- /Backend/Shared/VelopackUtils.cs: -------------------------------------------------------------------------------- 1 | using Serilog; 2 | using Segra.Backend.App; 3 | using System.Text.RegularExpressions; 4 | 5 | namespace Segra.Backend.Shared 6 | { 7 | internal static class VelopackUtils 8 | { 9 | private static readonly string VelopackLogPath = Path.Combine( 10 | Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 11 | "Segra", 12 | "velopack.log" 13 | ); 14 | 15 | /// 16 | /// Checks the Velopack log file for recent update errors and displays them to the user. 17 | /// Reads from the bottom of the log file upward until it finds the most recent "Applying package" entry, 18 | /// checking for any ERROR entries in between. 19 | /// 20 | public static async Task CheckForRecentUpdateErrors() 21 | { 22 | try 23 | { 24 | if (!File.Exists(VelopackLogPath)) 25 | { 26 | return; 27 | } 28 | 29 | int attempts = 0; 30 | bool success = false; 31 | 32 | string[] logLines = []; 33 | while (attempts < 30) 34 | { 35 | try 36 | { 37 | logLines = await File.ReadAllLinesAsync(VelopackLogPath); 38 | success = true; 39 | break; 40 | } 41 | catch (IOException) 42 | { 43 | attempts++; 44 | await Task.Delay(1000); 45 | } 46 | } 47 | 48 | if (!success) 49 | { 50 | Log.Error("Failed to read Velopack log file after multiple attempts, skipping update error check"); 51 | return; 52 | } 53 | 54 | DateTime now = DateTime.Now; 55 | DateTime cutoffTime = now.AddSeconds(-10); 56 | 57 | var errorPattern = new Regex(@"\[update:\d+\]\s+\[(\d{2}:\d{2}:\d{2})\]\s+\[ERROR\]\s+(.+)"); 58 | var applyingPackagePattern = new Regex(@"\[update:\d+\]\s+\[(\d{2}:\d{2}:\d{2})\]\s+\[INFO\]\s+Applying package"); 59 | var packageAppliedSuccessPattern = new Regex(@"\[update:\d+\]\s+\[(\d{2}:\d{2}:\d{2})\]\s+\[INFO\]\s+Package applied successfully"); 60 | 61 | foreach (string line in logLines.Reverse()) 62 | { 63 | var successMatch = packageAppliedSuccessPattern.Match(line); 64 | if (successMatch.Success) 65 | { 66 | return; 67 | } 68 | 69 | var applyingMatch = applyingPackagePattern.Match(line); 70 | if (applyingMatch.Success) 71 | { 72 | break; 73 | } 74 | 75 | var errorMatch = errorPattern.Match(line); 76 | if (errorMatch.Success) 77 | { 78 | string timeStr = errorMatch.Groups[1].Value; 79 | string errorMessage = errorMatch.Groups[2].Value; 80 | 81 | if (TimeSpan.TryParse(timeStr, out TimeSpan logTime)) 82 | { 83 | DateTime logDateTime = DateTime.Today.Add(logTime); 84 | 85 | if (logDateTime > now) 86 | { 87 | logDateTime = logDateTime.AddDays(-1); 88 | } 89 | 90 | if (logDateTime >= cutoffTime && logDateTime <= now) 91 | { 92 | string displayMessage = ExtractErrorMessage(errorMessage); 93 | 94 | Log.Warning("Found recent Velopack update error: {Error}", displayMessage); 95 | 96 | await MessageService.ShowModal( 97 | "Update Error", 98 | displayMessage, 99 | "error", 100 | "An error occurred during the update process" 101 | ); 102 | 103 | return; 104 | } 105 | } 106 | } 107 | } 108 | } 109 | catch (Exception ex) 110 | { 111 | Log.Error(ex, "Error checking Velopack log file"); 112 | } 113 | } 114 | 115 | /// 116 | /// Extracts the actual error message from the log entry. 117 | /// Gets the text after the last colon and trims whitespace. 118 | /// Example: "Apply error: Error applying package: Unable to start the update, because one or more running processes prevented it." 119 | /// returns "Unable to start the update, because one or more running processes prevented it." 120 | /// 121 | private static string ExtractErrorMessage(string fullErrorMessage) 122 | { 123 | int lastColonIndex = fullErrorMessage.LastIndexOf(':'); 124 | if (lastColonIndex >= 0 && lastColonIndex < fullErrorMessage.Length - 1) 125 | { 126 | return fullErrorMessage.Substring(lastColonIndex + 1).Trim(); 127 | } 128 | 129 | return fullErrorMessage.Trim(); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Backend/Media/CompressionService.cs: -------------------------------------------------------------------------------- 1 | using Serilog; 2 | using Segra.Backend.Core.Models; 3 | using Segra.Backend.Services; 4 | using Segra.Backend.App; 5 | 6 | namespace Segra.Backend.Media 7 | { 8 | internal static class CompressionService 9 | { 10 | public static async Task CompressVideo(string filePath) 11 | { 12 | int processId = Guid.NewGuid().GetHashCode(); 13 | 14 | try 15 | { 16 | if (!File.Exists(filePath)) 17 | { 18 | Log.Error($"File not found for compression: {filePath}"); 19 | return; 20 | } 21 | 22 | long originalSize = new FileInfo(filePath).Length; 23 | string directory = Path.GetDirectoryName(filePath)!; 24 | string fileName = Path.GetFileNameWithoutExtension(filePath); 25 | string extension = Path.GetExtension(filePath); 26 | string tempOutputPath = Path.Combine(directory, $"{fileName}_temp_compressed{extension}"); 27 | 28 | TimeSpan durationTs = await FFmpegService.GetVideoDuration(filePath); 29 | double? duration = durationTs.TotalSeconds > 0 ? durationTs.TotalSeconds : null; 30 | 31 | Log.Information($"Starting compression for: {filePath} (Original size: {originalSize / 1024 / 1024}MB)"); 32 | await MessageService.SendFrontendMessage("CompressionProgress", new { filePath, progress = 0, status = "compressing" }); 33 | 34 | string arguments = $"-y -i \"{filePath}\" -c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 128k -movflags +faststart \"{tempOutputPath}\""; 35 | 36 | await FFmpegService.RunWithProgress(processId, arguments, duration, (progress) => 37 | { 38 | _ = MessageService.SendFrontendMessage("CompressionProgress", new { filePath, progress = (int)(progress * 100), status = "compressing" }); 39 | }); 40 | 41 | if (!File.Exists(tempOutputPath)) 42 | { 43 | Log.Error($"Compression failed for: {filePath}"); 44 | await MessageService.SendFrontendMessage("CompressionProgress", new { filePath, progress = -1, status = "error", message = "Compression failed" }); 45 | return; 46 | } 47 | 48 | long compressedSize = new FileInfo(tempOutputPath).Length; 49 | Log.Information($"Compression complete. Original: {originalSize / 1024 / 1024}MB, Compressed: {compressedSize / 1024 / 1024}MB"); 50 | 51 | if (compressedSize >= originalSize) 52 | { 53 | Log.Information($"Compressed file is not smaller than original, keeping original"); 54 | File.Delete(tempOutputPath); 55 | await MessageService.SendFrontendMessage("CompressionProgress", new { filePath, progress = 100, status = "skipped", message = "Compressed file was not smaller" }); 56 | return; 57 | } 58 | 59 | Content.ContentType contentType = directory.EndsWith("clips", StringComparison.OrdinalIgnoreCase) 60 | ? Content.ContentType.Clip 61 | : Content.ContentType.Highlight; 62 | 63 | Content? originalContent = Settings.Instance.State.Content.FirstOrDefault(c => c.FilePath == filePath); 64 | string? game = originalContent?.Game; 65 | 66 | string finalPath; 67 | if (Settings.Instance.RemoveOriginalAfterCompression) 68 | { 69 | finalPath = Path.Combine(directory, $"{fileName}_compressed{extension}"); 70 | if (File.Exists(finalPath)) File.Delete(finalPath); 71 | File.Move(tempOutputPath, finalPath); 72 | 73 | Log.Information($"Replaced original with compressed file: {finalPath}"); 74 | await ContentService.CreateMetadataFile(finalPath, contentType, game ?? "Unknown", originalContent?.Bookmarks, originalContent?.Title, originalContent?.CreatedAt); 75 | await ContentService.CreateThumbnail(finalPath, contentType); 76 | await ContentService.CreateWaveformFile(finalPath, contentType); 77 | 78 | await Task.Delay(500); 79 | await ContentService.DeleteContent(filePath, contentType, false); 80 | } 81 | else 82 | { 83 | finalPath = Path.Combine(directory, $"{fileName}_compressed{extension}"); 84 | if (File.Exists(finalPath)) File.Delete(finalPath); 85 | File.Move(tempOutputPath, finalPath); 86 | Log.Information($"Saved compressed file as: {finalPath}"); 87 | 88 | await ContentService.CreateMetadataFile(finalPath, contentType, game ?? "Unknown"); 89 | await ContentService.CreateThumbnail(finalPath, contentType); 90 | await ContentService.CreateWaveformFile(finalPath, contentType); 91 | } 92 | await MessageService.SendFrontendMessage("CompressionProgress", new { filePath, progress = 100, status = "done" }); 93 | await SettingsService.LoadContentFromFolderIntoState(); 94 | } 95 | catch (Exception ex) 96 | { 97 | Log.Error(ex, $"Error compressing video: {filePath}"); 98 | await MessageService.SendFrontendMessage("CompressionProgress", new { filePath, progress = -1, status = "error", message = ex.Message }); 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Frontend/src/Components/DropdownSelect.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react'; 2 | import { MdArrowDropDown } from 'react-icons/md'; 3 | import { motion } from 'framer-motion'; 4 | 5 | export interface DropdownItem { 6 | value: string; 7 | label: React.ReactNode; 8 | } 9 | 10 | interface DropdownSelectProps { 11 | items: DropdownItem[]; 12 | value: string | undefined; 13 | onChange: (value: string) => void; 14 | placeholder?: React.ReactNode; 15 | buttonClassName?: string; 16 | menuClassName?: string; 17 | itemClassName?: string; 18 | disabled?: boolean; 19 | align?: 'start' | 'end'; 20 | size?: 'sm' | 'md' | 'lg'; 21 | } 22 | 23 | export default function DropdownSelect({ 24 | items, 25 | value, 26 | onChange, 27 | placeholder = 'Select', 28 | buttonClassName, 29 | menuClassName, 30 | // ✅ use important prefix BEFORE the utility (`!text-primary`) 31 | itemClassName = 'justify-start text-sm font-medium hover:bg-white/5 rounded-md transition-all duration-200 hover:pl-3.5', 32 | disabled = false, 33 | align = 'end', 34 | size = 'md', 35 | }: DropdownSelectProps) { 36 | const selected = items.find((i) => i.value === value); 37 | const [isOpen, setIsOpen] = useState(false); 38 | const containerRef = useRef(null); 39 | const buttonRef = useRef(null); 40 | const [openDirection, setOpenDirection] = useState<'down' | 'up'>('down'); 41 | const [menuMaxHeight, setMenuMaxHeight] = useState(); 42 | 43 | const computeMenuFit = React.useCallback(() => { 44 | const btn = buttonRef.current; 45 | if (!btn) return; 46 | const rect = btn.getBoundingClientRect(); 47 | const margin = 8; 48 | const viewportH = window.innerHeight; 49 | const spaceBelow = Math.max(0, viewportH - rect.bottom - margin); 50 | const spaceAbove = Math.max(0, rect.top - margin); 51 | let dir: 'down' | 'up' = 'down'; 52 | let available = spaceBelow; 53 | if (spaceBelow < 140 && spaceAbove > spaceBelow) { 54 | dir = 'up'; 55 | available = spaceAbove; 56 | } 57 | setOpenDirection(dir); 58 | setMenuMaxHeight(Math.min(260, Math.max(120, Math.floor(available - 8)))); 59 | }, []); 60 | 61 | React.useEffect(() => { 62 | if (!isOpen) return; 63 | const handler = () => computeMenuFit(); 64 | window.addEventListener('resize', handler); 65 | window.addEventListener('scroll', handler, true); 66 | return () => { 67 | window.removeEventListener('resize', handler); 68 | window.removeEventListener('scroll', handler, true); 69 | }; 70 | }, [isOpen, computeMenuFit]); 71 | 72 | React.useEffect(() => { 73 | function onDocMouseDown(e: MouseEvent) { 74 | if (!containerRef.current?.contains(e.target as Node)) setIsOpen(false); 75 | } 76 | function onKeyDown(e: KeyboardEvent) { 77 | if (e.key === 'Escape') setIsOpen(false); 78 | } 79 | document.addEventListener('mousedown', onDocMouseDown); 80 | document.addEventListener('keydown', onKeyDown); 81 | return () => { 82 | document.removeEventListener('mousedown', onDocMouseDown); 83 | document.removeEventListener('keydown', onKeyDown); 84 | }; 85 | }, []); 86 | 87 | const sizeBtn = size === 'sm' ? 'btn-sm' : size === 'lg' ? 'btn-lg' : ''; 88 | const computedMenuClassName = 89 | menuClassName ?? 90 | `dropdown-content menu menu-md bg-base-300 border border-base-400 rounded-box z-[999] w-full p-2 ${openDirection === 'up' ? 'mb-1' : 'mt-1'} shadow flex-nowrap`; 91 | 92 | return ( 93 |
97 | {/* Trigger */} 98 | 127 | 128 | {/* Menu (DaisyUI v5 structure) */} 129 |
    134 | {items.map((item) => { 135 | const isActive = item.value === value; 136 | return ( 137 |
  • 138 | 151 |
  • 152 | ); 153 | })} 154 |
155 |
156 | ); 157 | } 158 | -------------------------------------------------------------------------------- /Backend/Services/PresetsService.cs: -------------------------------------------------------------------------------- 1 | using Segra.Backend.App; 2 | using Segra.Backend.Core.Models; 3 | using Segra.Backend.Windows.Display; 4 | using Serilog; 5 | 6 | namespace Segra.Backend.Services 7 | { 8 | public static class PresetsService 9 | { 10 | /// 11 | /// Applies a video quality preset to the settings 12 | /// 13 | public static async Task ApplyVideoPreset(string presetName) 14 | { 15 | var settings = Settings.Instance; 16 | settings.BeginBulkUpdate(); 17 | 18 | try 19 | { 20 | switch (presetName.ToLower()) 21 | { 22 | case "low": 23 | settings.VideoQualityPreset = "low"; 24 | settings.Resolution = "720p"; 25 | settings.FrameRate = 30; 26 | settings.RateControl = "CBR"; 27 | settings.Bitrate = 10; 28 | settings.Encoder = "gpu"; 29 | break; 30 | 31 | case "standard": 32 | settings.VideoQualityPreset = "standard"; 33 | settings.Resolution = "1080p"; 34 | settings.FrameRate = 60; 35 | settings.RateControl = "VBR"; 36 | settings.Bitrate = 40; 37 | settings.MinBitrate = 40; 38 | settings.MaxBitrate = 60; 39 | settings.Encoder = "gpu"; 40 | break; 41 | 42 | case "high": 43 | settings.VideoQualityPreset = "high"; 44 | settings.Resolution = DisplayService.HasDisplayWithMinHeight(1440) ? "1440p" : "1080p"; 45 | settings.FrameRate = 60; 46 | settings.RateControl = "VBR"; 47 | settings.Bitrate = 70; 48 | settings.MinBitrate = 70; 49 | settings.MaxBitrate = 100; 50 | settings.Encoder = "gpu"; 51 | break; 52 | 53 | case "custom": 54 | settings.VideoQualityPreset = "custom"; 55 | break; 56 | 57 | default: 58 | Log.Warning($"Unknown video preset: {presetName}"); 59 | return; 60 | } 61 | 62 | Log.Information("Applied video preset '{Preset}': {Resolution}, {FrameRate}fps, {RateControl}, {Encoder}", 63 | settings.VideoQualityPreset, settings.Resolution, settings.FrameRate, settings.RateControl, settings.Encoder); 64 | 65 | settings.EndBulkUpdateAndSaveSettings(); 66 | await MessageService.SendSettingsToFrontend("Video preset applied"); 67 | } 68 | catch (Exception ex) 69 | { 70 | Log.Error(ex, "Failed to apply video preset"); 71 | settings.EndBulkUpdateAndSaveSettings(); 72 | } 73 | } 74 | 75 | /// 76 | /// Applies a clip quality preset to the settings 77 | /// 78 | public static async Task ApplyClipPreset(string presetName) 79 | { 80 | var settings = Settings.Instance; 81 | settings.BeginBulkUpdate(); 82 | 83 | try 84 | { 85 | switch (presetName.ToLower()) 86 | { 87 | case "low": 88 | settings.ClipQualityPreset = "low"; 89 | settings.ClipEncoder = "cpu"; 90 | settings.ClipQualityCpu = 28; 91 | settings.ClipCodec = "h264"; 92 | settings.ClipFps = 30; 93 | settings.ClipAudioQuality = "96k"; 94 | settings.ClipPreset = "ultrafast"; 95 | break; 96 | 97 | case "standard": 98 | settings.ClipQualityPreset = "standard"; 99 | settings.ClipEncoder = "cpu"; 100 | settings.ClipQualityCpu = 23; 101 | settings.ClipCodec = "h264"; 102 | settings.ClipFps = 60; 103 | settings.ClipAudioQuality = "128k"; 104 | settings.ClipPreset = "veryfast"; 105 | break; 106 | 107 | case "high": 108 | settings.ClipQualityPreset = "high"; 109 | settings.ClipEncoder = "cpu"; 110 | settings.ClipQualityCpu = 20; 111 | settings.ClipCodec = "h264"; 112 | settings.ClipFps = 60; 113 | settings.ClipAudioQuality = "192k"; 114 | settings.ClipPreset = "medium"; 115 | break; 116 | 117 | case "custom": 118 | settings.ClipQualityPreset = "custom"; 119 | break; 120 | 121 | default: 122 | Log.Warning($"Unknown clip preset: {presetName}"); 123 | return; 124 | } 125 | 126 | Log.Information("Applied clip preset '{Preset}': {Encoder}, CRF {Quality}, {Codec}, {Fps}fps, {Audio} audio, {EncoderPreset}", 127 | settings.ClipQualityPreset, settings.ClipEncoder, settings.ClipQualityCpu, settings.ClipCodec, settings.ClipFps, settings.ClipAudioQuality, settings.ClipPreset); 128 | 129 | settings.EndBulkUpdateAndSaveSettings(); 130 | await MessageService.SendSettingsToFrontend("Clip preset applied"); 131 | } 132 | catch (Exception ex) 133 | { 134 | Log.Error(ex, "Failed to apply clip preset"); 135 | settings.EndBulkUpdateAndSaveSettings(); 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Resources/wwwroot/404.html: -------------------------------------------------------------------------------- 1 | 404: This page could not be found.Create Next App

404

This page could not be found.

--------------------------------------------------------------------------------