Repository: aza547/wow-recorder Branch: main Commit: 6699af64174f Files: 264 Total size: 247.4 MB Directory structure: gitextract_hwpkyjj1/ ├── .erb/ │ ├── configs/ │ │ ├── .eslintrc │ │ ├── webpack.config.base.ts │ │ ├── webpack.config.eslint.ts │ │ ├── webpack.config.main.dev.ts │ │ ├── webpack.config.main.prod.ts │ │ ├── webpack.config.preload.dev.ts │ │ ├── webpack.config.renderer.dev.dll.ts │ │ ├── webpack.config.renderer.dev.ts │ │ ├── webpack.config.renderer.prod.ts │ │ └── webpack.paths.ts │ ├── mocks/ │ │ └── fileMock.js │ └── scripts/ │ ├── .eslintrc │ ├── check-build-exists.ts │ ├── check-native-dep.js │ ├── check-node-env.js │ ├── check-port-in-use.js │ ├── clean.js │ ├── delete-source-maps.js │ ├── electron-rebuild.js │ ├── link-modules.ts │ └── notarize.js ├── .eslintrc.js ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ └── node.js.yml ├── .gitignore ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets/ │ ├── assets.d.ts │ └── entitlements.mac.plist ├── docs/ │ ├── CONTRIBUTING.md │ ├── Colors.md │ ├── FilterAudio.md │ ├── Localisation.md │ ├── LocateLogDirectory.md │ ├── SettingsReference.md │ ├── design.excalidraw │ └── example-obs-config/ │ ├── Output.json │ └── Video.json ├── eslint.config.mjs ├── installer.nsh ├── package.json ├── postcss.config.js ├── release/ │ └── app/ │ └── package.json ├── src/ │ ├── __tests__/ │ │ ├── activitys/ │ │ │ ├── ArenaMatch.test.ts │ │ │ ├── Battleground.test.ts │ │ │ ├── ChallengeModeDungeon.test.ts │ │ │ ├── RaidEncounter.test.ts │ │ │ └── SoloShuffle.test.ts │ │ ├── localisation/ │ │ │ └── Translate.test.ts │ │ └── parser/ │ │ └── Basic.test.ts │ ├── activitys/ │ │ ├── Activity.ts │ │ ├── ArenaMatch.ts │ │ ├── Battleground.ts │ │ ├── ChallengeModeDungeon.ts │ │ ├── Manual.ts │ │ ├── RaidEncounter.ts │ │ └── SoloShuffle.ts │ ├── config/ │ │ ├── ConfigService.ts │ │ └── configSchema.ts │ ├── localisation/ │ │ ├── chineseSimplified.ts │ │ ├── english.ts │ │ ├── german.ts │ │ ├── korean.ts │ │ ├── phrases.ts │ │ └── translations.ts │ ├── main/ │ │ ├── AppUpdater.ts │ │ ├── Combatant.ts │ │ ├── Manager.ts │ │ ├── Recorder.ts │ │ ├── VideoProcessQueue.ts │ │ ├── constants.ts │ │ ├── keystone.ts │ │ ├── main.ts │ │ ├── menu.ts │ │ ├── obsEnums.ts │ │ ├── preload.ts │ │ ├── types.ts │ │ └── util.ts │ ├── parsing/ │ │ ├── ClassicLogHandler.ts │ │ ├── CombatLogWatcher.ts │ │ ├── EraLogHandler.ts │ │ ├── LogHandler.ts │ │ ├── LogLine.ts │ │ ├── RetailLogHandler.ts │ │ └── logutils.ts │ ├── renderer/ │ │ ├── App.css │ │ ├── App.tsx │ │ ├── AudioSourceControls.tsx │ │ ├── BattlegroundInfo.tsx │ │ ├── BulkTransferDialog.tsx │ │ ├── CategoryPage.tsx │ │ ├── ChatOverlayControls.tsx │ │ ├── CloudSettings.tsx │ │ ├── ConfirmChatNamePrompt.tsx │ │ ├── CrashStatus.tsx │ │ ├── DateRangePicker.tsx │ │ ├── DeleteDialog.tsx │ │ ├── DiscordButton.tsx │ │ ├── FlavourSettings.tsx │ │ ├── GeneralSettings.tsx │ │ ├── KillVideoDialog.tsx │ │ ├── KillVideoProgress.tsx │ │ ├── KillVideoSourceTimeline.tsx │ │ ├── Layout.tsx │ │ ├── LocaleSettings.tsx │ │ ├── LogButton.tsx │ │ ├── ManualSettings.tsx │ │ ├── MicrophoneStatus.tsx │ │ ├── MultiPovPlaybackToggles.tsx │ │ ├── PVESettings.tsx │ │ ├── PVPSettings.tsx │ │ ├── PatreonButton.tsx │ │ ├── RaidComp.tsx │ │ ├── RecorderPreview.tsx │ │ ├── RendererTitleBar.tsx │ │ ├── SavingStatus.tsx │ │ ├── SceneEditor.tsx │ │ ├── SearchBar.tsx │ │ ├── SettingsPage.tsx │ │ ├── SideMenu.tsx │ │ ├── SnackBar.tsx │ │ ├── StorageFilterToggle.tsx │ │ ├── TagDialog.tsx │ │ ├── TestButton.tsx │ │ ├── VideoBaseControls.tsx │ │ ├── VideoChat.tsx │ │ ├── VideoCorrelator.ts │ │ ├── VideoFilter.ts │ │ ├── VideoMarkerToggles.tsx │ │ ├── VideoPlayer.tsx │ │ ├── VideoSourceControls.tsx │ │ ├── VideoTag.ts │ │ ├── WindowsSettings.tsx │ │ ├── components/ │ │ │ ├── Badge/ │ │ │ │ └── Badge.tsx │ │ │ ├── Button/ │ │ │ │ └── Button.tsx │ │ │ ├── Command/ │ │ │ │ └── Command.tsx │ │ │ ├── Dialog/ │ │ │ │ └── Dialog.tsx │ │ │ ├── DrawingOverlay/ │ │ │ │ ├── DrawingOverlay.css │ │ │ │ └── DrawingOverlay.tsx │ │ │ ├── HoverCard/ │ │ │ │ └── HoverCard.tsx │ │ │ ├── Input/ │ │ │ │ └── Input.tsx │ │ │ ├── Label/ │ │ │ │ └── Label.tsx │ │ │ ├── Menu/ │ │ │ │ ├── Item.tsx │ │ │ │ ├── Menu.tsx │ │ │ │ └── index.tsx │ │ │ ├── MultiSelect/ │ │ │ │ └── MultiSelect.tsx │ │ │ ├── Popover/ │ │ │ │ └── Popover.tsx │ │ │ ├── Progress/ │ │ │ │ └── Progress.tsx │ │ │ ├── ScrollArea/ │ │ │ │ └── ScrollArea.tsx │ │ │ ├── Select/ │ │ │ │ └── Select.tsx │ │ │ ├── Separator/ │ │ │ │ └── Separator.tsx │ │ │ ├── Slider/ │ │ │ │ └── Slider.tsx │ │ │ ├── StatusLight/ │ │ │ │ └── StatusLight.tsx │ │ │ ├── Switch/ │ │ │ │ └── Switch.tsx │ │ │ ├── Tables/ │ │ │ │ ├── Cells.tsx │ │ │ │ ├── Headers.tsx │ │ │ │ ├── Sorting.ts │ │ │ │ ├── TableData.tsx │ │ │ │ └── VideoSelectionTable.tsx │ │ │ ├── Tabs/ │ │ │ │ └── Tabs.tsx │ │ │ ├── TextArea/ │ │ │ │ └── textarea.tsx │ │ │ ├── TextBanner/ │ │ │ │ └── TextBanner.tsx │ │ │ ├── Toast/ │ │ │ │ ├── Toast.tsx │ │ │ │ ├── Toaster.tsx │ │ │ │ └── useToast.ts │ │ │ ├── Toggle/ │ │ │ │ └── Toggle.tsx │ │ │ ├── ToggleGroup/ │ │ │ │ └── ToggleGroup.tsx │ │ │ ├── Tooltip/ │ │ │ │ └── Tooltip.tsx │ │ │ ├── Viewpoints/ │ │ │ │ └── ViewpointSelection.tsx │ │ │ └── utils/ │ │ │ └── index.ts │ │ ├── containers/ │ │ │ ├── ApplicationStatusCard/ │ │ │ │ ├── ApplicationStatusCard.tsx │ │ │ │ ├── CloudStatus.tsx │ │ │ │ ├── CloudStatusCard.tsx │ │ │ │ ├── ErrorReporter.tsx │ │ │ │ ├── MicStatus.tsx │ │ │ │ └── Status.tsx │ │ │ └── UpdateNotifier/ │ │ │ └── UpdateNotifier.tsx │ │ ├── images.ts │ │ ├── index.ejs │ │ ├── index.tsx │ │ ├── preload.d.ts │ │ ├── rendererutils.ts │ │ ├── sounds.ts │ │ └── useSettings.ts │ ├── storage/ │ │ ├── CloudClient.ts │ │ ├── DiskClient.ts │ │ ├── DiskSizeMonitor.ts │ │ └── StorageClient.ts │ ├── types/ │ │ ├── KeyTypesUIOHook.ts │ │ ├── VideoCategory.ts │ │ └── api.ts │ └── utils/ │ ├── AsyncQueue.ts │ ├── AuthError.ts │ ├── Poller.ts │ ├── RetryableConfigError.ts │ ├── TestConfigService.ts │ ├── configUtils.ts │ ├── testButtonData.ts │ └── testButtonUtils.ts ├── tailwind.config.js ├── tests/ │ ├── README.md │ ├── logs/ │ │ ├── classic/ │ │ │ ├── battleground.txt │ │ │ ├── mop_challenge_mode.txt │ │ │ ├── raid.txt │ │ │ ├── rated_2v2.txt │ │ │ ├── rated_2v2_double.txt │ │ │ ├── rated_2v2_extra_units.txt │ │ │ ├── rated_2v2_feign_death.txt │ │ │ ├── rated_3v3.txt │ │ │ ├── rated_3v3_force_stop.txt │ │ │ └── rated_5v5.txt │ │ ├── era/ │ │ │ └── raid.txt │ │ └── retail/ │ │ ├── mythic_plus.txt │ │ ├── mythic_plus_ditch_into_raid.txt │ │ ├── mythic_plus_drop_go.txt │ │ ├── mythic_plus_no_boss.txt │ │ ├── mythic_plus_repair.txt │ │ ├── raid_reset.txt │ │ ├── raid_unknown_encounter.txt │ │ ├── raid_wipe.txt │ │ ├── rated_2v2.txt │ │ ├── rated_2v2_afk_out.txt │ │ ├── rated_3v3.txt │ │ ├── rated_battleground.txt │ │ ├── rated_solo_shuffle.txt │ │ ├── skirmish.txt │ │ ├── wargame_3v3.txt │ │ └── zone_changes.txt │ └── src/ │ ├── classic/ │ │ ├── battleground.py │ │ ├── mop_challenge_mode.py │ │ ├── raid.py │ │ ├── rated_2v2.py │ │ ├── rated_2v2_double.py │ │ ├── rated_2v2_extra_units.py │ │ ├── rated_2v2_feign_death.py │ │ ├── rated_3v3.py │ │ ├── rated_3v3_force_stop.py │ │ └── rated_5v5.py │ ├── era/ │ │ └── raid.py │ ├── retail/ │ │ ├── mythic_plus.py │ │ ├── mythic_plus_ditch_into_raid.py │ │ ├── mythic_plus_drop_go.py │ │ ├── mythic_plus_no_boss.py │ │ ├── mythic_plus_repair.py │ │ ├── raid_reset.py │ │ ├── raid_unknown_encounter.py │ │ ├── raid_wipe.py │ │ ├── rated_2v2.py │ │ ├── rated_2v2_afk_out.py │ │ ├── rated_3v3.py │ │ ├── rated_battleground.py │ │ ├── rated_solo_shuffle.py │ │ ├── skirmish.py │ │ ├── wargame_3v3.py │ │ └── zone_changes.py │ └── test.py └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .erb/configs/.eslintrc ================================================ { "rules": { "no-console": "off", "global-require": "off", "import/no-dynamic-require": "off", "no-underscore-dangle": "off" } } ================================================ FILE: .erb/configs/webpack.config.base.ts ================================================ /** * Base webpack config used across other specific configs */ import webpack from 'webpack'; import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin'; import webpackPaths from './webpack.paths'; import { dependencies as externals } from '../../release/app/package.json'; const configuration: webpack.Configuration = { externals: [...Object.keys(externals || {})], stats: 'errors-only', module: { rules: [ { test: /\.[jt]sx?$/, exclude: /node_modules/, use: { loader: 'ts-loader', options: { // Remove this line to enable type checking in webpack builds transpileOnly: true, compilerOptions: { module: 'nodenext', moduleResolution: 'nodenext', }, }, }, }, ], }, output: { path: webpackPaths.srcPath, // https://github.com/webpack/webpack/issues/1114 library: { type: 'commonjs2' }, }, /** * Determine the array of extensions that should be used to resolve modules. */ resolve: { extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'], modules: [webpackPaths.srcPath, 'node_modules'], // There is no need to add aliases here, the paths in tsconfig get mirrored plugins: [new TsconfigPathsPlugins()], fallback: { 'roughjs/bin/rough': require.resolve('roughjs/bin/rough'), 'roughjs/bin/generator': require.resolve('roughjs/bin/generator'), 'roughjs/bin/math': require.resolve('roughjs/bin/math') } }, plugins: [ new webpack.EnvironmentPlugin({ NODE_ENV: 'production', }), new webpack.DefinePlugin({ 'process.env.FLUENTFFMPEG_COV': false, }), ], }; export default configuration; ================================================ FILE: .erb/configs/webpack.config.eslint.ts ================================================ /* eslint import/no-unresolved: off, import/no-self-import: off */ module.exports = require('./webpack.config.renderer.dev').default; ================================================ FILE: .erb/configs/webpack.config.main.dev.ts ================================================ /** * Webpack config for development electron main process */ import path from 'path'; import webpack from 'webpack'; import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; import { merge } from 'webpack-merge'; import checkNodeEnv from '../scripts/check-node-env'; import baseConfig from './webpack.config.base'; import webpackPaths from './webpack.paths'; // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's // at the dev webpack config is not accidentally run in a production environment if (process.env.NODE_ENV === 'production') { checkNodeEnv('development'); } const configuration: webpack.Configuration = { devtool: 'inline-source-map', mode: 'development', target: 'electron-main', entry: { main: path.join(webpackPaths.srcMainPath, 'main.ts'), preload: path.join(webpackPaths.srcMainPath, 'preload.ts'), }, output: { path: webpackPaths.dllPath, filename: '[name].bundle.dev.js', library: { type: 'umd', }, }, plugins: [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore new BundleAnalyzerPlugin({ analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', analyzerPort: 8888, }), new webpack.DefinePlugin({ 'process.type': '"browser"', }), ], /** * Disables webpack processing of __dirname and __filename. * If you run the bundle in node.js it falls back to these values of node.js. * https://github.com/webpack/webpack/issues/2010 */ node: { __dirname: false, __filename: false, }, }; export default merge(baseConfig, configuration); ================================================ FILE: .erb/configs/webpack.config.main.prod.ts ================================================ /** * Webpack config for production electron main process */ import path from 'path'; import webpack from 'webpack'; import { merge } from 'webpack-merge'; import TerserPlugin from 'terser-webpack-plugin'; import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; import baseConfig from './webpack.config.base'; import webpackPaths from './webpack.paths'; import checkNodeEnv from '../scripts/check-node-env'; import deleteSourceMaps from '../scripts/delete-source-maps'; checkNodeEnv('production'); deleteSourceMaps(); const configuration: webpack.Configuration = { devtool: 'source-map', mode: 'production', target: 'electron-main', entry: { main: path.join(webpackPaths.srcMainPath, 'main.ts'), preload: path.join(webpackPaths.srcMainPath, 'preload.ts'), }, output: { path: webpackPaths.distMainPath, filename: '[name].js', library: { type: 'umd', }, }, optimization: { minimizer: [ new TerserPlugin({ parallel: true, }), ], }, plugins: [ new BundleAnalyzerPlugin({ analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', analyzerPort: 8888, }), /** * Create global constants which can be configured at compile time. * * Useful for allowing different behaviour between development builds and * release builds * * NODE_ENV should be production so that modules do not perform certain * development checks */ new webpack.EnvironmentPlugin({ NODE_ENV: 'production', DEBUG_PROD: false, START_MINIMIZED: false, }), new webpack.DefinePlugin({ 'process.type': '"browser"', }), ], /** * Disables webpack processing of __dirname and __filename. * If you run the bundle in node.js it falls back to these values of node.js. * https://github.com/webpack/webpack/issues/2010 */ node: { __dirname: false, __filename: false, }, }; export default merge(baseConfig, configuration); ================================================ FILE: .erb/configs/webpack.config.preload.dev.ts ================================================ import path from 'path'; import webpack from 'webpack'; import { merge } from 'webpack-merge'; import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; import baseConfig from './webpack.config.base'; import webpackPaths from './webpack.paths'; import checkNodeEnv from '../scripts/check-node-env'; // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's // at the dev webpack config is not accidentally run in a production environment if (process.env.NODE_ENV === 'production') { checkNodeEnv('development'); } const configuration: webpack.Configuration = { devtool: 'inline-source-map', mode: 'development', target: 'electron-preload', entry: path.join(webpackPaths.srcMainPath, 'preload.ts'), output: { path: webpackPaths.dllPath, filename: 'preload.js', library: { type: 'umd', }, }, plugins: [ new BundleAnalyzerPlugin({ analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', }), /** * Create global constants which can be configured at compile time. * * Useful for allowing different behaviour between development builds and * release builds * * NODE_ENV should be production so that modules do not perform certain * development checks * * By default, use 'development' as NODE_ENV. This can be overriden with * 'staging', for example, by changing the ENV variables in the npm scripts */ new webpack.EnvironmentPlugin({ NODE_ENV: 'development', }), new webpack.LoaderOptionsPlugin({ debug: true, }), ], /** * Disables webpack processing of __dirname and __filename. * If you run the bundle in node.js it falls back to these values of node.js. * https://github.com/webpack/webpack/issues/2010 */ node: { __dirname: false, __filename: false, }, watch: true, }; export default merge(baseConfig, configuration); ================================================ FILE: .erb/configs/webpack.config.renderer.dev.dll.ts ================================================ /** * Builds the DLL for development electron renderer process */ import webpack from 'webpack'; import path from 'path'; import { merge } from 'webpack-merge'; import baseConfig from './webpack.config.base'; import webpackPaths from './webpack.paths'; import { dependencies } from '../../package.json'; import checkNodeEnv from '../scripts/check-node-env'; checkNodeEnv('development'); const dist = webpackPaths.dllPath; const configuration: webpack.Configuration = { context: webpackPaths.rootPath, devtool: 'eval', mode: 'development', target: 'electron-renderer', externals: ['fsevents', 'crypto-browserify'], /** * Use `module` from `webpack.config.renderer.dev.js` */ module: require('./webpack.config.renderer.dev').default.module, entry: { renderer: Object.keys(dependencies || {}), }, output: { path: dist, filename: '[name].dev.dll.js', library: { name: 'renderer', type: 'var', }, }, plugins: [ new webpack.DllPlugin({ path: path.join(dist, '[name].json'), name: '[name]', }), /** * Create global constants which can be configured at compile time. * * Useful for allowing different behaviour between development builds and * release builds * * NODE_ENV should be production so that modules do not perform certain * development checks */ new webpack.EnvironmentPlugin({ NODE_ENV: 'development', }), new webpack.LoaderOptionsPlugin({ debug: true, options: { context: webpackPaths.srcPath, output: { path: webpackPaths.dllPath, }, }, }), ], }; export default merge(baseConfig, configuration); ================================================ FILE: .erb/configs/webpack.config.renderer.dev.ts ================================================ import 'webpack-dev-server'; import path from 'path'; import fs from 'fs'; import webpack from 'webpack'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import chalk from 'chalk'; import { merge } from 'webpack-merge'; import { execSync, spawn } from 'child_process'; import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; import baseConfig from './webpack.config.base'; import webpackPaths from './webpack.paths'; import checkNodeEnv from '../scripts/check-node-env'; // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's // at the dev webpack config is not accidentally run in a production environment if (process.env.NODE_ENV === 'production') { checkNodeEnv('development'); } const port = process.env.PORT || 1212; const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json'); const skipDLLs = module.parent?.filename.includes('webpack.config.renderer.dev.dll') || module.parent?.filename.includes('webpack.config.eslint'); /** * Warn if the DLL is not built */ if ( !skipDLLs && !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest)) ) { console.log( chalk.black.bgYellow.bold( 'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"', ), ); execSync('npm run postinstall'); } const configuration: webpack.Configuration = { devtool: 'inline-source-map', mode: 'development', target: ['web', 'electron-renderer'], entry: [ `webpack-dev-server/client?http://localhost:${port}/dist`, 'webpack/hot/only-dev-server', path.join(webpackPaths.srcRendererPath, 'index.tsx'), ], output: { path: webpackPaths.distRendererPath, publicPath: '/', filename: 'renderer.dev.js', library: { type: 'umd', }, }, module: { rules: [ { test: /\.s?(c|a)ss$/, use: [ 'style-loader', { loader: 'css-loader', options: { modules: true, sourceMap: true, importLoaders: 1, }, }, 'sass-loader', 'postcss-loader', ], include: /\.module\.s?(c|a)ss$/, }, { test: /\.s?css$/, use: ['style-loader', 'css-loader', 'sass-loader', 'postcss-loader'], exclude: /\.module\.s?(c|a)ss$/, }, // Fonts { test: /\.(woff|woff2|eot|ttf|otf)$/i, type: 'asset/resource', }, // Images { test: /\.(png|jpg|jpeg|gif)$/i, type: 'asset/resource', }, // Audio { test: /\.(mp3)$/i, type: 'asset/resource', }, // SVG { test: /\.svg$/, use: [ { loader: '@svgr/webpack', options: { prettier: false, svgo: false, svgoConfig: { plugins: [{ removeViewBox: false }], }, titleProp: true, ref: true, }, }, 'file-loader', ], }, ], }, plugins: [ ...(skipDLLs ? [] : [ new webpack.DllReferencePlugin({ context: webpackPaths.dllPath, manifest: require(manifest), sourceType: 'var', }), ]), new webpack.NoEmitOnErrorsPlugin(), /** * Create global constants which can be configured at compile time. * * Useful for allowing different behaviour between development builds and * release builds * * NODE_ENV should be production so that modules do not perform certain * development checks * * By default, use 'development' as NODE_ENV. This can be overriden with * 'staging', for example, by changing the ENV variables in the npm scripts */ new webpack.EnvironmentPlugin({ NODE_ENV: 'development', }), new webpack.LoaderOptionsPlugin({ debug: true, }), new ReactRefreshWebpackPlugin(), new HtmlWebpackPlugin({ filename: path.join('index.html'), template: path.join(webpackPaths.srcRendererPath, 'index.ejs'), minify: { collapseWhitespace: true, removeAttributeQuotes: true, removeComments: true, }, isBrowser: false, env: process.env.NODE_ENV, isDevelopment: process.env.NODE_ENV !== 'production', nodeModules: webpackPaths.appNodeModulesPath, }), ], node: { __dirname: false, __filename: false, }, devServer: { port, compress: true, hot: true, headers: { 'Access-Control-Allow-Origin': '*' }, static: { publicPath: '/', }, historyApiFallback: { verbose: true, }, setupMiddlewares(middlewares) { console.log('Starting preload.js builder...'); const preloadProcess = spawn('npm', ['run', 'start:preload'], { shell: true, stdio: 'inherit', }) .on('close', (code: number) => process.exit(code!)) .on('error', (spawnError) => console.error(spawnError)); console.log('Starting Main Process...'); let args = ['run', 'start:main']; if (process.env.MAIN_ARGS) { args = args.concat( ['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat(), ); } spawn('npm', args, { shell: true, stdio: 'inherit', }) .on('close', (code: number) => { preloadProcess.kill(); process.exit(code!); }) .on('error', (spawnError) => console.error(spawnError)); return middlewares; }, }, }; export default merge(baseConfig, configuration); ================================================ FILE: .erb/configs/webpack.config.renderer.prod.ts ================================================ /** * Build config for electron renderer process */ import path from 'path'; import webpack from 'webpack'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; import { merge } from 'webpack-merge'; import TerserPlugin from 'terser-webpack-plugin'; import baseConfig from './webpack.config.base'; import webpackPaths from './webpack.paths'; import checkNodeEnv from '../scripts/check-node-env'; import deleteSourceMaps from '../scripts/delete-source-maps'; checkNodeEnv('production'); deleteSourceMaps(); const configuration: webpack.Configuration = { devtool: 'source-map', mode: 'production', target: ['web', 'electron-renderer'], entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')], output: { path: webpackPaths.distRendererPath, publicPath: './', filename: 'renderer.js', library: { type: 'umd', }, }, module: { rules: [ { test: /\.s?(a|c)ss$/, use: [ MiniCssExtractPlugin.loader, { loader: 'css-loader', options: { modules: true, sourceMap: true, importLoaders: 1, }, }, 'sass-loader', 'postcss-loader', ], include: /\.module\.s?(c|a)ss$/, }, { test: /\.s?(a|c)ss$/, use: [ MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader', 'postcss-loader', ], exclude: /\.module\.s?(c|a)ss$/, }, // Fonts { test: /\.(woff|woff2|eot|ttf|otf)$/i, type: 'asset/resource', }, // Images { test: /\.(png|jpg|jpeg|gif)$/i, type: 'asset/resource', }, // Audio { test: /\.(mp3)$/i, type: 'asset/resource', }, // SVG { test: /\.svg$/, use: [ { loader: '@svgr/webpack', options: { prettier: false, svgo: false, svgoConfig: { plugins: [{ removeViewBox: false }], }, titleProp: true, ref: true, }, }, 'file-loader', ], }, ], }, optimization: { minimize: true, minimizer: [new TerserPlugin(), new CssMinimizerPlugin()], }, plugins: [ /** * Create global constants which can be configured at compile time. * * Useful for allowing different behaviour between development builds and * release builds * * NODE_ENV should be production so that modules do not perform certain * development checks */ new webpack.EnvironmentPlugin({ NODE_ENV: 'production', DEBUG_PROD: false, }), new MiniCssExtractPlugin({ filename: 'style.css', }), new BundleAnalyzerPlugin({ analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', analyzerPort: 8889, }), new HtmlWebpackPlugin({ filename: 'index.html', template: path.join(webpackPaths.srcRendererPath, 'index.ejs'), minify: { collapseWhitespace: true, removeAttributeQuotes: true, removeComments: true, }, isBrowser: false, isDevelopment: false, }), new webpack.DefinePlugin({ 'process.type': '"renderer"', }), ], }; export default merge(baseConfig, configuration); ================================================ FILE: .erb/configs/webpack.paths.ts ================================================ const path = require('path'); const rootPath = path.join(__dirname, '../..'); const erbPath = path.join(__dirname, '..'); const erbNodeModulesPath = path.join(erbPath, 'node_modules'); const dllPath = path.join(__dirname, '../dll'); const srcPath = path.join(rootPath, 'src'); const srcMainPath = path.join(srcPath, 'main'); const srcRendererPath = path.join(srcPath, 'renderer'); const releasePath = path.join(rootPath, 'release'); const appPath = path.join(releasePath, 'app'); const appPackagePath = path.join(appPath, 'package.json'); const appNodeModulesPath = path.join(appPath, 'node_modules'); const srcNodeModulesPath = path.join(srcPath, 'node_modules'); const distPath = path.join(appPath, 'dist'); const distMainPath = path.join(distPath, 'main'); const distRendererPath = path.join(distPath, 'renderer'); const buildPath = path.join(releasePath, 'build'); export default { rootPath, erbNodeModulesPath, dllPath, srcPath, srcMainPath, srcRendererPath, releasePath, appPath, appPackagePath, appNodeModulesPath, srcNodeModulesPath, distPath, distMainPath, distRendererPath, buildPath, }; ================================================ FILE: .erb/mocks/fileMock.js ================================================ export default 'test-file-stub'; ================================================ FILE: .erb/scripts/.eslintrc ================================================ { "rules": { "no-console": "off", "global-require": "off", "import/no-dynamic-require": "off", "import/no-extraneous-dependencies": "off" } } ================================================ FILE: .erb/scripts/check-build-exists.ts ================================================ // Check if the renderer and main bundles are built import path from 'path'; import chalk from 'chalk'; import fs from 'fs'; import webpackPaths from '../configs/webpack.paths'; const mainPath = path.join(webpackPaths.distMainPath, 'main.js'); const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js'); if (!fs.existsSync(mainPath)) { throw new Error( chalk.whiteBright.bgRed.bold( 'The main process is not built yet. Build it by running "npm run build:main"', ), ); } if (!fs.existsSync(rendererPath)) { throw new Error( chalk.whiteBright.bgRed.bold( 'The renderer process is not built yet. Build it by running "npm run build:renderer"', ), ); } ================================================ FILE: .erb/scripts/check-native-dep.js ================================================ import fs from 'fs'; import chalk from 'chalk'; import { execSync } from 'child_process'; import { dependencies } from '../../package.json'; if (dependencies) { const dependenciesKeys = Object.keys(dependencies); const nativeDeps = fs .readdirSync('node_modules') .filter((folder) => fs.existsSync(`node_modules/${folder}/binding.gyp`)); if (nativeDeps.length === 0) { process.exit(0); } try { // Find the reason for why the dependency is installed. If it is installed // because of a devDependency then that is okay. Warn when it is installed // because of a dependency const { dependencies: dependenciesObject } = JSON.parse( execSync(`npm ls ${nativeDeps.join(' ')} --json`).toString(), ); const rootDependencies = Object.keys(dependenciesObject); const filteredRootDependencies = rootDependencies.filter((rootDependency) => dependenciesKeys.includes(rootDependency), ); if (filteredRootDependencies.length > 0) { const plural = filteredRootDependencies.length > 1; console.log(` ${chalk.whiteBright.bgYellow.bold( 'Webpack does not work with native dependencies.', )} ${chalk.bold(filteredRootDependencies.join(', '))} ${ plural ? 'are native dependencies' : 'is a native dependency' } and should be installed inside of the "./release/app" folder. First, uninstall the packages from "./package.json": ${chalk.whiteBright.bgGreen.bold('npm uninstall your-package')} ${chalk.bold( 'Then, instead of installing the package to the root "./package.json":', )} ${chalk.whiteBright.bgRed.bold('npm install your-package')} ${chalk.bold('Install the package to "./release/app/package.json"')} ${chalk.whiteBright.bgGreen.bold( 'cd ./release/app && npm install your-package', )} Read more about native dependencies at: ${chalk.bold( 'https://electron-react-boilerplate.js.org/docs/adding-dependencies/#module-structure', )} `); process.exit(1); } } catch (e) { console.log('Native dependencies could not be checked'); } } ================================================ FILE: .erb/scripts/check-node-env.js ================================================ import chalk from 'chalk'; export default function checkNodeEnv(expectedEnv) { if (!expectedEnv) { throw new Error('"expectedEnv" not set'); } if (process.env.NODE_ENV !== expectedEnv) { console.log( chalk.whiteBright.bgRed.bold( `"process.env.NODE_ENV" must be "${expectedEnv}" to use this webpack config`, ), ); process.exit(2); } } ================================================ FILE: .erb/scripts/check-port-in-use.js ================================================ import chalk from 'chalk'; import detectPort from 'detect-port'; const port = process.env.PORT || '1212'; detectPort(port, (_err, availablePort) => { if (port !== String(availablePort)) { throw new Error( chalk.whiteBright.bgRed.bold( `Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start`, ), ); } else { process.exit(0); } }); ================================================ FILE: .erb/scripts/clean.js ================================================ import { rimrafSync } from 'rimraf'; import fs from 'fs'; import webpackPaths from '../configs/webpack.paths'; const foldersToRemove = [ webpackPaths.distPath, webpackPaths.buildPath, webpackPaths.dllPath, ]; foldersToRemove.forEach((folder) => { if (fs.existsSync(folder)) rimrafSync(folder); }); ================================================ FILE: .erb/scripts/delete-source-maps.js ================================================ import fs from 'fs'; import path from 'path'; import { rimrafSync } from 'rimraf'; import webpackPaths from '../configs/webpack.paths'; export default function deleteSourceMaps() { if (fs.existsSync(webpackPaths.distMainPath)) rimrafSync(path.join(webpackPaths.distMainPath, '*.js.map'), { glob: true, }); if (fs.existsSync(webpackPaths.distRendererPath)) rimrafSync(path.join(webpackPaths.distRendererPath, '*.js.map'), { glob: true, }); } ================================================ FILE: .erb/scripts/electron-rebuild.js ================================================ import { execSync } from 'child_process'; import fs from 'fs'; import { dependencies } from '../../release/app/package.json'; import webpackPaths from '../configs/webpack.paths'; if ( Object.keys(dependencies || {}).length > 0 && fs.existsSync(webpackPaths.appNodeModulesPath) ) { const electronRebuildCmd = '../../node_modules/.bin/electron-rebuild --force --types prod,dev,optional --module-dir . -v 38.1.2'; const cmd = process.platform === 'win32' ? electronRebuildCmd.replace(/\//g, '\\') : electronRebuildCmd; execSync(cmd, { cwd: webpackPaths.appPath, stdio: 'inherit', }); } ================================================ FILE: .erb/scripts/link-modules.ts ================================================ import fs from 'fs'; import webpackPaths from '../configs/webpack.paths'; const { srcNodeModulesPath, appNodeModulesPath, erbNodeModulesPath } = webpackPaths; if (fs.existsSync(appNodeModulesPath)) { if (!fs.existsSync(srcNodeModulesPath)) { fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction'); } if (!fs.existsSync(erbNodeModulesPath)) { fs.symlinkSync(appNodeModulesPath, erbNodeModulesPath, 'junction'); } } ================================================ FILE: .erb/scripts/notarize.js ================================================ const { notarize } = require('@electron/notarize'); const { build } = require('../../package.json'); exports.default = async function notarizeMacos(context) { const { electronPlatformName, appOutDir } = context; if (electronPlatformName !== 'darwin') { return; } if (process.env.CI !== 'true') { console.warn('Skipping notarizing step. Packaging is not running in CI'); return; } if ( !( 'APPLE_ID' in process.env && 'APPLE_ID_PASS' in process.env && 'APPLE_TEAM_ID' in process.env ) ) { console.warn( 'Skipping notarizing step. APPLE_ID, APPLE_ID_PASS, and APPLE_TEAM_ID env variables must be set', ); return; } const appName = context.packager.appInfo.productFilename; await notarize({ tool: 'notarytool', appBundleId: build.appId, appPath: `${appOutDir}/${appName}.app`, appleId: process.env.APPLE_ID, appleIdPassword: process.env.APPLE_ID_PASS, teamId: process.env.APPLE_TEAM_ID, }); }; ================================================ FILE: .eslintrc.js ================================================ module.exports = { extends: 'erb', rules: { // A temporary hack related to IDE not resolving correct package.json 'import/no-extraneous-dependencies': 'off', 'import/no-unresolved': 'error', // Since React 17 and typescript 4.1 you can safely disable the rule 'react/react-in-jsx-scope': 'off', 'no-underscore-dangle': 'off', 'no-plusplus': 'off', 'no-console': 'off', 'prettier/prettier': [ 'error', { endOfLine: 'auto', // stops prettier complaining about windows line-endings }, ], 'react/jsx-props-no-spreading': 'off', }, parserOptions: { ecmaVersion: 2020, sourceType: 'module', project: './tsconfig.json', tsconfigRootDir: __dirname, createDefaultProgram: true, }, settings: { 'import/resolver': { // See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below node: {}, webpack: { config: require.resolve('./.erb/configs/webpack.config.eslint.ts'), }, typescript: {}, }, 'import/parsers': { '@typescript-eslint/parser': ['.ts', '.tsx'], }, }, }; ================================================ FILE: .gitattributes ================================================ * text eol=lf *.exe binary *.png binary *.jpg binary *.jpeg binary *.ico binary *.icns binary *.eot binary *.otf binary *.ttf binary *.woff binary *.woff2 binary ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/node.js.yml ================================================ # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs name: Warcraft Recorder CI on: push: branches: [ "main" ] pull_request: branches: [ "main" ] jobs: build: runs-on: windows-latest steps: - uses: actions/checkout@v4 - name: Setup Node JS uses: actions/setup-node@v4 with: node-version: '22.12.0' cache: 'npm' - name: Install deps run: npm install # https://github.com/aza547/wow-recorder/issues/737 # - name: Run unit tests # run: npm test ================================================ FILE: .gitignore ================================================ # Logs *.log # Runtime data pids *.pid *.seed # Coverage directory used by tools like istanbul coverage .eslintcache # Dependency directory # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git node_modules # OSX .DS_Store release/app/dist release/build .erb/dll .idea npm-debug.log.* *.css.d.ts *.sass.d.ts *.scss.d.ts # OBS Stuff src/main/osn-data/* # Python crap *cpython* *__pycache__* # Cloud storage dirs upload buffer # Some stuff OBS window capture creates at runtime? win-capture/ ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["dbaeumer.vscode-eslint", "EditorConfig.EditorConfig"] } ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { "name": "Electron: Main", "type": "node", "request": "launch", "protocol": "inspector", "runtimeExecutable": "npm", "runtimeArgs": [ "run start:main --inspect=5858 --remote-debugging-port=9223" ], "preLaunchTask": "Start Webpack Dev" }, { "name": "Electron: Renderer", "type": "chrome", "request": "attach", "port": 9223, "webRoot": "${workspaceFolder}", "timeout": 15000 } ], "compounds": [ { "name": "Electron: All", "configurations": ["Electron: Main", "Electron: Renderer"] } ] } ================================================ FILE: .vscode/settings.json ================================================ { "files.associations": { ".eslintrc": "jsonc", ".prettierrc": "jsonc", ".eslintignore": "ignore" }, "eslint.validate": [ "javascript", "javascriptreact", "html", "typescriptreact", "typescript" ], "javascript.validate.enable": false, "javascript.format.enable": false, "typescript.format.enable": false, "search.exclude": { ".git": true, ".eslintcache": true, ".erb/dll": true, "release/{build,app/dist}": true, "node_modules": true, "npm-debug.log.*": true, "test/**/__snapshots__": true, "package-lock.json": true, "*.{css,sass,scss}.d.ts": true } } ================================================ FILE: .vscode/tasks.json ================================================ { "version": "2.0.0", "tasks": [ { "type": "npm", "label": "Start Webpack Dev", "script": "start:renderer", "options": { "cwd": "${workspaceFolder}" }, "isBackground": true, "problemMatcher": { "owner": "custom", "pattern": { "regexp": "____________" }, "background": { "activeOnStart": true, "beginsPattern": "Compiling\\.\\.\\.$", "endsPattern": "(Compiled successfully|Failed to compile)\\.$" } } } ] } ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased ### Changed ### Added ### Fixed ## [7.6.3] - 2026-04-13 ### Fixed - Fix the bulk download button. ## [7.6.2] - 2026-04-12 ### Added - [Issue 695](https://github.com/aza547/wow-recorder/issues/695) - Warn users when Advanced Combat Logging is disabled in WoW settings. ### Fixed - Fix a bug introduced in the last release where deleting videos manually didn't actually delete the file. - Update Algeth'ar Academy dungeon timer. ## [7.6.1] - 2026-04-10 ### Changed - Restyled the tag dialog box to handle multiline descriptions and be scrollable. ### Added - [Issue 805](https://github.com/aza547/wow-recorder/issues/805) - Automatically generate YouTube compatible timeline for multiview kill videos. ### Fixed - Fix the viewpoints counter being cut off if the player name is too long. - Upload rendered kill videos if config specifies clip upload. - Fix an issue where the default storage path could interacted poorly with OneDrive. - Add some missing map IDs causing files to be named "Unknown Dungeon". ## [7.6.0] - 2026-03-14 ### Added - [Issue 777](https://github.com/aza547/wow-recorder/issues/777) - Update M+ timers for Midnight S1. - Cloud videos can now be directly clipped. - Adds the kill video creator feature. ## [7.5.2] - 2026-02-26 ### Added - [Issue 797](https://github.com/aza547/wow-recorder/issues/797) - Add the new devourer spec. ### Fixed - Validate log path shows false by default when it's meant to show true. - Avoid shipping a duplicate copy of ffmpeg which reduces app size by about 60MB. ## [7.5.1] - 2026-01-21 ### Fixed - Update the parsing of COMBATANT_INFO events which changed in Midnight prepatch. ## [7.5.0] - 2026-01-18 ### Added - A switch to disable strict log path validation. - Manual recording start/stop buttons on the side menu as an alternative to hotkeys. - [Issue 788](https://github.com/aza547/wow-recorder/issues/788) - Added the app version to the video metadata and an indicator in the frontend. - [Issue 754](https://github.com/aza547/wow-recorder/issues/754) - Add a timer for the currently recording activity. ### Fixed - [Issue 789](https://github.com/aza547/wow-recorder/issues/789) - Fixes a bug where manual recordings could be interrupted by combat log events. - Fix a regression where the force stop button did nothing. ## [7.4.0] - 2026-01-08 ### Added - [Issue 769](https://github.com/aza547/wow-recorder/issues/769) - Users with delete permissions can now delete chat messages. - [Issue 783](https://github.com/aza547/wow-recorder/issues/783) - Search for common WoW locations on initial install to auto-configure log paths. - [Issue 783](https://github.com/aza547/wow-recorder/issues/783) - Default storage folder to a sensible location on first time installation. ### Fixed - [Issue 733](https://github.com/aza547/wow-recorder/issues/733) - Fix row highlighting when sorted. - [Issue 731](https://github.com/aza547/wow-recorder/issues/731) - Fix an issue where quickly switching between content types could fail to record. - [Issue 782](https://github.com/aza547/wow-recorder/issues/782) - Make video buttons more friendly in the presence of cloud/disk storage. ## [7.3.0] - 2025-12-07 ### Added - [Issue 777](https://github.com/aza547/wow-recorder/issues/777) - Adds Midnight M+, raids and new arena. - [Issue 769](https://github.com/aza547/wow-recorder/issues/769) - Enables chat for Clips and fixes for Manual categories. ## [7.2.1] - 2025-11-29 ### Changed - [Issue 771](https://github.com/aza547/wow-recorder/issues/771) - Record in MKV as an intermediate format for better error handle characteristics. - [Issue 775](https://github.com/aza547/wow-recorder/issues/755) - Selected player is now sticky when changing between rows. ### Added - [PR 768](https://github.com/aza547/wow-recorder/pull/768) - Added config for setting classic PTR. - [Issue 776](https://github.com/aza547/wow-recorder/issues/776) - Patreon icon in bottom left. ### Fixed - Improve frontend render performance on upload/download progress re-renders. - Fix an issue where the temporary recording folder would only get cleared out on restarts of the app. - [Issue 775](https://github.com/aza547/wow-recorder/issues/775) - Fixes a bug where phyiscally changing from an Nvidia to an AMD GPU or vice versa could cause the app to crash. ## [7.2.0] - 2025-11-12 ### Changed - Moved the viewpoints selection into an expandable drawer. ### Added - [PR 764](https://github.com/aza547/wow-recorder/pull/764) - Adds the video chat feature for Pro users. - Classic PTR "support". The app will accept Classic PTR paths. As usual, PTR support is best effort. ### Fixed - Take the latest rust-ps executable. - Make the debounce timer on inputting cloud login details longer so it's less annoying. - Fix the style of the scrollbar on the filter selection dropdown. - [Issue 765](https://github.com/aza547/wow-recorder/issues/765) - Allow number settings fields to be temporarily empty while changing them. ## [7.1.1] - 2025-10-31 ### Fixed - [Issue 760](https://github.com/aza547/wow-recorder/issues/760) - Guard against invalid monitor indexes. - Add the `-movflags +faststart` ffmpeg flags to the final video cut to improve Cloud load speeds. - Fix some Korean translations that were accidentally in Chinese. - [Issue 759](https://github.com/aza547/wow-recorder/issues/759) - Fix issue where sleeping windows could trigger a "failed to force stop" error. ## [7.1.0] - 2025-10-26 ### Added - [Issue 719](https://github.com/aza547/wow-recorder/issues/719) - Add the ability to bulk upload/download videos. ### Fixed - [Issue 748](https://github.com/aza547/wow-recorder/issues/748) - Fix an issue where the auto-installer would fail to close the application. - [Issue 728](https://github.com/aza547/wow-recorder/issues/728) - Fix an issue where WCR could fail to load videos from disk if many thousands of videos are present. - Fix an issue where WCR wouldn't launch for some users with modified PATH environment variables. - Better adhere to Electron security best practices, videos are now served from disk in a more secure manner via a custom protocol. ## [7.0.3] - 2025-10-11 ### Fixed - Fixes some translations and phrases in the audio settings. - [Issue 746](https://github.com/aza547/wow-recorder/issues/746) - Rewords the "Reset Game" button to "Auto-Fit Game". - [Issue 745](https://github.com/aza547/wow-recorder/issues/745) - Fix PTT/Manual hotkeys being overly permissive. - [Issue 747](https://github.com/aza547/wow-recorder/issues/747) - Better client logout handling on disabled/deleted guild. ## [7.0.2] - 2025-10-01 ### Added - Log electron GPU info on startup. - Log error messages from OBS signals if available. ### Fixed - [Issue 740](https://github.com/aza547/wow-recorder/issues/740) - Fix an issue where HDR displays would show a black screen on both the preview and recording. - [Issue 743](https://github.com/aza547/wow-recorder/issues/743) - Fix a bug where the encoder could auto-default to an invalid selection if recording resolutions higher than 4000. - [Issue 744](https://github.com/aza547/wow-recorder/issues/744) - Fix a bug where the game source would not move if the overlay source was snapped. - Fix a bug where a previous clip could be re-used if OBS hit a recording error. ## [7.0.1] - 2025-09-27 ### Fixed - Fix a bug where opening the audio settings page could crash the app. ## [7.0.0] - 2025-09-27 ### Changed - [Issue 697](https://github.com/aza547/wow-recorder/issues/697) & [PR 702](https://github.com/aza547/wow-recorder/pull/702) - Remove the dependency on OSN, OBS bindings are now provided by [noobs](https://github.com/aza547/noobs). - Buffer recordings are now entirely in memory, removing a timing window that could cause videos to be wrongly cut also reducing disk wear and hopefully user confusion. - Rework the audio sources page to be easier to understand. - The `jim_nvenc` and `jim_nvenc_av1` encoders have been replaced by their modern counterparts. Migration is automatic. - Pro config is now separated from the base config and won't interrupt recording if it goes wrong (e.g. your guild is deleted). ### Added - Add the ability to manually record via a hotkey configurable in the settings. - Add the ability to drag and scale video sources on the scene, as well as various other options. - Split out cloud config from regular config and add an appropriate status card. Cloud settings can be reconfigured while recording. - Add the ability to force the video sources to SDR. - Volume can now be configured on a per-source basis. - [Issue 632](https://github.com/aza547/wow-recorder/issues/632) - Add AV1 support for AMD: there has been no testing of this, please share your experience in Discord! - Add support for QSV (H264 and AV1) encoding. - Add a button to refresh the list of guilds on demand. - On first time setup, automatically set the default the encoder to a sensible default (i.e. pick a hardware encoder). - Add a button to set the encoder to a sensible default (same as above, but on demand rather than on first time install). ### Fixed - [Issue 677](https://github.com/aza547/wow-recorder/issues/677) - Resolve issues with select menus hidden behind scene preview. - [Issue 640](https://github.com/aza547/wow-recorder/issues/640) - Remove a hack relying on moving the preview off-screen when not on display. - Fix HOA timer. - Hide the scene preview while select menus are open, so they do not overlap. - Automatically handle the changing of an audio device ID, if the device is available with a different ID. - OBS logs now live in the same folder as the WCR logs so should be more obvious. - OBS logs now have correct timestamps, rather than starting at 00:00:00. - ## [6.15.6] - 2025-08-28 ### Fixed - [Issue 725](https://github.com/aza547/wow-recorder/issues/725) - Remove the faders. This is to address crashes reported by several users. This function will come back using the libobs volume setting in 7.0.0 but setting source volume is for now unsupported. Stability is priority. ## [6.15.5] - 2025-08-25 ### Fixed - Fix Streets of Tazavesh timer again. - [Issue 720](https://github.com/aza547/wow-recorder/issues/720) - Fix record challenge mode toggle. - [Issue 716](https://github.com/aza547/wow-recorder/issues/716) - Don't allow "#" in storage path. - [Issue 703](https://github.com/aza547/wow-recorder/issues/703) - Allow "`" as the PTT key. - [Issue 639](https://github.com/aza547/wow-recorder/issues/639) - Fix key handling when drawing mode is enabled. ## [6.15.4] - 2025-08-16 ### Fixed - [Issue 717](https://github.com/aza547/wow-recorder/issues/717) - Update to OSN `0.25.47` to fix game capture mode. - Fixed some key timers. - Fixed a fader release log. ## [6.15.3] - 2025-08-10 ### Added - [PR 713](https://github.com/aza547/wow-recorder/pull/713) - Add switch to Enable/Disable recording of MoP Classic challenge modes. ### Fixed - [Issue 708](https://github.com/aza547/wow-recorder/issues/708) - Fix an issue where force stopping classic arena would give the wrong result and category. - Update timers for TWW season 3 Mythic+. ## [6.15.1] - 2025-08-01 ### Fixed - Add some missing MOP classic arenas. ## [6.15.0] - 2025-07-31 ### Changed - Track the region as part of the combatant now the combat log includes it. ### Added - [PR 692](https://github.com/aza547/wow-recorder/pull/692/files) - Adds Configurable Push-To-Talk Release Delay. - Add MOP challenge modes. ### Fixed - Make wipes overrun a few seconds longerto avoid missing anything. - [Issue 668](https://github.com/aza547/wow-recorder/issues/668) - Fixes the zoom in hotkey. - Make the test raid button work even when "record current raid only" is selected. - Fix an issue where rescaling would not trigger on changing the canvas size. ## [6.14.2] - 2025-06-29 ### Changed - Don't apply current retail encounter filtering to PTR. - Update OSN to 0.25.34. ### Added - [Issue 689](https://github.com/aza547/wow-recorder/issues/689) - Add TWW Season 3 new dungeon. ### Fixed - Fix a bug where the video source wouldn't rescale correctly after a reconfigure sometimes. ## [6.14.1] - 2025-06-15 ### Changed - [Issue 660](https://github.com/aza547/wow-recorder/issues/660) - Re-enable labels on video progress / volume sliders. ### Fixed - [Issue 650](https://github.com/aza547/wow-recorder/issues/650) - Fix an issue where outside players could appear in the Mythic+ roster. - [Issue 687](https://github.com/aza547/wow-recorder/issues/687) - Fix an issue where the sorting of some columns was broken. - [Issue 686](https://github.com/aza547/wow-recorder/issues/686) - Fix an issue where some keyboard interactions with the video selection table were broken when sorted. - [Issue 684](https://github.com/aza547/wow-recorder/issues/684) - Fix an issue where Solo Shuffle videos could be grouped in the UI in error. - [Issue 585](https://github.com/aza547/wow-recorder/issues/585) - Fix some missing translations. ## [6.14.0] - 2025-06-07 ### Changed - [Issue 519](https://github.com/aza547/wow-recorder/issues/519), [PR 676](https://github.com/aza547/wow-recorder/pull/676) - Use OBS's force stop functionality where we don't need a video file. - Some style improvements to the video selection table. ### Added - [Issue 678](https://github.com/aza547/wow-recorder/issues/678) - Add the ability to record/upload current the current raid tier encounters only. - Added an indicator to show how many videos are queued for upload download in addition to those currently in progress. ### Fixed - [Issue 594](https://github.com/aza547/wow-recorder/issues/594) - Move the tag button to a more sensible place. ## [6.13.3] - 2025-06-01 ### Added - Add a "hide empty categories" option. - [Issue 673](https://github.com/aza547/wow-recorder/issues/673) - Improve the delete dialog to allow individual videos to be deleted. - Add the ability to disable hardware acceleration in the app. ### Fixed - Stop re-encoding the audio on cutting video. - [Issue 659](https://github.com/aza547/wow-recorder/issues/650) - Fix a bug where M+ without a boss pull would not record. - [Issue 671](https://github.com/aza547/wow-recorder/issues/671) - Fix a benign error popping up on installing visual C++ libs. - [Issue 672](https://github.com/aza547/wow-recorder/issues/672) - Fix a JavaScript error that could appear on quitting. ## [6.13.2] - 2025-05-22 ### Added - Add audio source bars to the audio configuration panel. ### Fixed - [Issue 663](https://github.com/aza547/wow-recorder/issues/663) - Fix a bug with ghost audio devices not being deselectable. - Rescale video to fit scene on source callback from OBS rather than a timer. - Fix a bug where the process audio slider did not apply correctly. ## [6.13.1] - 2025-05-16 ### Changed - Change the polling mechanism to be websocket based, client refreshes are quicker and more efficient on the server side. - Add a small amount of overrun to raid wipes to alleviate any rounding of the duration cutting into the pull. ## [6.13.0] - 2025-05-11 ### Added - [Issue 610](https://github.com/aza547/wow-recorder/issues/610) - Adds audio source configuration on a per-app basis. ### Fixed - Fix an issue where 0% wipes were shown as 100% wipes. - Fix an issue where Gallywix would show as 0% wipe on Mythic if wiping before the shield is broken. ## [6.12.2] - 2025-05-08 ### Changed - [Issue 654](https://github.com/aza547/wow-recorder/issues/654) - Make the colors for the boss %age HP more natural. ### Fixed - [Issue 651](https://github.com/aza547/wow-recorder/issues/651) - Fixed an issue where deleting wouldn't update the GUI. ## [6.12.1] - 2025-04-28 ### Fixed - Fix an issue where double clicking the storage filter would set undefined. - [Issue 469](https://github.com/aza547/wow-recorder/issues/649) - Fix an issue where the storage filter could reset to the "no videos" page. - Fix an issue where video would randomly resize after changing tab. ## [6.12.0] - 2025-04-27 ### Added - [Issue 616](https://github.com/aza547/wow-recorder/issues/616) - Add an upload toggle for Retail and Classic recordings. - Add a Storage filter toggle, remove the search tags for cloud/disk and the override switch for bypassing cloud videos. ### Fixed - [Issue 642](https://github.com/aza547/wow-recorder/issues/642) - Check daily for updates not just on startup. - Fixed an issue where 0% wipes would not show the %. - Fix an issue where the Log path validation would accept a folder of the same level (e.g Interface). - [Issue 646](https://github.com/aza547/wow-recorder/issues/646) - Fix an issue with the table page resetting on interacting with the videos. - Fix an issue where arrow keys would not have consistent behaviour when video progress or volume slider was focused. - [Issue 643](https://github.com/aza547/wow-recorder/issues/643) - Add missing unlocked filter and fix some icons. - [Issue 602](https://github.com/aza547/wow-recorder/issues/602) - Reset the video player size on a resize if it's bigger than the window height. ## [6.11.1] - 2025-04-21 ### Fixed - Fix an issue where the scene preview would be misplaced when using Windows scaling. ## [6.11.0] - 2025-04-20 ### Changed - Shortlinks are now permanent, so reflect that in the UI. ### Added - Add a drawing mode to allow annotation of the video player with Excalidraw. - [Issue 621](https://github.com/aza547/wow-recorder/issues/621) - Add a delete permission tier and update client to respect it. ### Fixed - The date range filter now translates correctly. - Allow key events to propogate while video progress slider is focused. - [Issue 628](https://github.com/aza547/wow-recorder/issues/628) - Fixes a bug where downloading a video wasn't possible if cloud upload was disabled in the config. - [Issue 609](https://github.com/aza547/wow-recorder/issues/609) - Fixes a bug where numeric fields in settings would accept text and display NaN. - [Issue 622](https://github.com/aza547/wow-recorder/issues/622) - Fix a bug where it was not possible to deselect the default audio devices. - [Issue 625](https://github.com/aza547/wow-recorder/issues/625) - Fix a bug where audio devices would get deselected if they were unrecognized. - [Issue 638](https://github.com/aza547/wow-recorder/issues/638) - Fix a bug where zooming (CTRL SHIFT + / CTRL -) would mess with the scene preview. ## [6.10.0] - 2025-04-12 ### Changed - Restyle the video table, removing the expandable rows in preference for a more minimal design. - Delete, tag, and protect/star/lock buttons now apply to the row and not the viewpoint. - Add the name "Warcraft Recorder" and app icon to the title bar. - Upgrade obs-studio-node to 0.25.17 (and OBS to 30.2.4). ### Added - Added all combatants back to search filter. - Show boss percentages on wipes. - Catch render errors and display a refresh button for the user to recover. - Add a date filter, and range picker. ### Fixed - Bump packages, including electron, to pick up latest tzdata. - [Issue 624](https://github.com/aza547/wow-recorder/issues/624) - Fixes an issue where manually deleting log files could cause recording to stop. - [Issue 606](https://github.com/aza547/wow-recorder/issues/606) - Fixes an issue where bulk delete could cause the app to go blank. - Add the cage of carnage arena. ## [6.9.3] - 2025-03-24 ### Fixed - Fix an issue introduced in 6.8.0 and then again in 6.9.2 where audio could be desynced on video seeking. - Fix an issue where bulk deletes could fail due to exceeding the database rate limit. - Fix an issue where AV1 encoded long videos (Mythic+) could fail to cut due to ffprobe outputting >1MB of stdout. ## [6.9.2] - 2025-03-21 ### Fixed - Fix an issue where clipping videos could fail when clipping from the start of a video. - Fix an issue where MP4 files were technically not valid (despite still playing), preventing raw video embeds in Discord and flikering playback in VLC. - Reduce the time to cut videos by reverting to copying the audio stream, and adding more frequent keyframes. - [Issue 661](https://github.com/aza547/wow-recorder/issues/611) - Updates for some M+ timers that changed. ## [6.9.1] - 2025-03-16 ### Fixed - [Issue 605](https://github.com/aza547/wow-recorder/issues/605) - Fix an issue where CTRL + Shift modifiers weren't being releases properly sometimes. - Fix some more M+ timers. ## [6.9.0] - 2025-03-16 ### Changed - Collapse the search bar dropdown on picking something. ### Added - [Issue 380](https://github.com/aza547/wow-recorder/issues/380) - Add support for the Nvidia and AMD AV1 encoders. ### Fixed - [Issue 608](https://github.com/aza547/wow-recorder/issues/608) - Fix some M+ timers. ## [6.8.2] - 2025-03-07 ### Changed - Clipping mode now assumes a more sensible clip size. - Clipping mode labels now don't overlap so poorly. - Show a more appropriate cloud icon if there isn't a cloud video to view. - Stop CI builds (but still run the tests), code signing means the build needs to be local to the token. ### Added - Add the 5760x1080 resolution. - Fix a bunch of S2 Mythic+ dungeon timers. - EV code signing! ### Fixed - [Issue 588](https://github.com/aza547/wow-recorder/issues/588) - Fixes an issue where the client was prone to locking accounts on password change. ## [6.8.1] - 2025-02-23 ### Added - App updates are now automatic, but will still prompt the user before installing. ### Fixed - Fix an issue introduced in 6.8.0 where audio could be desynced on video seeking. - Do better validation of the retail PTR log path. - Fix a bug where PTR recordings were cut short. ## [6.8.0] - 2025-02-21 ### Added - [Issue 593](https://github.com/aza547/wow-recorder/issues/593) - Allow use of arrow keys, enter, backspace to control search tags. - Add the Wintergrasp zone ID. - Add the ability to use ctrl / shift modifiers to select videos in the video table. - [PR 597](https://github.com/aza547/wow-recorder/pull/597) - Add the ability to do multipov playback. - [PR 598](https://github.com/aza547/wow-recorder/pull/598) - Resolve an issue where the start time of videos could vary by a few seconds due to keyframe snapping. ## [6.7.0] - 2025-02-02 ### Changed - Performance and style improvements for the video player. ### Added - [Issue 590](https://github.com/aza547/wow-recorder/issues/590) - Add support for TWW S2 Mythic+ dungeons. - [Issue 591](https://github.com/aza547/wow-recorder/issues/591) - Add a "clips upload" toggle in the settings. - [Issue 583](https://github.com/aza547/wow-recorder/issues/583) - Add a PTR recording settings, supported on a best-effort basis. ### Fixed - [Issue 586](https://github.com/aza547/wow-recorder/issues/586) - Fix a bug where we would spam logs with rescaling information. - Fix a longstanding issue where the status tooltip could overlap the recording preview. - Allow the default audio device to be selected, and default to it. - Fix a bug with Game and Window capture modes not working for Beta or PTR clients. ## [6.6.0] - 2025-01-27 ### Changed - Revamp the search bar to provide a nicer experience. ### Added - Add the ability to skip individual frames with the "." or the "," hot keys. - Add some extra details to the video selection table showing if rows contain videos that are starred or tagged. ### Fixed - [Issue 587](https://github.com/aza547/wow-recorder/issues/587) - Fixes issues with the video player covering title bar. ## [6.5.1] - 2025-01-05 ### Fixed - Fix some missing translations. - Fix a bug with Window capture mode not correctly setting up the scene. - [Issue 579](https://github.com/aza547/wow-recorder/issues/579) - Fix a bug where clipped videos were counting towards pull count. ## [6.5.0] - 2025-01-03 ### Changed - Use new API endpoint. - Replace the guild text field with a dropdown. ### Added - Simplified Chinese language support. ### Fixed - Tidy up some borders in the video table. - Let video category counters go higher than 999. - [Issue 571](https://github.com/aza547/wow-recorder/issues/571) - Fix non-english client game/window capture detection. ## [6.4.1] - 2024-12-20 ### Added - German language support. ### Fixed - Fix a bug where you couldn't configure a custom chat overlay. - Add pagnation to video lists to avoid performance problems with a large number of videos. ## [6.4.0] - 2024-12-19 ### Changed - [Issue 559](https://github.com/aza547/wow-recorder/issues/559) - Don't group clips in the UI, it's confusing. - Fix an issue where Challenger's Peril was being accounted for twice. ### Added - [Issue 566](https://github.com/aza547/wow-recorder/issues/566) - Add localisation support. - [Issue 566](https://github.com/aza547/wow-recorder/issues/566) - Add Korean translations. ### Fixed - [Issue 572](https://github.com/aza547/wow-recorder/issues/572) - Fix upload bug. - Stop upload/download trackers flickering on 0%. ## [6.3.0] - 2024-12-12 ### Changed - Improve responsiveness of viewpoint switching, and retain playing state. - Update Electron from version 24.4.0 to 33.2.0, and other related packages. ### Added - Add difficulty IDs for Classic Era Phase 6 raids. ## [6.2.1] - 2024-12-08 ### Fixed - Fix a bug where Classic Era was not recording. - Fix a bug where the status card would not update after a game mode config change. - Fix a bug where Challenger's Peril wasn't being accounted for in M+ dungeon timers. - Fix a bug where bulk deleting videos wouldn't delete disk based videos. - Fix a bug where bulk deleting videos when not a Pro user would crash the app. ## [6.2.0] - 2024-12-06 ### Added - [Issue 488](https://github.com/aza547/wow-recorder/issues/488) - Added a bulk delete option. ### Fixed - [Issue 558](https://github.com/aza547/wow-recorder/issues/558) - Fix a bug where it was possible to set your storage path and buffer path to the same thing. - [Issue 558](https://github.com/aza547/wow-recorder/issues/558) - Fix a bug where we would rescale the scene too often under some conditions. ## [6.1.0] - 2024-11-30 ### Added - Redesign the video selection panel to be more performant and useful. - [Issue 538](https://github.com/aza547/wow-recorder/issues/538) - Pro users can now use a gif as their custom chat overlay. ### Fixed - Fix an issue where the upload/download icons would flicker. - Relax pull grouping timer as apparently Windows does a bad job of automatically keeping you in sync with an NTP server. - [Issue 550](https://github.com/aza547/wow-recorder/issues/550) - Add the 90s Challenger's Peril correction to M+ chest calculation. - Fix a bug where deleted videos were sometimes not correctly deleted. - Fix an issue where the CMAA 2 setting in WoW could cause blurry video. ## [6.0.4] - 2024-10-27 ### Fixed - [Issue 544](https://github.com/aza547/wow-recorder/issues/544) - Fix the "cannot be closed on upgrade" bug. - Fix some M+ timers in TWW S1. - Add raid IDs for MC / BWL / ZG / Onyxia Era raids. ## [6.0.3] - 2024-10-12 ### Changed - Relax the timer we expect OBS to stop in from 30s to 60s. ### Added - Add cloud / disk as search filters. - [Issue 537](https://github.com/aza547/wow-recorder/issues/537) - Space bar can now be used to start/stop the video player. - [Issue 522](https://github.com/aza547/wow-recorder/issues/522) - Adds chinese client support for Window and Game capture modes. ### Fixed - [Issue 542](https://github.com/aza547/wow-recorder/issues/542) - Fix a bug with the Devour M+ affix not displaying an image. - [Issue 539](https://github.com/aza547/wow-recorder/issues/539) - Fix a bug where Mythic+ result search terms were misrepresented by the search bar. - [Issue 540](https://github.com/aza547/wow-recorder/issues/540) - Clips are now timestamped at the point of clipping and sorted accordingly, rather than inheriting from their parent video. - [Issue 543](https://github.com/aza547/wow-recorder/issues/543) - Fix a bug where the raid encounter pull counter could be wrong. - [Issue 457](https://github.com/aza547/wow-recorder/issues/457) - Stop keyboard media keys playing/pausing the video when the app is minimized. - [Issue 533](https://github.com/aza547/wow-recorder/issues/533) - Fix a bug where Challenger's Peril wasn't included in the keystone uprade level calculation. - [Issue 535](https://github.com/aza547/wow-recorder/issues/535) - Fix a bug where using multiple audio devices could cause audio to sound terrible. - Fix an issue where the scene could end up wrongly scaled after multiple settings changes. - A bug with the 3440x1200 resolution. ## [6.0.2] - 2024-09-21 ### Added - [Issue 526](https://github.com/aza547/wow-recorder/issues/526) - Added the 3360x1440 resolution option. ### Fixed - [Issue 518](https://github.com/aza547/wow-recorder/issues/518) - Minor UI issues. - [Issue 529](https://github.com/aza547/wow-recorder/issues/529) - Raid upload difficulty threshold is no longer ignored. - [Issue 531](https://github.com/aza547/wow-recorder/issues/531) - Selecting a monitor and unplugging it would break the app. - Improve OBS signal handling to be more robust to timeouts. - Update TWW season 1 M+ timers. ## [6.0.1] - 2024-09-02 ### Fixed - [Issue 521](https://github.com/aza547/wow-recorder/issues/521) - Missing support for the new Deephaul Ravine battleground. ## [6.0.0] - 2024-08-27 ### Changed - [PR 517](https://github.com/aza547/wow-recorder/pull/517) - Major rework of the UI, big thanks to Stephix for contributing this. - Upgrade OBS to 29 (and OSN to 0.24.43). ### Fixed - [Issue 512](https://github.com/aza547/wow-recorder/issues/512) - Fix a bug where the manager would repeatedly retry configuration if the user got the password wrong. - Fixed an issue where you could not download a video if the cloud upload setting was disabled. - Fix a bug where downloading the same video twice in a row would fail. - Improve simultaenous death handling when applying video timeline marks. ## [5.7.2] - 2024-08-04 ### Fixed - The cloud account name field is now labelled user / email. - [Issue 510](https://github.com/aza547/wow-recorder/issues/510) - Refocus the main window if the users tries to launch the app again. ## [5.7.1] - 2024-08-04 ### Fixed - [Issue 504](https://github.com/aza547/wow-recorder/issues/504) - Reattempt to configure on network failures. - Another attempt to fix an issue where the rust-ps binary was getting flagged by anti-virus. ## [5.7.0] - 2024-08-03 ### Changed - [Issue 508](https://github.com/aza547/wow-recorder/issues/508) - Shareable links now last up to 30 days. ### Added - Adds TWW season 1 Mythic+ dungeon support. ### Fixed - Handle TWW log timestamps which now include the year. - Attempt to fix an issue where the rust-ps binary was getting flagged by anti-virus. ## [5.6.2] - 2024-07-27 ### Fixed - Fix a regression where PTR and Beta didn't work in 5.6.1. - [Issue 506](https://github.com/aza547/wow-recorder/issues/506) - Improve disk delete behaviour. - Fix a bug where window capture did not work on TWW pre-patch as Blizzard changed the window name. - Stop writing the cloud password to logs. ## [5.6.1] - 2024-07-08 ### Changed - Make delete and delete all points of view into separate buttons for ease of use. ### Fixed - Fix a major memory leak when using the AMD hardware encoder. - Fix a slow memory leak caused by the process polling mechanism. - Fix a bug where the upload rate limit field would show when not relevant. - Fix a bug where we would sometimes fail to cut a video as the clean-up ran mid-cutting. ## [5.6.0] - 2024-06-15 ### Added - [Issue 485](https://github.com/aza547/wow-recorder/issues/485) - Added upload rate limit setting. ### Fixed - Make the chat overlay scale slider step size smaller. - Fix the scrollbar width on the scene editor. ## [5.5.0] - 2024-06-12 ### Added - [Issue 403](https://github.com/aza547/wow-recorder/issues/403) - Allow Pro users to use a custom chat overlay. - Access control to allow users to read but not write. Accompanies website changes. ### Fixed - Fix a bug where TWW beta wouldn't be accepted as a retail log path. - [Issue 227](https://github.com/aza547/wow-recorder/issues/227) - Fix a bug in classic where arena games sometimes ended too early. ## [5.4.2] - 2024-06-03 ### Changed - Make shareable links readable, and website player enhancements. ### Fixed - Fix a bug with the disk size monitor not cleaning up correctly. ## [5.4.1] - 2024-05-23 ### Changed - [Issue 502](https://github.com/aza547/wow-recorder/issues/502) - Bring back combatant search filters. ### Fixed - More M+ timer fixes. - Better detection of mage specs in classic. - Ignore the desktop.ini file when running folder checks. ## [5.4.0] - 2024-05-11 ### Fixed - Fix a bug where we would re-use the same stream on a failed upload, instead of a new one. - Fix a bug where deleting a cloud video would not trigger other clients to update. - Make the cloud settings more responsive withb a debounce timer. - [Issue 500](https://github.com/aza547/wow-recorder/issues/500) - Fix some M+ timers. - [Issue 400](https://github.com/aza547/wow-recorder/issues/400) - Prevent setting a pre-existing storage or buffer path with videos in it to avoid accidental deletions. - [Issue 499](https://github.com/aza547/wow-recorder/issues/499) - Add a button and hotkey to delete all points of view for an activity. ## [5.3.1] - 2024-05-08 ### Fixed - Improve retry handling in-case of cloud upload failure. - Fix a bug where two copies of WR were showing in Windows. - Allow classic beta to be accepted as a valid log path. ## [5.3.0] - 2024-05-05 ### Added - [Issue 498](https://github.com/aza547/wow-recorder/issues/498) - Allows more detailed configuration of what videos to upload. - Use new API auth endpoint to validate we're authenticated with the cloud. - Client-side work to match API improvements for scalability. - Fix a server side bug where long-running uploads would fail after an hour. ### Fixed - [Issue 497](https://github.com/aza547/wow-recorder/issues/497) - Fix a bug where the upload/download buttons would show up when config wasn't valid, and when the upload button could sometimes cause errors. ## [5.2.5] - 2024-05-02 ### Fixed - [Issue 494](https://github.com/aza547/wow-recorder/issues/494) - Fix a bug where some old videos don't upload correctly due to level/keystoneLevel confusion. - [Issue 495](https://github.com/aza547/wow-recorder/issues/495) - Fix a bug where old videos didn't show the start time correctly. ## [5.2.4] - 2024-04-28 ### Fixed - [Issue 483](https://github.com/aza547/wow-recorder/issues/483) - Fix a bug where pull count for raid bosses reset over midnight. - [Issue 490](https://github.com/aza547/wow-recorder/issues/490) - Fix a bug where durations over an hour would wrap. - [Issue 489](https://github.com/aza547/wow-recorder/issues/489) - Fix a bug where videos larger than 5GB failed to upload. ## [5.2.3] - 2024-04-21 ### Fixed - Fix a bug showing M+ level with old metadata versions. ## [5.2.2] - 2024-04-21 ### Changed - Video button styling. ### Fixed - Fix a status icon bug. ## [5.2.1] - 2024-04-21 ### Fixed - Various search bar fixes. - Remove app updater code, it doesn't work without a certificate. ## [5.2.0] - 2024-04-20 ### Changed - Cloud size monitor now runs server side. ### Added - Add a reconfiguring status and animation to the status icon. ### Fixed - Max cloud storage size is now variable. - Fix a bug where the wrong video would show on startup/category change with multipov. ## [5.1.3] - 2024-04-17 ### Fixed - Fix a deplete timer in DOTI. - Fix a bug where we could try dereference an undefined video. - Fix a bug where search text was retained when changing categories. ## [5.1.2] - 2024-04-16 ### Fixed - A bug where the show more button would show in error. - A bug where the cloud size monitor would delete protected videos. ## [5.1.1] - 2024-04-14 ### Fixed - Fix a bug where hitting play/pause was slightly slow to respond. ## [5.1.0] - 2024-04-14 ### Fixed - Bring back auto-updater. ## [5.0.1] - 2024-04-14 ### Fixed - Fix the cloud size monitor so it deletes from the database. - Fix to download button not working. ## [5.0.0] - 2024-04-13 ### Changed - Use D1 for storing cloud video state, instead of JSON files in R2. ### Fixed - Fix to include new SOD difficulty for Sunken Temple. ## [4.1.4] - 2024-04-12 ### Fixed - Fix pull counter for cloud videos. ## [4.1.3] - 2024-04-07 ### Fixed - Improve the responsiveness of setting reconfigures by only validating what has changed. - Make the video player resizing more responsive. - Remove deaths from unique hashing as it seems they can vary. - Cloud size monitor running in wrong direction & test for this. - Significant frontend performance improvements. ## [4.1.2] - 2024-04-04 ### Fixed - Cloud size monitor running in wrong direction & add a test for this. ## [4.1.1] - 2024-04-02 ### Fixed - Fix size monitor to not stop at 1000 keys. - Reset number of videos displayed on category change. - Fix a leaky event listener on the video button download function. - Improve some cloud logging. - Reset the videos shown on changing category. ## [4.1.0] - 2024-04-01 ### Changed - Let badges go higher than 99. ### Added - Classic era raid support. ## [4.0.9] - 2024-04-01 ### Changed - Change the test icon to be more intiuative. - Order video POVs alphabetically. - Fix a bug where we would forget the player size. ## [4.0.8] - 2024-03-31 ### Fixed - POV styling improvements. - Cloud access bug with hardcoded bucket name. - Make settings scrollbar wider. - More cloud access logging. ## [4.0.7] - 2024-03-30 ### Changed - Make the POV selection group cloud and disk videos. ## [4.0.6] - 2024-03-30 ### Fixed - Fix ripple effect on video buttons when selecting sub-buttons. - Fix button ripple effect radius. ## [4.0.5] - 2024-03-30 ### Fixed - Fix the UI button around the download spinner. - Fix some margins around the video buttons. ## [4.0.4] - 2024-03-30 ### Changed - Add some borders to UI buttons. ### Fixed - Fix a bug where special characters in character names could break some functionality. ## [4.0.3] - 2024-03-30 ### Fixed - Fix a bug where frontend resource URLs could expire and fail to load. - Improve logging for cloud function. - Fix video marker buttons not reacting correctly. ## [4.0.2] - 2024-03-29 ### Fixed - Fix a bug where deleteing a POV could cause a blank screen. ## [4.0.1] - 2024-03-29 ### Fixed - Fix a bug where uploads would buffer the entire file into memory. - Fix a bug where the progress bars maths was wonky. ## [4.0.0] - 2024-03-29 ### Changed - Change the CQP values for recording, they were too high resulting in large video files. ### Added - Add cloud storage support. ## [3.25.3] - 2024-03-05 ### Fixed - Fix a bug where we would fail to detect wow running due to fastlist missing dependencies. ## [3.25.2] - 2024-03-04 ### Fixed - [Issue 482](https://github.com/aza547/wow-recorder/issues/482) - Fix a bug where leaving a Mythic+ and re-entering would cut it wrongly. - [Issue 317](https://github.com/aza547/wow-recorder/issues/317) - Remove dependency on tasklist. - Bring back the avoid_negative_ts make_zero to the cut command. - Make the selected video more obvious. ## [3.25.1] - 2024-02-26 ### Fixed - [Issue 226](https://github.com/aza547/wow-recorder/issues/226) - Fix a bug where classic arena could be tagged with the wrong category. - [Issue 252](https://github.com/aza547/wow-recorder/issues/252) - Fix a bug where hunter's feign death would end a classic arena game early. - [Issue 481](https://github.com/aza547/wow-recorder/issues/481) - Improve classic arena spec detection. - Cut videos more accurately by dropping the no-negative-ts flag from the cut call to ffmpeg. - Show durations in the UI including overrun. - Fix the color of unidentified specs in the UI. ## [3.25.0] - 2024-02-13 ### Changed - Remove combatant specs and classes from filter queries, class and spec querys now only apply to the player. - Restyle the video delete prompt as a dialog option. ### Added - [Issue 421](https://github.com/aza547/wow-recorder/issues/421) - Tagging feature. ### Fixed - [Issue 388](https://github.com/aza547/wow-recorder/issues/388) - Pause video playback on minimize to system tray. - Prevent spellchecking on the search bar text field giving annoying squiggles. - [Issue 434](https://github.com/aza547/wow-recorder/issues/434) - Fix abandoned/deplete marking on Mythic+ dungeons. ## [3.24.0] - 2024-02-10 ### Changed - [Issue 474](https://github.com/aza547/wow-recorder/issues/474) - Use CQP/CRF encoder modes rather than VBR. - Removed support for ffmpeg_nvenc encoder, as jim_nvenc is always preferable. ### Added - [Issue 475](https://github.com/aza547/wow-recorder/issues/475) - Make overrun times for raid and dungeons configurable. ### Fixed - [Issue 478](https://github.com/aza547/wow-recorder/issues/478) - Fix an issue with config validation sometimes failing when it shouldn't. ## [3.23.2] - 2024-02-03 ### Fixed - [Issue 462](https://github.com/aza547/wow-recorder/issues/462) - Fix a bug where you could not delete the selected video. - Fix a bug where we were using the day of the week instead of day of the month in clipped file names. ## [3.23.1] - 2024-01-27 ### Changed - Improvements to the python integration test infrastructure. - Improvements to video player controls aesthetics and overlapping of sliders in clipping mode. ### Added - [Issue 470](https://github.com/aza547/wow-recorder/issues/470) - Add the 5120x2160 resolution. ### Fixed - [Issue 311](https://github.com/aza547/wow-recorder/issues/311) - Ensure there is enough disk space on application of storage config. - [Issue 477](https://github.com/aza547/wow-recorder/issues/477) - Prevent mic showing as listening when WoW is closed. - [Issue 476](https://github.com/aza547/wow-recorder/issues/476) - Fix some buttons getting shunted off the screen on small monitors. - Fix a bug where month numbers in clipped file names were off by one. ## [3.23.0] - 2023-12-30 ### Added - [Issue 463](https://github.com/aza547/wow-recorder/issues/463) - Video clipping feature, includes a rework to the video player controls and timeline markers. ### Fixed - [Issue 459](https://github.com/aza547/wow-recorder/issues/459) - Fix a bug where we didn't respect the content type settings. ## [3.22.1] - 2023-12-17 ### Added - Add the ability to drag to resize the video player without going fullscreen. ### Fixed - Fix some issues with the status icon occasionally showing the wrong status. ## [3.22.0] - 2023-12-10 ### Added - Add badges to the video buttons to show how many videos in each category. - [Issue 115](https://github.com/aza547/wow-recorder/issues/115) - Add option to suppress background microphone noise. - [Issue 448](https://github.com/aza547/wow-recorder/issues/448) - Allow naked modifier key use as the push to talk hotkey. ### Changed - Improve responsiveness on startup. ### Fixed - [Issue 458](https://github.com/aza547/wow-recorder/issues/458) - Fixed a bug where videos could be cut way shorter than intended. ## [3.21.0] - 2023-12-04 ### Changed - Make the video button more concise. - Don't include "Unknown Raid" in the video file name. ### Fixed - [Issue 455](https://github.com/aza547/wow-recorder/issues/455) - Fix the first write of the combat log being ignored. - [Issue 453](https://github.com/aza547/wow-recorder/issues/453) - Fix erroneously holding an audio device on app starting. - [Issue 454](https://github.com/aza547/wow-recorder/issues/454) - Improve error handling around OBS including automatic recovery from OBS misbehaviour. - [Issue 456](https://github.com/aza547/wow-recorder/issues/456) - Fix issue with search bar remembering query but not text. ## [3.20.2] - 2023-11-17 ### Fixed - Add missing endboss in Waycrest to avoid "undefined" showing on the video timeline. - Make text for dungeon names a bit smaller as new dungeons have long names. ## [3.20.1] - 2023-11-14 ### Fixed - Fix some bugs with Dragonflight season 3 Mythic+ dungeons. ## [3.20.0] - 2023-11-14 ### Added - [Issue 451](https://github.com/aza547/wow-recorder/issues/451) - Add Dragonflight season 3 Mythic+ dungeons. ## [3.19.4] - 2023-11-07 ### Fixed - Fix a bug where deleting a video when the first video in a category is selected breaks things. ## [3.19.3] - 2023-11-07 ### Fixed - Fix a bug where deleting a video when the last video in a category is selected breaks things. - [Issue 447](https://github.com/aza547/wow-recorder/issues/447) - Fix a bug where could end up in an error state after sleeping Windows/closing WoW. - [Issue 401](https://github.com/aza547/wow-recorder/issues/401) - Fix a bug where the selected video delete button shows but doesn't work. - Pre-emptively increase the buffer time to 15 mins in anticipation of combat log changes. ## [3.19.2] - 2023-10-23 ### Fixed - Improve rejected promise handling by removing blanket rejected promise handling. - Revert some color format changes breaking compatiblity with some platforms/players. ## [3.19.1] - 2023-10-21 ### Added - Add a CTRL override to the delete video confirmation prompt. - Add retail / classic queries to the search bar. ### Changed - Improve the look of some settings while a recording is active. ### Fixed - [Issue 446](https://github.com/aza547/wow-recorder/issues/446) - Fix to darkness in AMD encodings, and improvements for other encoders. - [Issue 443](https://github.com/aza547/wow-recorder/issues/443) - Back out initial fix and handle F5 refresh properly. - Fixed an issue with some encounters not working as search bar queries. - Hide the "Unknown Raid" text to make app maintenance easier. - Fixed issue where we tried to delete some non-existent files noisy ENOENT errors in logs. - Improve the search bar behaviour for existing queries when new videos are recorded. ## [3.19.0] - 2023-10-03 ### Added - Add the 3840 x 1200 resolution. - [Issue 125](https://github.com/aza547/wow-recorder/issues/125) - Push to talk for microphone recording. - Window capture support. ### Fixed - [Issue 443](https://github.com/aza547/wow-recorder/issues/443) - Fix to Ctrl + R breaking the app. - Fix PTR log path validation. ## [3.18.1] - 2023-09-03 ### Fixed - [Issue 440](https://github.com/aza547/wow-recorder/issues/440) - Fix a bug preventing settings being configured for the first time. ## [3.18.0] - 2023-09-03 ### Added - [Issue 425](https://github.com/aza547/wow-recorder/issues/425) - Add death counter to the video button for raids and mythic+. ### Fixed - [Issue 435](https://github.com/aza547/wow-recorder/issues/435) - Only allow the software encoder for resolutions over 4000 pixels. - [Issue 437](https://github.com/aza547/wow-recorder/issues/437) - Replace some sync logic with async logic. ## [3.17.0] - 2023-08-05 ### Added - [Issue 415](https://github.com/aza547/wow-recorder/issues/415) - Add buttons for easily toggling video markers, and other video marker improvements. ### Fixed - Fix a bug where the coloring of the video tracker bar was slightly wrong. - Fix a bug where the app didn't remember the last selected category on starting up. ## [3.16.1] - 2023-07-28 ### Fixed - Fix a bug where if no matches found with a filter query then the app would forever show the no videos message. ## [3.16.0] - 2023-07-23 ### Changed - Revamp the UI to be more intuative. ## [3.15.2] - 2023-07-16 ### Fixed - [Issue 310](https://github.com/aza547/wow-recorder/issues/310) - React to Windows sleep events better to avoid problems on Windows waking up. ### Changed - Start building and packaging with the latest version of NodeJS (20.4.0). ## [3.15.1] - 2023-07-12 ### Added - Add the augmentation spec for evokers. ## [3.15.0] - 2023-07-08 ### Added - [Issue 407](https://github.com/aza547/wow-recorder/issues/407) - Record unrecognised encounters. This will enable WR to record legacy, beta and ptr raid bosses. - [Issue 428](https://github.com/aza547/wow-recorder/issues/428) - Add a daily pull counter to raid video buttons. ### Changed - Improve home page aesthetics. - [Issue 427](https://github.com/aza547/wow-recorder/issues/427) - Include the player name in the video file name. ### Fixed - Fix category selection chip which would do nothing when used on the settings/scene editor pages. - Default max storage to zero (unlimited) instead of 200GB. ## [3.14.2] - 2023-06-24 ### Added - Add encounters for Trial of the Crusader classic. ## [3.14.1] - 2023-06-21 ### Fixed - Revert the version of OBS studio node to 0.23.71 as the upgrade cause a bug preventing the chat overlay from showing. ## [3.14.0] - 2023-06-20 ### Added - [Issue 423](https://github.com/aza547/wow-recorder/issues/423) - Add affixes and some other UI improvements. - [Issue 353](https://github.com/aza547/wow-recorder/issues/353) - Add overrun icon to the status indicator. ### Changed - Bump the version of OBS studio node to 0.23.82. ## [3.13.1] - 2023-06-04 ### Changed - [Issue 416](https://github.com/aza547/wow-recorder/issues/416) - Automatically scale to canvas size. - [Issue 420](https://github.com/aza547/wow-recorder/issues/420) - Convert to using thumbnails instead of fixed images. ### Fixed - [Issue 417](https://github.com/aza547/wow-recorder/issues/417) - Fix a bug where sometimes recordings were missed. - Split out the chat overlay settings from the game settings so it's more responsive. - [Issue 418](https://github.com/aza547/wow-recorder/issues/418) - Improve the test button UX. - [Issue 419](https://github.com/aza547/wow-recorder/issues/419) - Fix some misnamed boss encounters. ## [3.13.0] - 2023-05-21 ### Added - [Issue 216](https://github.com/aza547/wow-recorder/issues/216) - Add volume controls for audio sources. ### Changed - [Issue 404](https://github.com/aza547/wow-recorder/issues/404) - Revamp settings so they can be configured live. - [Issue 406](https://github.com/aza547/wow-recorder/issues/406) - Show death markers in all content types. ### Fixed - [Issue 387](https://github.com/aza547/wow-recorder/issues/387) - Fix to Mythic+ video markers UX. - [Issue 395](https://github.com/aza547/wow-recorder/issues/395) - Fix Dragonflight S2 dungeon timings for +2 and +3 chests. - [Issue 405](https://github.com/aza547/wow-recorder/issues/405) - Guard against setting retail and classic log path to the same value. - [Issue 410](https://github.com/aza547/wow-recorder/issues/410) - Fix preview dissapearing if recording while player is fullscreen. - [Issue 393](https://github.com/aza547/wow-recorder/issues/393) - Improve some clipping issues with the preview. ## [3.12.0] - 2023-05-03 ### Added - [Issue 386](https://github.com/aza547/wow-recorder/issues/386) - Option to disable minimize to tray. - [Issue 394](https://github.com/aza547/wow-recorder/issues/394) - Updates for Dragonflight season 2 content. ### Fixed - [Issue 397](https://github.com/aza547/wow-recorder/issues/397) - Fix issue where scene preview may not position correctly. ## [3.11.0] - 2023-04-30 ### Added - [Issue 354](https://github.com/aza547/wow-recorder/issues/354) - Add a scene preview to the home page. - [Issue 354](https://github.com/aza547/wow-recorder/issues/354) - Add a scene editor page. - [Issue 46](https://github.com/aza547/wow-recorder/issues/46) - Add the ability to use a chat overlay. ### Changed - Restyle some checkboxes in the settings as switches, for continuity. ### Fixed - [Issue 384](https://github.com/aza547/wow-recorder/issues/384) - Fix Balakar Khan encounter ID. - [Issue 391](https://github.com/aza547/wow-recorder/issues/391) - Bump electron version to ^24.0.0 to fix Mexico timezone bug. ## [3.10.4] - 2023-04-26 ### Added - [Issue 381](https://github.com/aza547/wow-recorder/issues/381) - Add "bookmarked" as a filter option. ### Fixed - [Issue 381](https://github.com/aza547/wow-recorder/issues/381) - Fix a bug where the search bar query isn't cleared. - [Issue 383](https://github.com/aza547/wow-recorder/issues/383) - Remove some options from the installer that didn't work. - [Issue 382](https://github.com/aza547/wow-recorder/issues/383) - Fix a bug where toggling bookmark on a video could make it vanish from the UI. ## [3.10.3] - 2023-04-24 ### Fixed - [Issue 328](https://github.com/aza547/wow-recorder/issues/328) - Fix a bug where the installer didn't automatically install the Visual C++ Redistributable package from Microsoft. ## [3.10.2] - 2023-04-23 ### Changed - [Issue 370](https://github.com/aza547/wow-recorder/issues/370) - Show more than the most recent video on home page. - [Issue 371](https://github.com/aza547/wow-recorder/issues/371) - Include Battleground names in the UI. - [Issue 352](https://github.com/aza547/wow-recorder/issues/352) - Change the home page to be less fancy and more functional. - [Issue 359](https://github.com/aza547/wow-recorder/issues/359) - Add lots of support querys to the filter bar. - [Issue 366](https://github.com/aza547/wow-recorder/issues/366) - Delete button now has a confirmation prompt. ### Fixed - [Issue 376](https://github.com/aza547/wow-recorder/issues/376) - Change seeking so clicking a video marker won't go to the start but to where the user clicked. ## [3.10.1] - 2023-04-11 ### Fixed - [Issue 373](https://github.com/aza547/wow-recorder/issues/373) - Fixed an issue where the Mythic+ UI didn't display correctly due to combatant bleed. - [Issue 372](https://github.com/aza547/wow-recorder/issues/372) - Improve the help text next to the force stop button. ## [3.10.0] - 2023-04-10 ### Added - [Issue 358](https://github.com/aza547/wow-recorder/issues/358) - Option to set threshold for raid difficulty recordings. - [Issue 177](https://github.com/aza547/wow-recorder/issues/177) - Allow picking a category when using the test button. - [Issue 359](https://github.com/aza547/wow-recorder/issues/359) - Add a search bar for filtering videos. ### Changed - [Issue 352](https://github.com/aza547/wow-recorder/issues/352) - Improvements to the home page aesthetics. - [Issue 347](https://github.com/aza547/wow-recorder/issues/347) - Only overrun in raids on kills. ### Fixed - [Issue 355](https://github.com/aza547/wow-recorder/issues/355) - Fixed an issue where swapping characters can confuse the parser. - [Issue 355](https://github.com/aza547/wow-recorder/issues/355) - Fixed an issue where we were incorrectly marking abandoned M+ as completed. - [Issue 356](https://github.com/aza547/wow-recorder/issues/356) - Fixed an issue where adding too many audio devices wasn't handled well. - [Issue 365](https://github.com/aza547/wow-recorder/issues/365) - Fixed an issue where an unknown specID would crash the app. - [Issue 367](https://github.com/aza547/wow-recorder/issues/367) - Fixed an issue where Mythic+ COMBATANT_INFO events weren't getting handled appropriately. - [Issue 368](https://github.com/aza547/wow-recorder/issues/368) - Fixed an issue where comps don't display correctly in some retail content. ## [3.9.0] - 2023-04-07 ### Added - [Issue 334](https://github.com/aza547/wow-recorder/issues/334) - Add JKL, arrows and space hotkeys to video player for seeking. ### Changed - [Issue 352](https://github.com/aza547/wow-recorder/issues/352) - Improvements to the home page aesthetics. - Adjusted M+ markers to be based on segment duration. - Adjusted M+ marker mouseover tips to be boss names instead of just 'Boss'. ### Fixed - [NO-ISSUE] Fixed a bug where the app would sometimes crash on selecting a category with no videos. - [Issue 362](https://github.com/aza547/wow-recorder/issues/362) - Include the M+ keystone level in the GUI. ## [3.8.0] - 2023-04-04 ### Changed - [Issue 352](https://github.com/aza547/wow-recorder/issues/352) - Totally redesign the user interface. - [Issue 348](https://github.com/aza547/wow-recorder/issues/348) - Changed CSS for Seek Bar to make it taller. ### Fixed - [Issue 350](https://github.com/aza547/wow-recorder/issues/350) - Fix a bug where videos could be cut wrongly after using the stop recording button. - Corrected first boss name in Court of Stars. - [Issue 144](https://github.com/aza547/wow-recorder/issues/144) - Hide markers for some categories/flavours. ## [3.7.0] - 2023-03-24 ### Added - [Issue 340](https://github.com/aza547/wow-recorder/issues/340) - Option to minimize on clicking quit button. - [Issue 144](https://github.com/aza547/wow-recorder/issues/144) - Add video timeline markers for solo shuffle and mythic+. - [NO-ISSUE](https://github.com/aza547/wow-recorder/issues/144) - Add 5120x1440 resolution. ### Changed - [Issue 144](https://github.com/aza547/wow-recorder/issues/144) - Use the Video JS player for playback. ### Fixed - [Issue 334](https://github.com/aza547/wow-recorder/issues/344) - Fix bitrate for AMD GPUs, signficantly improving video quality. - [Issue 334](https://github.com/aza547/wow-recorder/issues/344) - Remove some unsupported encoders. ## [3.6.2] - 2023-03-12 ### Changed - [NO-ISSUE] Upgrade OSN from 0.23.59 to 0.23.71. ### Fixed - [Issue 287](https://github.com/aza547/wow-recorder/issues/287) - Fix some ugly icons to look better. - [Issue 325](https://github.com/aza547/wow-recorder/issues/325) - Fix the OBS process not closing correctly on quitting. - [Issue 338](https://github.com/aza547/wow-recorder/issues/338) - Resolve a problem when upgrading the app wouldn't shutdown OBS. ## [3.6.1] - 2023-03-08 ### Fixed - [Issue 332](https://github.com/aza547/wow-recorder/issues/332) - Disable hardware accelerated rendering of the app. - [Issue 336](https://github.com/aza547/wow-recorder/issues/336) - Fix a bug where we sometimes didn't refresh the GUI after a video was recorded. - [Issue 337](https://github.com/aza547/wow-recorder/issues/337) - Fix a spammy log if there is an empty WoW log dir. ## [3.6.0] - 2023-03-04 ### Added - [Issue 329](https://github.com/aza547/wow-recorder/issues/329) - Option to hide cursor. - [Issue 326](https://github.com/aza547/wow-recorder/issues/326) - Option to ignore M+ below a certain level. ### Fixed - [Issue 323](https://github.com/aza547/wow-recorder/issues/329) - Fix bug where size monitor was deleting protected videos. ## [3.5.1] - 2023-02-07 ### Fixed - [Issue 321](https://github.com/aza547/wow-recorder/issues/321) - Fix so that bitrate settings are remembered after first recording. ## [3.5.0] - 2023-02-03 ### Added - [Issue 34](https://github.com/aza547/wow-recorder/issues/34) - Add unit test infrastructure. - [Issue 312](https://github.com/aza547/wow-recorder/issues/312) - Add a bookmark icon for protected videos. ### Changed - [Issue 272](https://github.com/aza547/wow-recorder/issues/272) - Revert request for elevated permissions preventing running on startup. ### Fixed - [Issue 293](https://github.com/aza547/wow-recorder/issues/293) - Fix a backend bug where reconfiguring would leak audio device references. - [Issue 303](https://github.com/aza547/wow-recorder/issues/303) - Fix error handling so that we don't get a blank screen if something goes wrong. - [Issue 291](https://github.com/aza547/wow-recorder/issues/291) - Improve activity and recorder logic to prevent classic double stop issue. - [Issue 314](https://github.com/aza547/wow-recorder/issues/314) - Make size monitor more async to avoid app lag on game ending. ## [3.4.0] - 2023-01-22 ### Added - [Issue 296](https://github.com/aza547/wow-recorder/issues/296) - Add Ulduar classic support. - [Issue 300](https://github.com/aza547/wow-recorder/issues/300) - Add difficulty to raid file names. ### Fixed - [Issue 285](https://github.com/aza547/wow-recorder/issues/285) - Fix bug that prevented retail recording of retail war games. - [Issue 288](https://github.com/aza547/wow-recorder/issues/288) - Fix bug that prevented changing the FPS setting. - [Issue 275](https://github.com/aza547/wow-recorder/issues/275) - Increase retail log timeout for better handling of M+. - [Issue 251](https://github.com/aza547/wow-recorder/issues/251) - Fix text overflow clipping in video selection buttons. - [Issue 293](https://github.com/aza547/wow-recorder/issues/293) - Fix to prevent adding too many audio devices in the settings. - [Issue 294](https://github.com/aza547/wow-recorder/issues/294) - Remove a bunch of encoders that don't work with WR. ## [3.3.3] - 2023-01-14 ### Fixed - Fix a bug where we didn't respect the overrun. - [Issue 279](https://github.com/aza547/wow-recorder/issues/279) - Improve signalling robustness. - [Issue 276](https://github.com/aza547/wow-recorder/issues/276) - Reconfiguring settings doesn't result in a blank screen with game capture. - [Issue 280](https://github.com/aza547/wow-recorder/issues/280) - Handle unplugged audio devices better in the config. ## [3.3.2] - 2023-01-08 ### Fixed - [Issue 273](https://github.com/aza547/wow-recorder/issues/273) - Fix a bug where raid resets could crash the app. - [Issue 271](https://github.com/aza547/wow-recorder/issues/271) - Allow OBS more time to signal. - [Issue 274](https://github.com/aza547/wow-recorder/issues/274) - Fix bug when sometimes an internal arena zone change would crash the app. ## [3.3.1] - 2023-01-02 ### Fixed - [NO-ISSUE]- Fix crass default resolution bug. ## [3.3.0] - 2023-01-01 ### Added - [Issue 245](https://github.com/aza547/wow-recorder/issues/245) - Ability to pick and chose any combination of audio devices to record. ### Changed - Upgrade obs-studio-node to 0.23.59. ### Fixed - [Issue 264](https://github.com/aza547/wow-recorder/issues/264) - Attempt to fix some permissions problems on Windows 11. - [Issue 223](https://github.com/aza547/wow-recorder/issues/223) - Make resolutions hardcoded so there can be no weird disappearing of options. - [Issue 256](https://github.com/aza547/wow-recorder/issues/256) - Fix bug where resolution was sometimes flipped. - [Issue 57](https://github.com/aza547/wow-recorder/issues/57) - Fix a bug preventing windows from sleeping with WR open. ## [3.2.0] - 2022-12-23 ### Added - [Issue 247](https://github.com/aza547/wow-recorder/issues/247) - Better handling for Solo Shuffle. ### Fixed - [Issue 257](https://github.com/aza547/wow-recorder/issues/257) - Improve right click menu responsiveness. ## [3.1.2] - 2022-12-17 ### Added - [Issue 246](https://github.com/aza547/wow-recorder/issues/246) - Config check that storage path and buffer path are different. - [Issue 282](https://github.com/aza547/wow-recorder/issues/282) - Added config toggle switch to force input audio to mono. ### Changed - Autoplay videos on selection. - Display errors in a neater manner with suggestions on how to get help. - Improve clipping of classic arenas to skip waiting room. ### Fixed - Fix a bug where closing wow didn't stop the recorder if mid activity. - Fix a problem when saving videos to NFS mounts. - [Issue 187](https://github.com/aza547/wow-recorder/issues/187) - Add Dragonflight M+ timings. ## [3.1.1] - 2022-12-09 ### Fixed - [Issue 239](https://github.com/aza547/wow-recorder/issues/239) - Fix app crashing when WoW closes if both log paths (retail and classic) are not configured. - [Issue 238](https://github.com/aza547/wow-recorder/issues/238) - Don't crash on unrecognised video category, just don't record. - Fix to log watching to make the UI more responsive. - Fix Nokhun Proving Grounds image and shorten name. - Update video poster to look better. ## [3.1.0] - 2022-12-03 ### Added - [Issue 187](https://github.com/aza547/wow-recorder/issues/187) - Added the new M+ dungeons and arena for Dragonflight S1. - [Issue 237](https://github.com/aza547/wow-recorder/issues/237) - Show specifically what config is wrong when config is invalid. ### Fixed - [Issue 236](https://github.com/aza547/wow-recorder/issues/236) - Fix to ignore normal, heroic and m0 dungeon bosses, as well as unknown encounters. ## [3.0.4] - 2022-11-27 ### Fixed - [Issue 235](https://github.com/aza547/wow-recorder/issues/235) - Fix a bug where abandoned M+ runs caused the app to crash. - [Issue 229](https://github.com/aza547/wow-recorder/issues/229) - Fix a bug where the test button didn't work without a retail log path configured. - Fix a bug where closing WoW while in an activity could crash the app. ## [3.0.3] - 2022-11-24 ### Fixed - Fix a bug where overrun isn't working as intended. Broke this in 3.0.1. ## [3.0.2] - 2022-11-20 ### Fixed - Fix a bug where videos were sometimes cut to wrong sizes. - Fix a bug where Evoker class color wasn't displayed in the UI. ## [3.0.1] - 2022-11-14 ### Changed - [Issue 228](https://github.com/aza547/wow-recorder/issues/228) - Clip activities better by not assuming buffer time is end of video. ### Fixed - [Issue 224](https://github.com/aza547/wow-recorder/issues/224) - Make settings window taller to avoid clipping content settings. - [Issue 230](https://github.com/aza547/wow-recorder/issues/230) - Fix a bug where recordings after saving settings were broken. ## [3.0.0] - 2022-11-12 ### Added - [Issue 50](https://github.com/aza547/wow-recorder/issues/50) - Classic arena and battleground support. - Functionality to have a good estimate at if a battleground is a win or loss. - Spec detection for all categories that lacked it. - Initial Evoker class handling in preperation for Dragonflight. ### Changed - [Issue 224](https://github.com/aza547/wow-recorder/issues/224) - Improve main window styling. Bbreaks backwards compatbility with previously recorded videos. ### Fixed - [Issue 221](https://github.com/aza547/wow-recorder/issues/221) - Fix a bug where on some setups only a subsection of the game was recorded. ## [2.10.2] - 2022-11-04 ### Fixed - [Issue 220](https://github.com/aza547/wow-recorder/issues/220) - Revert audio sources muting to fix black screen recording bug. ## [2.10.1] - 2022-11-02 ### Fixed - [Issue 57](https://github.com/aza547/wow-recorder/issues/57) - Only enable audio sources when recording buffer to avoid Windows not being able to go into sleep mode. - [Issue 218](https://github.com/aza547/wow-recorder/issues/218) - Fix solo shuffle on DF pre-patch. ## [2.10.0] - 2022-10-22 ### Added - [Issue 211](https://github.com/aza547/wow-recorder/issues/211) - Validate combat log paths to avoid mistakes. - [Issue 2](https://github.com/aza547/wow-recorder/issues/2) - Add Game Capture mode. - [Issue 207](https://github.com/aza547/wow-recorder/issues/207) - Suggest some bitrates in the settings help text. ### Fixed - [Issue 168](https://github.com/aza547/wow-recorder/issues/168) - Fix player combatant not being saved properly when a recording is forcibly ended. - [Issue 205](https://github.com/aza547/wow-recorder/issues/205) - Expose encoder in Advanced Settings. - [Issue 206](https://github.com/aza547/wow-recorder/issues/206) - Bitrate label corrected say to Mbps. - [Issue 208](https://github.com/aza547/wow-recorder/issues/208) - Fix to Warsong Gulch image. - [Issue 213](https://github.com/aza547/wow-recorder/issues/213) - Fix the application occasionally crashing when WoW is closed. ## [2.9.0] - 2022-10-11 ### Added - [Issue 50](https://github.com/aza547/wow-recorder/issues/50) - Add WOTLK classic raid support for Naxx, EOE, OS and VOA. - [Issue 191](https://github.com/aza547/wow-recorder/issues/191) - Recording FPS, output resolution, and video bit rate now adjustable in video settings. - [Issue 187](https://github.com/aza547/wow-recorder/issues/187) - Added Vault of the Incarnates raid IDs. - [Issue 192](https://github.com/aza547/wow-recorder/issues/192) - Accept Beta, PTR and classic processes as reason to move to ready state. ### Changed - [Issue 194](https://github.com/aza547/wow-recorder/issues/194) - Change to variable bitrate recording. This will drastically reduce video sizes. ### Fixed - [Issue 194](https://github.com/aza547/wow-recorder/issues/194) - Cut videos in a queue and don't block the recorder. - [Issue 193](https://github.com/aza547/wow-recorder/issues/193) - Add some guards so we don't start recording in an invalid state. - [Issue 199](https://github.com/aza547/wow-recorder/issues/199) - Fix a bug where clicking test button several times would do bad things. ## [2.8.2] - 2022-10-02 ### Added - [Issue 184](https://github.com/aza547/wow-recorder/issues/184) - Option to start-up to the system tray. ### Fixed - [Issue 164](https://github.com/aza547/wow-recorder/issues/164) - Expose the settings help text in the UI. - [Issue 178](https://github.com/aza547/wow-recorder/issues/178) - Fix bufferStoragePath defaulting to an empty string. - [Issue 186](https://github.com/aza547/wow-recorder/issues/186) - Prevent running multiple copies of WR. ## [2.8.1] - 2022-09-27 ### Fixed - [Issue 175](https://github.com/aza547/wow-recorder/issues/175) - Fix test button on non en-GB locales. - [Issue 176](https://github.com/aza547/wow-recorder/issues/176) - Fix app crashing when recording is force stopped. - [Issue 177](https://github.com/aza547/wow-recorder/issues/177) - Let test run regardless of 2v2 config setting. ## [2.8.0] - 2022-09-26 ### Added - [Issue 81](https://github.com/aza547/wow-recorder/issues/81) - Better monitor selection in settings UI. - [Issue 52](https://github.com/aza547/wow-recorder/issues/52) - Video files are now named more human friendly. - [Issue 134](https://github.com/aza547/wow-recorder/issues/134) - Only handle UNIT_DIED when a recording activity is in progress. - [Issue 142](https://github.com/aza547/wow-recorder/issues/142) - Make it possible to stop recording by clicking the 'rec' icon. - [Issue 166](https://github.com/aza547/wow-recorder/issues/166) - Remember the selected category across application restarts. - [Issue 150](https://github.com/aza547/wow-recorder/issues/50) - Add infrastructure for future classic support. - [Issue 168](https://github.com/aza547/wow-recorder/issues/168) - Add a timeout feature that will end a recording after 2 minutes of combatlog inactivity. ### Changed - [Issue 165](https://github.com/aza547/wow-recorder/issues/165) - Now loads videos asynchronously to improve application reponsiveness on start up with many videos. - [Issue 164](https://github.com/aza547/wow-recorder/issues/164) - Entirely revamp the settings to be more responsive and modern. Will reset user settings. ### Fixed - [Issue 124](https://github.com/aza547/wow-recorder/issues/234) - Make buffering dir configurable. This setting is optional and will sensibly default. - [Issue 123](https://github.com/aza547/wow-recorder/issues/123) - More robust monitor selection. - [Issue 128](https://github.com/aza547/wow-recorder/issues/128) - Guard against multiple recording buffer restarts. - [Issue 130](https://github.com/aza547/wow-recorder/issues/130) - Fix invalid default audio input/output device. - [Issue 139](https://github.com/aza547/wow-recorder/issues/139) - Give OBS longer to recover, but crash the app if it doesn't signal. - [Issue 155](https://github.com/aza547/wow-recorder/issues/155) - Fix periodic lag spike every 1 second while using app. - [Issue 167](https://github.com/aza547/wow-recorder/issues/167) - Fix Iron Docks M+ timer. - [Issue 133](https://github.com/aza547/wow-recorder/pull/133) - Fix bug that audio device would sometimes record when set to none. ## [2.7.0] - 2022-09-19 ### Added - [Issue 47](https://github.com/aza547/wow-recorder/issues/48) - Add Mythic+ recording support. - [Issue 74](https://github.com/aza547/wow-recorder/issues/74) - Added version check from github releases page. - [Issue 99](https://github.com/aza547/wow-recorder/issues/99) - Remember video sound settings when changing videos. - [Issue 107](https://github.com/aza547/wow-recorder/issues/107) - Add a config setting for minimum raid duration, to avoid saving boss resets. - [Issue 17](https://github.com/aza547/wow-recorder/issues/17) - Allow the selection of input/output audio devices for recording in settings. ### Changed - [Issue 66](https://github.com/aza547/wow-recorder/issues/66) - Store buffer recordings in a better location. ### Fixed - [Issue 96](https://github.com/aza547/wow-recorder/issues/96) - Fixed windows resolution scaling resulting in OBS Resolutions not being set properly. - [Issue 78](https://github.com/aza547/wow-recorder/issues/78) - Gracefully fail if a video can't be deleted, rather than giving an uncaught exception error. - [Issue 75](https://github.com/aza547/wow-recorder/issues/75) - Fix to size monitor blocking saving of videos. - [Issue 86](https://github.com/aza547/wow-recorder/issues/86) - Fix various event listener leaks. - [Issue 112](https://github.com/aza547/wow-recorder/issues/112) - Crash the app if OBS gets into a bad state. ## [2.6.1] - 2022-09-05 ### Fixed - [Issue 70](https://github.com/aza547/wow-recorder/issues/70) - Double clicking test button no longer breaks the test. - [Issue 77](https://github.com/aza547/wow-recorder/issues/77) - Don't expect hyphen in WoWCombatLog.txt. - [Issue 82](https://github.com/aza547/wow-recorder/issues/82) - Don't fall over if a 5v5 wargame recording is made. - Update various NPM packages to resolve various dependabot security issues. ## [2.6.0] - 2022-08-29 ### Added - [Issue 50](https://github.com/aza547/wow-recorder/issues/50) - Add some plumbing for future when we support classic. - [Issue 2](https://github.com/aza547/wow-recorder/issues/2) - Add a monitor selection config option. Defaults to first monitor. - [Issue 9](https://github.com/aza547/wow-recorder/issues/9) - Add a test button to the GUI. ### Changed - Assert that OBS behaves as expected or crash the app, previously we would just continue and get into god knows what error states. - No longer require the application to be restarted on a config change. - Take OSN `0.22.10`, previously was on `0.10.10`. ### Fixed - Rename window from "Arena Recorder" to "Warcraft Recorder". - [Issue 23](https://github.com/aza547/wow-recorder/issues/23) - Fix clean-up buffer issue on app close. - [Issue 64](https://github.com/aza547/wow-recorder/issues/64), [Issue 60](https://github.com/aza547/wow-recorder/issues/60) - Overhaul async logic causing problems. - [Issue 54](https://github.com/aza547/wow-recorder/issues/54) - Fix to stop recording when leaving arena games with /afk. - [Issue 23](https://github.com/aza547/wow-recorder/issues/23) - Fix bug where app would fail to start if there were no logs in the WoW logs directory. - [Issue 69](https://github.com/aza547/wow-recorder/issues/69) - Fix cleanup buffer JS error. ## [2.5.2] - 2022-08-29 ### Fixed - Fix issue where ZONE_CHANGE can crash the app. ## [2.5.1] - 2022-08-29 ### Fixed - Fix ID for Sun King's Salvation encounter. - Fix resolution hardcoded regression. - Fix issue where raid encounters don't save the result correctly if quickly followed by a zone change. ## [2.5.0] - 2022-08-29 ### Added - [Issue 29](https://github.com/aza547/wow-recorder/issues/29) - Add all shadowlands raid encounters. - [Issue 44](https://github.com/aza547/wow-recorder/issues/44) - Auto-stop recording if WoW is closed. - [Issue 26](https://github.com/aza547/wow-recorder/issues/26) - Buffer recording to always capture the beginning of games/encounters. - Add a button to open the application log path for debugging. - Add a link to Discord in the application. - Small improvements improvements to tests. ### Fixed - Clean-up handling of images, it was really messy. ## [2.4.1] - 2022-08-20 ### Fixed - Fix BG recording that was regressed in 2.4.0. - Fix Warsong Gulch zone ID. ## [2.4.0] - 2022-08-20 ### Added - Write more useful information to metadata files, including player name and spec. Thanks again to ericlytle for the contribution. - [Issue 19](https://github.com/aza547/wow-recorder/issues/19) - Display spec and name on arena and raid videos. - MMR hover text for arenas. ### Changed - Remove hardcoded aspect ratio of application only appropriate for 1080p recordings. ### Fixed - [Issue 40](https://github.com/aza547/wow-recorder/issues/40) - Fix to AMD AMF encoder. - [Issue 42](https://github.com/aza547/wow-recorder/issues/42) - Fix to Deepwind Gorge button image. - [Issue 42](https://github.com/aza547/wow-recorder/issues/42) - Fix issue where internal BG zone changes stop the recording. ## [2.3.0] - 2022-08-14 ### Added - Resources directory and better test scripts, although they still suck. - [Issue 10](https://github.com/aza547/wow-recorder/issues/10) - Add logging infrastructure. - [Issue 33](https://github.com/aza547/wow-recorder/issues/33) - Add tray icon and menu. Make minimizing now hide in system tray. - [Issue 32](https://github.com/aza547/wow-recorder/issues/32) - Add setting to run on start-up. - [Issue 6](https://github.com/aza547/wow-recorder/issues/6) - Battlegrounds is now a supported category. ### Changed - Record at 60 FPS instead of 30. ### Fixed - Clean-up of react UI code. ## [2.2.0] - 2022-08-07 ### Added - [Issue 28](https://github.com/aza547/wow-recorder/issues/28) - Add open file in system explorer option when right clicking videos. - [Issue 28](https://github.com/aza547/wow-recorder/issues/28) - Add delete video option when right clicking videos. - [Issue 27](https://github.com/aza547/wow-recorder/issues/27) - Add save video option when right clicking videos. ### Changed - [Issue 37](https://github.com/aza547/wow-recorder/issues/37) - Remove bitrate cap, drastically increasing recording quality (and file size). Probably should make this configurable in the future. ### Fixed - [Issue 22](https://github.com/aza547/wow-recorder/issues/22) - Make app less fragile to missing metadata files. ## [2.1.0] - 2022-08-05 ### Added - Add some color to outcome indicator. ### Changed ### Fixed - [Issue 5](https://github.com/aza547/wow-recorder/issues/5) - Fix arena win/loss indicator. Thanks to ericlytle for the code contribution. ## [2.0.1] - 2022-07-31 ### Fixed - Fix minimize button. - [Issue 21](https://github.com/aza547/wow-recorder/issues/21) - Handle people /afking out of content gracefully by stopping recording on ZONE_CHANGE for most categories. ## [2.0.0] - 2022-07-07 ### Added - Backdrops for SOFO raid bosses. ### Changed - Use libobs for recording. - Removal of the python code for screen recording. - Removal of ffmpeg binary for screen capture. - Refactor of most internal logic. - Disable BG/Mythic+ modes in the GUI for now. ### Fixed ## [1.0.3] - 2022-07-03 ### Added - Better logs for GPU detection. ### Changed - Move output.log to a fixed relative location. ### Fixed - Fix for AMD hardware encoding. ## [1.0.2] - 2022-06-28 ### Changed - Rename python.log to ffmpeg.log. - Use hardware encoding on NVIDIA or AMD GPUs. - Change app icon so not using the electron default. ### Fixed - Fix size monitor so it actually works. ## [1.0.1] - 2022-06-26 ### Fixed - Fixed up README and CHANGELOG. - Remove some hardcoded paths. - Fix bug that recorder doesn't start on a dir without logs in it. - Create required directories in storage path if they don't exist. - Stop/start recorder process on config change. - Add some extremely basic console logs for python recorder controller. ## [1.0.0] - 2022-06-21 ### Added - Initial drop of project. ================================================ FILE: LICENSE ================================================ Warcraft Recorder --------------------------------------------- GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Ty Coon, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. Electron React Boilerplate --------------------------------------------- The MIT License (MIT) Copyright (c) 2015-present Electron React Boilerplate Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Excalidraw --------------------------------------------- MIT License Copyright (c) 2020 Excalidraw Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Warcraft Recorder ![GitHub all releases](https://img.shields.io/github/downloads/aza547/wow-recorder/total) ![Version](https://img.shields.io/github/package-json/v/aza547/wow-recorder?filename=release%2Fapp%2Fpackage.json) ![Discord](https://img.shields.io/discord/1004860808737591326) Warcraft Recorder is a desktop screen recorder. It watches the WoW combat log file for interesting events, records them, and presents a user interface in which the recordings can be viewed. image # How to Use 1. Download and run the most recent [Warcraft Recorder installer](https://github.com/aza547/wow-recorder/releases/latest). 2. Launch the application and click the Settings button. - Create a folder on your PC to store the recordings. - Set the Storage Path to the folder you just created. - Enable recording and set the location of your World of Warcraft logs folder. - Modify any other settings as desired. 3. Click the Scene button and configure the OBS scene and recording settings. - Select your desired output resolution. - Add your speakers and/or microphone if you want to include audio. - Recommend selecting a hardware encoder, if available. - Modify any other settings as desired. 5. Install the required combat logging addon, enabling advanced combat logging when prompted. - Retail: SimpleCombatLogger ([CurseForge](https://www.curseforge.com/wow/addons/simplecombatlogger), [Wago](https://addons.wago.io/addons/simplecombatlogger)). - Classic: AutoCombatLogger ([CurseForge](https://www.curseforge.com/wow/addons/autocombatlogger), [Wago](https://addons.wago.io/addons/autocombatlogger)). - Classic Era: AutoCombatLogger ([CurseForge](https://www.curseforge.com/wow/addons/autocombatlogger), [Wago](https://addons.wago.io/addons/autocombatlogger)). # Supported Platforms | OS | Support | |---|---| | Windows | Yes | | Mac | No | | Linux | No | | Flavour | Support | |---|---| | Retail | Yes | | MoP Classic | Yes | | Classic Era | SoD Raids Only | # Testing It Works You can test that Warcraft Recorder works by clicking the test icon with World of Warcraft running after you have completed the above setup steps. This runs a short test of the recording function. # Bug Reports & Suggestions Please create an issue, I will get to it eventually. Bear in mind maintaining this is a hobby for me, so it may take me some time to comment. If you think you can improve something, feel free to submit a PR. I've created a dedicated discord for this project, feel free to join [here](https://discord.gg/NPha7KdjVk). # Contributing If you're interested in getting involved please drop me a message on discord and I can give you access to our development channel. Also see [contributing](https://github.com/aza547/wow-recorder/blob/main/docs/CONTRIBUTING.md) docs. # Mentions The recording done by Warcraft Recorder is made possible by packaging up [OBS](https://obsproject.com/). We wouldn't stand a chance at providing something useful without it. Big thanks to the OBS developers. The app is built with [Electron](https://www.electronjs.org/) and [React](https://react.dev/), using the boilerplate provided by the [ERB](https://electron-react-boilerplate.js.org/) project. Drawing overlay created using [Excalidraw](https://github.com/excalidraw/excalidraw). ================================================ FILE: assets/assets.d.ts ================================================ type Styles = Record; declare module '*.svg' { const content: string; export default content; } declare module '*.png' { const content: string; export default content; } declare module '*.jpg' { const content: string; export default content; } declare module '*.mp3' { const src: string; export default src; } declare module '*.scss' { const content: Styles; export default content; } declare module '*.sass' { const content: Styles; export default content; } declare module '*.css' { const content: Styles; export default content; } ================================================ FILE: assets/entitlements.mac.plist ================================================ com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.allow-jit ================================================ FILE: docs/CONTRIBUTING.md ================================================ # Contributing The below steps describe development on Windows. The app is currently not supported on other operating systems. ## Architecture Once I drew the structure of the application in Excalidraw. You can see that below. It's a rough overview of the key parts and may be a useful overview for any interested developers. ![](https://i.imgur.com/UbZ0aWY.png) You can find the source in the `design.excalidraw` file in this directory. ## Start in Development Mode Development mode benefits from the infrastructure offered by [electron-react-boilerplate](https://github.com/electron-react-boilerplate/electron-react-boilerplate). You can read more about it on their [docs](https://electron-react-boilerplate.js.org/). It allows for a very quick development cycle, access to chrome dev tools, and hot reloading of the app on saving new changes. 1. Install the latest version of Node.js version (latest at time of writing is 20.4.0) from [here](https://nodejs.org/en/). 1. Clone a copy of the [wow-recorder](https://github.com/aza547/wow-recorder) codebase. 1. Change into the checkout directory. 1. Run `npm install` on the command line to install required node packages. 1. Run `npm start` to launch the application in development mode. ## Building, Packaging and Releasing > As of 3.3.1, we have CI builds for all commits to main. > As of 6.8.2, only the tests run in the CI. Can't build in the CI now that we're signing the exe. 1. Build the electron application. 1. Update the version number in `./release/app/package.json` if appropriate. 1. Run `npm run package` to build the electron application. 1. Install the .exe and run the tests to make sure you've not broken something crass. 1. With WarcraftRecorder open, run: `python .\resources\test-scripts\all_tests.py`. 1. Manually check the app behaves as expected while this runs. 1. Recordings are created. 1. Appropriate metadata is created. 1. User experience has not degraded. 1. Share the application. 1. Update the CHANGELOG.md with the new version number and change details. 1. Commit and push all changes. 1. Tag a release on GitHub and attach the built files: - `./release/build/WarcraftRecorder-Setup-X.Y.Z.exe` to enable installation. - `./release/build/latest.yml` to allow the auto updater to function. - `./release/build/WarcraftRecorder-Setup-6.8.0.exe.blockmap` to allow the auto updater to function. ## Tests 1. Run `npm test` to run the UTs. 1. These are `jest` based unit tests. 2. Note: This is a WIP - the UTs currently are not useful. 2. To run end-to-end tests (requires some hardcoded path updates): * All tests: `python .\resources\test-scripts\all_tests.py`. * Individual test: `python .\resources\test-scripts\retail_mythic_plus.py`. ## Debugging Mode You can use VSCode's JavaScript Debug terminal to step through the code, add breakpoints, view variables and the other IDE features. 1. Go to file, new terminal. 1. Click the arrow next to the "+" icon. 1. Select "JavaScrtip Debug Terminal". See below image. 1. Run the application in development mode as per above instructions (i.e. `npm start`). 1. Enjoy the ability to use the IDE features. ## Debugging in Production with Dev Tools From [here](https://electron-react-boilerplate.js.org/docs/packaging). `npx cross-env DEBUG_PROD=true npm run package` ## Building OSN > Advice is not to build this and just get it from the folks at Streamlabs. I built it once, it was a total faff. > If you really need to build it, you can probably find some useful notes in the history of this doc. > This is hosted by streamlabs here (note version number): > - https://s3-us-west-2.amazonaws.com/obsstudionodes3.streamlabs.com/osn-0.23.59-release-win64.tar.gz The above is no longer true, I'm now rebuilding OSN to add force stop functionality. Follow the OSN build instructions and then do: `tar -czvf osn-0.25.34wcr-release-win64.tar.gz -C build/distribute/ obs-studio-node` to get an archive ready for use. Below are various additional OSN resources: - [Example](https://github.com/Envek/obs-studio-node-example) - [Community Docs](https://github.com/hrueger/obs-studio-node-docs) - [Streamlabs Desktop](https://github.com/stream-labs/desktop) - [AdvancedRecordingFactory API](https://github.com/stream-labs/obs-studio-node/pull/1128) - [OSN Tests](https://github.com/stream-labs/obs-studio-node/tree/staging/tests/osn-tests/src) ## Microsoft Stamp of Approval If we just build a .exe and release it Windows will warn it may be dangerous. Could resolve this buy purchasing a certificate from a CA, but it costs a fortune. Read more about it in this [issue](https://github.com/aza547/wow-recorder/issues/11). 1. Submit it for analysis [here](https://www.microsoft.com/en-us/wdsi/filesubmission) after releasing it to make that warning go away. 1. Select "Microsoft Defender Smartscreen" as the security product. 1. "Company name" - just put your own name. 1. "Do you have a Microsoft support case number?" - No. 1. Leave next few fields blank/unchanged. 1. Select & upload the .exe. 1. "What do you believe this file is?" - Incorrectly detected as malware/malicious 1. Detection name - "WarcraftRecorder.Setup.2.0.1.exe" 1. "Additional information" - whatever, I'm sure no one will read it. 1. This isn't instant but seems to get resolved within 24 hours, that seems good enough. ================================================ FILE: docs/Colors.md ================================================ Dark blue background: #1A233A (set on HTML in app.css) Old lighter Blue background: #272e48 Extra dark blue? (bottom/top bars): #182035 Extra extra dark blue (used in settings boxes): #141b2d Red: #bb4420 ================================================ FILE: docs/FilterAudio.md ================================================ # How to Filter Recorded Audio The below is a method to allow Warcraft Recorder to record the system audio while ignoring discord audio. Thanks to Maxi for submitting these instructions. ## Instructions 1. Download the latest version of [Voicemeeter Banana](https://vb-audio.com/Voicemeeter/banana.htm). 1. Install and reboot your computer. 1. Set up the input and output for Voicemeeter Banana in the Windows audio settings playback. - Right click on 'Voicemeeter Input' and select 'Default Device'. - Right click your desired speaker and select 'Default Communications Device'. 1. Switch to the Recordings tab - Right click on 'Voicemeeter Output' and select 'Default Device'. - Right click your desired microphone and select 'Default Communications Device'. 1. Open Voicemeeter Banana. - Click on the menu button in the top right of the program. - Set to automatically launch with Windows. 1. Set your desired speaker output within Voicemeeter. - Click A1 in the top right and select your speaker. 1. Open Discord settings and go to Voice and Video tab. - Choose 'Voice Meeter Aux Input' as your 'Output Device'. 1. Open Warcraft Recorder settings and set the audio device to VoiceMeeter. 1. Enjoy! ## Tips - Rename your output columns so you don’t get confused on which is which. Just right click the area circled. VAIO is your nonfiltered audio and AUX is the filtered discord audio. - You can have multiple audio outputs for different speaker/headset configs by selecting them in the A1 A2 A3 in the top right. Put your secondary setup on A2 and then whenever you want to swap from headset to speaker or whatever you have setup select them. ================================================ FILE: docs/Localisation.md ================================================ # How to Add A Language 1. Copy the file `src/localisation/english.ts` to another language e.g. `src/localisation/klingon.ts`. 2. Replace all the strings with the appropriate translation. 3. Add an entry in the `Languages` enum in `src/localisation/types.ts`. For example: ``` enum Language { ENGLISH = 'English', KOREAN = 'Korean', ... KLINGON = 'Klingon', } ``` 4. Add an entry to the data variable in `src/localisation/translations.ts` to point to the new translations. For example: ``` import KlingonTranslations from './Klingon'; ... const data: LocalizationDataType = { [Language.ENGLISH]: EnglishTranslations, [Language.KOREAN]: KoreanTranslations, ... [Language.KLINGON]: KlingonTranslations, }; ``` 5. Optionally update the VideoFilter class to include search strings for the new language. These won't translate automatically. If you skip this the search bar won't be usable in the new language. 6. That's it. You should now be able to select the new language in the settings page. # How To Add User-Facing Strings I've just added localisation support to the client. All users facing text in the application should now be populated via the localisation infrastructure. That means no hard coded english user facing strings, unless there is good reason for it. To add a new localised phrase through the you must: 1. Add it to the `Phrase` enum in `src/localisation/types.ts`. For example: ``` enum Phrase { ... SettingsDisabledText } ``` 2. Add a translation to each language the client supports (see `src/localisation/english.ts` for example). This is well typed, so you should see warnings if you miss this. For example: ``` ... [Phrase.SettingsDisabledText]: 'Invalid retail log path.', ``` 3. In the appropriate component where you want to display the phrase, render it using the `getLocalPhrase(language, phrase)` function. For example: ``` {getLocalePhrase(appState.language, Phrase.SettingsDisabledText)} ``` Obviously this doesn't apply to anything internal. That should all remain in english. ================================================ FILE: docs/LocateLogDirectory.md ================================================ # How to find your World of Warcraft combat log directory It can be a bit confusing to find the right directory where your Warcraft combat logs are saved if you've never dealt with them before, so here's a quick step-by-step guide to how to find it for any flavour of Warcraft. # Instructions 1. Open the Battle.net Launcher 2. Select the version of WoW you want to record 3. Click the gear-icon to the right of the "_Play_" button 4. Click on "_Show in Explorer_"
![](https://i.imgur.com/7Pf5Dk2.png) 5. Double-click on the flavour of WoW you want to record. One of `_classic`, `_retail_`, `_ptr_`, or `_beta_`
![](https://i.imgur.com/5odiFIw.png) 6. Double-click the folder `Logs`
![](https://i.imgur.com/TRWVpko.png) 7. Right click the address bar in Explorer and copy the location
![](https://i.imgur.com/4pegWpB.png) 8. This location can be pasted into the folder selection box in Warcraft Recorder # Tips If you're using Windows 11, you can stop at step 6 and simply right click the `Logs` folder and select "_Copy as path_". ================================================ FILE: docs/SettingsReference.md ================================================ # Settings Reference This is intended to be a quick reference to help understand the configuration we can apply to libobs via the obs-studio-node package. It's been collated using the following functions: - OBS_settings_getListCategories - OBS_settings_getSettings ## Categories ``` [ 'General', 'Stream', 'Output', 'Audio', 'Video', 'Hotkeys', 'Advanced' ] ``` ## General ``` { data: [ { nameSubCategory: 'Output', parameters: [ { name: 'WarnBeforeStartingStream', type: 'OBS_PROPERTY_BOOL', description: 'Show confirmation dialog when starting streams', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false }, { name: 'WarnBeforeStoppingStream', type: 'OBS_PROPERTY_BOOL', description: 'Show confirmation dialog when stopping streams', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false }, { name: 'RecordWhenStreaming', type: 'OBS_PROPERTY_BOOL', description: 'Automatically record when streaming', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false }, { name: 'KeepRecordingWhenStreamStops', type: 'OBS_PROPERTY_BOOL', description: 'Keep recording when stream stops', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false }, { name: 'ReplayBufferWhileStreaming', type: 'OBS_PROPERTY_BOOL', description: 'Automatically start replay buffer when streaming', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false }, { name: 'KeepReplayBufferStreamStops', type: 'OBS_PROPERTY_BOOL', description: 'Keep replay buffer active when stream stops', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false } ] }, { nameSubCategory: 'Source Alignement Snapping', parameters: [ { name: 'SnappingEnabled', type: 'OBS_PROPERTY_BOOL', description: 'Enable', subType: '', currentValue: true, values: [], visible: true, enabled: true, masked: false }, { name: 'SnapDistance', type: 'OBS_PROPERTY_DOUBLE', description: 'Snap Sensitivity', subType: '', currentValue: 10, minVal: 0, maxVal: 100, stepVal: 0.5, values: [], visible: true, enabled: true, masked: false }, { name: 'ScreenSnapping', type: 'OBS_PROPERTY_BOOL', description: 'Snap Sources to edge of screen', subType: '', currentValue: true, values: [], visible: true, enabled: true, masked: false }, { name: 'SourceSnapping', type: 'OBS_PROPERTY_BOOL', description: 'Snap Sources to other sources', subType: '', currentValue: true, values: [], visible: true, enabled: true, masked: false }, { name: 'CenterSnapping', type: 'OBS_PROPERTY_BOOL', description: 'Snap Sources to horizontal and vertical center', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false } ] }, { nameSubCategory: 'Projectors', parameters: [ { name: 'HideProjectorCursor', type: 'OBS_PROPERTY_BOOL', description: 'Hide cursor over projectors', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false }, { name: 'ProjectorAlwaysOnTop', type: 'OBS_PROPERTY_BOOL', description: 'Make projectors always on top', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false }, { name: 'SaveProjectors', type: 'OBS_PROPERTY_BOOL', description: 'Save projectors on exit', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false } ] }, { nameSubCategory: 'System Tray', parameters: [ { name: 'SysTrayEnabled', type: 'OBS_PROPERTY_BOOL', description: 'Enable', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false }, { name: 'SysTrayWhenStarted', type: 'OBS_PROPERTY_BOOL', description: 'Minimize to system tray when started', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false }, { name: 'SysTrayMinimizeToTray', type: 'OBS_PROPERTY_BOOL', description: 'Always minimize to system tray instead of task bar', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false } ] } ], type: 0 } ``` ## Video ``` { data: [ { nameSubCategory: 'Untitled', parameters: [ { name: 'Base', type: 'OBS_INPUT_RESOLUTION_LIST', description: 'Base (Canvas) Resolution', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: '1280x720', values: [ { '1920x1080': '1920x1080' }, { '1280x720': '1280x720' }, { '1080x1920': '1080x1920' } ], visible: true, enabled: false, masked: false }, { name: 'Output', type: 'OBS_INPUT_RESOLUTION_LIST', description: 'Output (Scaled) Resolution', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: '640x360', values: [ { '1280x720': '1280x720' }, { '1024x576': '1024x576' }, { '960x540': '960x540' }, { '852x480': '852x480' }, { '768x432': '768x432' }, { '730x410': '730x410' }, { '640x360': '640x360' }, { '568x320': '568x320' }, { '512x288': '512x288' }, { '464x260': '464x260' }, { '426x240': '426x240' } ], visible: true, enabled: false, masked: false }, { name: 'ScaleType', type: 'OBS_PROPERTY_LIST', description: 'Downscale Filter', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'bicubic', values: [ { 'Bilinear (Fastest, but blurry if scaling)': 'bilinear' }, { 'Bicubic (Sharpened scaling, 16 samples)': 'bicubic' }, { 'Lanczos (Sharpened scaling, 32 samples)': 'lanczos' } ], visible: true, enabled: false, masked: false }, { name: 'FPSType', type: 'OBS_PROPERTY_LIST', description: 'FPS Type', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'Common FPS Values', values: [ { 'Common FPS Values': 'Common FPS Values' }, { 'Integer FPS Value': 'Integer FPS Value' }, { 'Fractional FPS Value': 'Fractional FPS Value' } ], visible: true, enabled: false, masked: false }, { name: 'FPSCommon', type: 'OBS_PROPERTY_LIST', description: 'Common FPS Values', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: '60', values: [ { '10': '10' }, { '20': '20' }, { '24 NTSC': '24 NTSC' }, { '29.97': '29.97' }, { '30': '30' }, { '48': '48' }, { '59.94': '59.94' }, { '60': '60' } ], visible: true, enabled: false, masked: false } ] } ], type: 0 } ``` ## Output ``` { data: [ { nameSubCategory: 'Untitled', parameters: [ { name: 'Mode', type: 'OBS_PROPERTY_LIST', description: 'Output Mode', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'Advanced', values: [ { Simple: 'Simple' }, { Advanced: 'Advanced' } ], visible: true, enabled: false, masked: false } ] }, { nameSubCategory: 'Streaming', parameters: [ { name: 'TrackIndex', type: 'OBS_PROPERTY_LIST', description: 'Audio Track', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: '1', values: [ { '1': '1' }, { '2': '2' }, { '3': '3' }, { '4': '4' }, { '5': '5' }, { '6': '6' } ], visible: true, enabled: false, masked: false }, { name: 'VodTrackEnabled', type: 'OBS_PROPERTY_BOOL', description: 'Twitch VOD', subType: '', currentValue: false, values: [], visible: true, enabled: false, masked: false }, { name: 'Encoder', type: 'OBS_PROPERTY_LIST', description: 'Encoder', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'obs_x264', values: [ { 'Software (x264)': 'obs_x264' }, { 'Hardware (NVENC)': 'ffmpeg_nvenc' }, { 'Hardware (NVENC) (new)': 'jim_nvenc' } ], visible: true, enabled: false, masked: false }, { name: 'ApplyServiceSettings', type: 'OBS_PROPERTY_BOOL', description: 'Enforce streaming service encoder settings', subType: '', currentValue: true, values: [], visible: true, enabled: false, masked: false }, { name: 'Rescale', type: 'OBS_PROPERTY_BOOL', description: 'Rescale Output', subType: '', currentValue: false, values: [], visible: true, enabled: false, masked: false }, { name: 'rate_control', type: 'OBS_PROPERTY_LIST', description: 'Rate Control', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'CBR', values: [ { CBR: 'CBR' }, { ABR: 'ABR' }, { VBR: 'VBR' }, { CRF: 'CRF' } ], visible: true, enabled: false, masked: false }, { name: 'bitrate', type: 'OBS_PROPERTY_INT', description: 'Bitrate', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 2500, minVal: 50, maxVal: 10000000, stepVal: 50, values: [ { CBR: 'CBR' }, { ABR: 'ABR' }, { VBR: 'VBR' }, { CRF: 'CRF' } ], visible: true, enabled: false, masked: false }, { name: 'use_bufsize', type: 'OBS_PROPERTY_BOOL', description: 'Use Custom Buffer Size', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: false, values: [ { CBR: 'CBR' }, { ABR: 'ABR' }, { VBR: 'VBR' }, { CRF: 'CRF' } ], visible: true, enabled: false, masked: false }, { name: 'buffer_size', type: 'OBS_PROPERTY_INT', description: 'Buffer Size', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 2500, minVal: 0, maxVal: 10000000, stepVal: 1, values: [ { CBR: 'CBR' }, { ABR: 'ABR' }, { VBR: 'VBR' }, { CRF: 'CRF' } ], visible: false, enabled: false, masked: false }, { name: 'crf', type: 'OBS_PROPERTY_INT', description: 'CRF', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 23, minVal: 0, maxVal: 51, stepVal: 1, values: [ { CBR: 'CBR' }, { ABR: 'ABR' }, { VBR: 'VBR' }, { CRF: 'CRF' } ], visible: false, enabled: false, masked: false }, { name: 'keyint_sec', type: 'OBS_PROPERTY_INT', description: 'Keyframe Interval (seconds, 0=auto)', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 0, minVal: 0, maxVal: 20, stepVal: 1, values: [ { CBR: 'CBR' }, { ABR: 'ABR' }, { VBR: 'VBR' }, { CRF: 'CRF' } ], visible: true, enabled: false, masked: false }, { name: 'preset', type: 'OBS_PROPERTY_LIST', description: 'CPU Usage Preset (higher = less CPU)', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'veryfast', values: [ { ultrafast: 'ultrafast' }, { superfast: 'superfast' }, { veryfast: 'veryfast' }, { faster: 'faster' }, { fast: 'fast' }, { medium: 'medium' }, { slow: 'slow' }, { slower: 'slower' }, { veryslow: 'veryslow' }, { placebo: 'placebo' } ], visible: true, enabled: false, masked: false }, { name: 'profile', type: 'OBS_PROPERTY_LIST', description: 'Profile', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: '', values: [ { '(None)': '' }, { baseline: 'baseline' }, { main: 'main' }, { high: 'high' } ], visible: true, enabled: false, masked: false }, { name: 'tune', type: 'OBS_PROPERTY_LIST', description: 'Tune', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: '', values: [ { '(None)': '' }, { film: 'film' }, { animation: 'animation' }, { grain: 'grain' }, { stillimage: 'stillimage' }, { psnr: 'psnr' }, { ssim: 'ssim' }, { fastdecode: 'fastdecode' }, { zerolatency: 'zerolatency' } ], visible: true, enabled: false, masked: false }, { name: 'x264opts', type: 'OBS_PROPERTY_TEXT', description: 'x264 Options (separated by space)', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: '', values: [ { '(None)': '' }, { film: 'film' }, { animation: 'animation' }, { grain: 'grain' }, { stillimage: 'stillimage' }, { psnr: 'psnr' }, { ssim: 'ssim' }, { fastdecode: 'fastdecode' }, { zerolatency: 'zerolatency' } ], visible: true, enabled: false, masked: false }, { name: 'repeat_headers', type: 'OBS_PROPERTY_BOOL', description: 'repeat_headers', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: false, values: [ { '(None)': '' }, { film: 'film' }, { animation: 'animation' }, { grain: 'grain' }, { stillimage: 'stillimage' }, { psnr: 'psnr' }, { ssim: 'ssim' }, { fastdecode: 'fastdecode' }, { zerolatency: 'zerolatency' } ], visible: false, enabled: false, masked: false } ] }, { nameSubCategory: 'Recording', parameters: [ { name: 'RecType', type: 'OBS_PROPERTY_LIST', description: 'Type', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'Standard', values: [ { Standard: 'Standard' } ], visible: true, enabled: false, masked: false }, { name: 'RecFilePath', type: 'OBS_PROPERTY_PATH', description: 'Recording Path', subType: '', currentValue: 'D:\\wow-recorder-files\\.temp', values: [], visible: true, enabled: false, masked: false }, { name: 'RecFileNameWithoutSpace', type: 'OBS_PROPERTY_BOOL', description: 'Generate File Name without Space', subType: '', currentValue: false, values: [], visible: true, enabled: false, masked: false }, { name: 'RecFormat', type: 'OBS_PROPERTY_LIST', description: 'Recording Format', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'mp4', values: [ { mp4: 'mp4' }, { flv: 'flv' }, { mov: 'mov' }, { mkv: 'mkv' }, { ts: 'ts' }, { m3u8: 'm3u8' } ], visible: true, enabled: false, masked: false }, { name: 'RecTracks', type: 'OBS_PROPERTY_BITMASK', description: 'Audio Track', subType: '', currentValue: 63, minVal: -200, maxVal: 200, stepVal: 1, values: [], visible: true, enabled: true, masked: false }, { name: 'RecEncoder', type: 'OBS_PROPERTY_LIST', description: 'Recording', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'jim_nvenc', values: [ { 'Use stream encoder': 'none' }, { 'Software (x264)': 'obs_x264' }, { 'Hardware (NVENC)': 'ffmpeg_nvenc' }, { 'Hardware (NVENC) (new)': 'jim_nvenc' } ], visible: true, enabled: false, masked: false }, { name: 'RecRescale', type: 'OBS_PROPERTY_BOOL', description: 'Rescale Output', subType: '', currentValue: false, values: [], visible: false, enabled: false, masked: false }, { name: 'RecMuxerCustom', type: 'OBS_PROPERTY_EDIT_TEXT', description: 'Custom Muxer Settings', subType: '', currentValue: '', values: [], visible: true, enabled: false, masked: false }, { name: 'Recrate_control', type: 'OBS_PROPERTY_LIST', description: 'Rate Control', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'VBR', values: [ { CBR: 'CBR' }, { CQP: 'CQP' }, { VBR: 'VBR' }, { Lossless: 'lossless' } ], visible: true, enabled: false, masked: false }, { name: 'Recbitrate', type: 'OBS_PROPERTY_INT', description: 'Bitrate', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 15360, minVal: 50, maxVal: 300000, stepVal: 50, values: [ { CBR: 'CBR' }, { CQP: 'CQP' }, { VBR: 'VBR' }, { Lossless: 'lossless' } ], visible: true, enabled: false, masked: false }, { name: 'Recmax_bitrate', type: 'OBS_PROPERTY_INT', description: 'Max Bitrate', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 300000, minVal: 50, maxVal: 300000, stepVal: 50, values: [ { CBR: 'CBR' }, { CQP: 'CQP' }, { VBR: 'VBR' }, { Lossless: 'lossless' } ], visible: true, enabled: false, masked: false }, { name: 'Reccqp', type: 'OBS_PROPERTY_INT', description: 'CQ Level', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 20, minVal: 1, maxVal: 30, stepVal: 1, values: [ { CBR: 'CBR' }, { CQP: 'CQP' }, { VBR: 'VBR' }, { Lossless: 'lossless' } ], visible: false, enabled: false, masked: false }, { name: 'Reckeyint_sec', type: 'OBS_PROPERTY_INT', description: 'Keyframe Interval (seconds, 0=auto)', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 0, minVal: 0, maxVal: 10, stepVal: 1, values: [ { CBR: 'CBR' }, { CQP: 'CQP' }, { VBR: 'VBR' }, { Lossless: 'lossless' } ], visible: true, enabled: false, masked: false }, { name: 'Recpreset', type: 'OBS_PROPERTY_LIST', description: 'Preset', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'hq', values: [ { 'Max Quality': 'mq' }, { Quality: 'hq' }, { Performance: 'default' }, { 'Max Performance': 'hp' }, { 'Low-Latency': 'll' }, { 'Low-Latency Quality': 'llhq' }, { 'Low-Latency Performance': 'llhp' } ], visible: true, enabled: false, masked: false }, { name: 'Recprofile', type: 'OBS_PROPERTY_LIST', description: 'Profile', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'high', values: [ { high: 'high' }, { main: 'main' }, { baseline: 'baseline' } ], visible: true, enabled: false, masked: false }, { name: 'Reclookahead', type: 'OBS_PROPERTY_BOOL', description: 'Look-ahead', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: false, values: [ { high: 'high' }, { main: 'main' }, { baseline: 'baseline' } ], visible: true, enabled: false, masked: false }, { name: 'Recrepeat_headers', type: 'OBS_PROPERTY_BOOL', description: 'repeat_headers', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: false, values: [ { high: 'high' }, { main: 'main' }, { baseline: 'baseline' } ], visible: false, enabled: false, masked: false }, { name: 'Recpsycho_aq', type: 'OBS_PROPERTY_BOOL', description: 'Psycho Visual Tuning', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: true, values: [ { high: 'high' }, { main: 'main' }, { baseline: 'baseline' } ], visible: true, enabled: false, masked: false }, { name: 'Recgpu', type: 'OBS_PROPERTY_INT', description: 'GPU', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 0, minVal: 0, maxVal: 8, stepVal: 1, values: [ { high: 'high' }, { main: 'main' }, { baseline: 'baseline' } ], visible: true, enabled: false, masked: false }, { name: 'Recbf', type: 'OBS_PROPERTY_INT', description: 'Max B-frames', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 2, minVal: 0, maxVal: 4, stepVal: 1, values: [ { high: 'high' }, { main: 'main' }, { baseline: 'baseline' } ], visible: true, enabled: false, masked: false } ] }, { nameSubCategory: 'Audio - Track 1', parameters: [ { name: 'Track1Bitrate', type: 'OBS_PROPERTY_LIST', description: 'Audio Bitrate', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: '160', values: [ { '64': '64' }, { '96': '96' }, { '128': '128' }, { '160': '160' }, { '192': '192' }, { '224': '224' }, { '256': '256' }, { '288': '288' }, { '320': '320' } ], visible: true, enabled: false, masked: false }, { name: 'Track1Name', type: 'OBS_PROPERTY_EDIT_TEXT', description: 'Name', subType: '', currentValue: 'Mixed: all sources', values: [], visible: true, enabled: false, masked: false } ] }, { nameSubCategory: 'Audio - Track 2', parameters: [ { name: 'Track2Bitrate', type: 'OBS_PROPERTY_LIST', description: 'Audio Bitrate', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: '160', values: [ { '64': '64' }, { '96': '96' }, { '128': '128' }, { '160': '160' }, { '192': '192' }, { '224': '224' }, { '256': '256' }, { '288': '288' }, { '320': '320' } ], visible: true, enabled: false, masked: false }, { name: 'Track2Name', type: 'OBS_PROPERTY_EDIT_TEXT', description: 'Name', subType: '', currentValue: 'Microphone (3- G533 Gaming Headset)', values: [], visible: true, enabled: false, masked: false } ] }, { nameSubCategory: 'Audio - Track 3', parameters: [ { name: 'Track3Bitrate', type: 'OBS_PROPERTY_LIST', description: 'Audio Bitrate', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: '160', values: [ { '64': '64' }, { '96': '96' }, { '128': '128' }, { '160': '160' }, { '192': '192' }, { '224': '224' }, { '256': '256' }, { '288': '288' }, { '320': '320' } ], visible: true, enabled: false, masked: false }, { name: 'Track3Name', type: 'OBS_PROPERTY_EDIT_TEXT', description: 'Name', subType: '', currentValue: 'BenQ GW2480 (NVIDIA High Definition Audio)', values: [], visible: true, enabled: false, masked: false } ] }, { nameSubCategory: 'Audio - Track 4', parameters: [ { name: 'Track4Bitrate', type: 'OBS_PROPERTY_LIST', description: 'Audio Bitrate', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: '160', values: [ { '64': '64' }, { '96': '96' }, { '128': '128' }, { '160': '160' }, { '192': '192' }, { '224': '224' }, { '256': '256' }, { '288': '288' }, { '320': '320' } ], visible: true, enabled: false, masked: false }, { name: 'Track4Name', type: 'OBS_PROPERTY_EDIT_TEXT', description: 'Name', subType: '', currentValue: 'Digital Audio (S/PDIF) (High Definition Audio Device)', values: [], visible: true, enabled: false, masked: false } ] }, { nameSubCategory: 'Audio - Track 5', parameters: [ { name: 'Track5Bitrate', type: 'OBS_PROPERTY_LIST', description: 'Audio Bitrate', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: '160', values: [ { '64': '64' }, { '96': '96' }, { '128': '128' }, { '160': '160' }, { '192': '192' }, { '224': '224' }, { '256': '256' }, { '288': '288' }, { '320': '320' } ], visible: true, enabled: false, masked: false }, { name: 'Track5Name', type: 'OBS_PROPERTY_EDIT_TEXT', description: 'Name', subType: '', currentValue: 'Speakers (3- G533 Gaming Headset)', values: [], visible: true, enabled: false, masked: false } ] }, { nameSubCategory: 'Audio - Track 6', parameters: [ { name: 'Track6Bitrate', type: 'OBS_PROPERTY_LIST', description: 'Audio Bitrate', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: '160', values: [ { '64': '64' }, { '96': '96' }, { '128': '128' }, { '160': '160' }, { '192': '192' }, { '224': '224' }, { '256': '256' }, { '288': '288' }, { '320': '320' } ], visible: true, enabled: false, masked: false }, { name: 'Track6Name', type: 'OBS_PROPERTY_EDIT_TEXT', description: 'Name', subType: '', currentValue: 'BenQ GW2480 (NVIDIA High Definition Audio)', values: [], visible: true, enabled: false, masked: false } ] }, { nameSubCategory: 'Replay Buffer', parameters: [ { name: 'RecRB', type: 'OBS_PROPERTY_BOOL', description: 'Enable Replay Buffer', subType: '', currentValue: true, values: [], visible: true, enabled: false, masked: false }, { name: 'RecRBTime', type: 'OBS_PROPERTY_INT', description: 'Maximum Replay Time (Seconds)', subType: '', currentValue: 20, minVal: 0, maxVal: 21599, stepVal: 0, values: [], visible: true, enabled: false, masked: false } ] } ], type: 1 } ``` ## Audio ``` { data: [ { nameSubCategory: 'Untitled', parameters: [ { name: 'SampleRate', type: 'OBS_PROPERTY_LIST', description: 'Sample Rate (requires a restart)', subType: 'OBS_COMBO_FORMAT_INT', currentValue: 44100, minVal: -200, maxVal: 200, stepVal: 1, values: [ { '44.1khz': 44100 }, { '48khz': 48000 } ], visible: true, enabled: true, masked: false }, { name: 'ChannelSetup', type: 'OBS_PROPERTY_LIST', description: 'Channels (requires a restart)', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'Stereo', values: [ { Mono: 'Mono' }, { Stereo: 'Stereo' }, { '2.1': '2.1' }, { '4.0': '4.0' }, { '4.1': '4.1' }, { '5.1': '5.1' }, { '7.1': '7.1' } ], visible: true, enabled: true, masked: false } ] } ], type: 0 } ``` ## Advanced ``` data: [ { nameSubCategory: 'General', parameters: [ { name: 'ProcessPriority', type: 'OBS_PROPERTY_LIST', description: 'Process Priority', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'Normal', values: [ { High: 'High' }, { 'Above Normal': 'AboveNormal' }, { Normal: 'Normal' }, { 'Below Normal': 'BelowNormal' }, { Idle: 'Idle' } ], visible: true, enabled: true, masked: false } ] }, { nameSubCategory: 'Video', parameters: [ { name: 'ColorFormat', type: 'OBS_PROPERTY_LIST', description: 'Color Format', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'NV12', values: [ { NV12: 'NV12' }, { I420: 'I420' }, { I444: 'I444' }, { RGB: 'RGB' } ], visible: true, enabled: true, masked: false }, { name: 'ColorSpace', type: 'OBS_PROPERTY_LIST', description: 'YUV Color Space', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: '601', values: [ { '601': '601' }, { '709': '709' } ], visible: true, enabled: true, masked: false }, { name: 'ColorRange', type: 'OBS_PROPERTY_LIST', description: 'YUV Color Range', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'Partial', values: [ { Partial: 'Partial' }, { Full: 'Full' } ], visible: true, enabled: true, masked: false }, { name: 'ForceGPUAsRenderDevice', type: 'OBS_PROPERTY_BOOL', description: 'Force GPU as render device', subType: '', currentValue: true, values: [], visible: true, enabled: true, masked: false } ] }, { nameSubCategory: 'Audio', parameters: [ { name: 'MonitoringDeviceName', type: 'OBS_PROPERTY_LIST', description: 'Audio Monitoring Device', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'Default', values: [ { Default: 'Default' }, { 'BenQ GW2480 (NVIDIA High Definition Audio)': 'BenQ GW2480 (NVIDIA High Definition Audio)' }, { 'Digital Audio (S/PDIF) (High Definition Audio Device)': 'Digital Audio (S/PDIF) (High Definition Audio Device)' }, { 'Speakers (3- G533 Gaming Headset)': 'Speakers (3- G533 Gaming Headset)' }, { 'BenQ GW2480 (NVIDIA High Definition Audio)': 'BenQ GW2480 (NVIDIA High Definition Audio)' } ], visible: true, enabled: true, masked: false }, { name: 'DisableAudioDucking', type: 'OBS_PROPERTY_BOOL', description: 'Disable Windows audio ducking', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false } ] }, { nameSubCategory: 'Recording', parameters: [ { name: 'FilenameFormatting', type: 'OBS_PROPERTY_EDIT_TEXT', description: 'Filename Formatting', subType: '', currentValue: '%CCYY-%MM-%DD %hh-%mm-%ss', values: [], visible: true, enabled: true, masked: false }, { name: 'OverwriteIfExists', type: 'OBS_PROPERTY_BOOL', description: 'Overwrite if file exists', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false } ] }, { nameSubCategory: 'Replay Buffer', parameters: [ { name: 'RecRBPrefix', type: 'OBS_PROPERTY_EDIT_TEXT', description: 'Replay Buffer Filename Prefix', subType: '', currentValue: 'Replay', values: [], visible: true, enabled: true, masked: false }, { name: 'RecRBSuffix', type: 'OBS_PROPERTY_EDIT_TEXT', description: 'Replay Buffer Filename Suffix', subType: '', currentValue: '', values: [], visible: true, enabled: true, masked: false } ] }, { nameSubCategory: 'Stream Delay', parameters: [ { name: 'DelayEnable', type: 'OBS_PROPERTY_BOOL', description: 'Enable', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false }, { name: 'DelaySec', type: 'OBS_PROPERTY_INT', description: 'Duration (seconds)', subType: '', currentValue: 20, minVal: 0, maxVal: 1800, stepVal: 0, values: [], visible: true, enabled: true, masked: false }, { name: 'DelayPreserve', type: 'OBS_PROPERTY_BOOL', description: 'Preserved cutoff point (increase delay) when reconnecting', subType: '', currentValue: true, values: [], visible: true, enabled: true, masked: false } ] }, { nameSubCategory: 'Automatically Reconnect', parameters: [ { name: 'Reconnect', type: 'OBS_PROPERTY_BOOL', description: 'Enable', subType: '', currentValue: true, values: [], visible: true, enabled: true, masked: false }, { name: 'RetryDelay', type: 'OBS_PROPERTY_INT', description: 'Retry Delay (seconds)', subType: '', currentValue: 10, minVal: 0, maxVal: 30, stepVal: 0, values: [], visible: true, enabled: true, masked: false }, { name: 'MaxRetries', type: 'OBS_PROPERTY_INT', description: 'Maximum Retries', subType: '', currentValue: 20, minVal: 0, maxVal: 10000, stepVal: 0, values: [], visible: true, enabled: true, masked: false } ] }, { nameSubCategory: 'Network', parameters: [ { name: 'BindIP', type: 'OBS_PROPERTY_LIST', description: 'Bind to IP', subType: 'OBS_COMBO_FORMAT_STRING', currentValue: 'default', values: [ { Default: 'default' }, { '[Ethernet] 2a00:23c8:75a6:ab01:bf7a:f4cf:9148:d7dc': '2a00:23c8:75a6:ab01:bf7a:f4cf:9148:d7dc' }, { '[Ethernet] 2a00:23c8:75a6:ab01:6963:4a2b:896d:8e2a': '2a00:23c8:75a6:ab01:6963:4a2b:896d:8e2a' }, { '[Ethernet] fe80::65b8:61b9:a3e6:17c6': 'fe80::65b8:61b9:a3e6:17c6' }, { '[Ethernet] 192.168.1.108': '192.168.1.108' } ], visible: true, enabled: true, masked: false }, { name: 'DynamicBitrate', type: 'OBS_PROPERTY_BOOL', description: 'Dynamically change bitrate when dropping frames while streaming', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false }, { name: 'NewSocketLoopEnable', type: 'OBS_PROPERTY_BOOL', description: 'Enable new networking code', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false }, { name: 'LowLatencyEnable', type: 'OBS_PROPERTY_BOOL', description: 'Low latency mode', subType: '', currentValue: false, values: [], visible: true, enabled: true, masked: false } ] }, { nameSubCategory: 'Sources', parameters: [ { name: 'browserHWAccel', type: 'OBS_PROPERTY_BOOL', description: 'Enable Browser Source Hardware Acceleration (requires a restart)', subType: '', currentValue: true, values: [], visible: true, enabled: true, masked: false } ] }, { nameSubCategory: 'Media Files', parameters: [ { name: 'fileCaching', type: 'OBS_PROPERTY_BOOL', description: 'Enable media file caching', subType: '', currentValue: true, values: [], visible: true, enabled: true, masked: false } ] } ], type: 0 } ``` ================================================ FILE: docs/design.excalidraw ================================================ { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [ { "id": "CbHsQOiiQNNJLMyXsIJF1", "type": "diamond", "x": 552, "y": 243, "width": 165, "height": 157, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "seed": 590750557, "version": 46, "versionNonce": 324355443, "isDeleted": false, "boundElements": [ { "type": "text", "id": "CcOngIv622Dcnb9HId_EO" } ], "updated": 1701528460326, "link": null, "locked": false }, { "id": "CcOngIv622Dcnb9HId_EO", "type": "text", "x": 613.5600128173828, "y": 309.25, "width": 42.379974365234375, "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, "seed": 808346493, "version": 10, "versionNonce": 1805795133, "isDeleted": false, "boundElements": null, "updated": 1701528460326, "link": null, "locked": false, "text": "Main", "fontSize": 20, "fontFamily": 1, "textAlign": "center", "verticalAlign": "middle", "baseline": 18, "containerId": "CbHsQOiiQNNJLMyXsIJF1", "originalText": "Main", "lineHeight": 1.25 }, { "id": "TfEyMabeUHphyWTL5BWG1", "type": "rectangle", "x": 833, "y": 283, "width": 840.0000000000001, "height": 62, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "seed": 1938783965, "version": 178, "versionNonce": 1175592723, "isDeleted": false, "boundElements": [ { "type": "text", "id": "fsPEHQbcMbE4sCgezkEEa" }, { "id": "WbV7-s4YvinNTzg5dEAd8", "type": "arrow" } ], "updated": 1701528460326, "link": null, "locked": false }, { "id": "fsPEHQbcMbE4sCgezkEEa", "type": "text", "x": 1212.5500259399414, "y": 301.5, "width": 80.89994812011719, "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, "seed": 1094090867, "version": 161, "versionNonce": 1928417181, "isDeleted": false, "boundElements": null, "updated": 1701528460326, "link": null, "locked": false, "text": "Manager", "fontSize": 20, "fontFamily": 1, "textAlign": "center", "verticalAlign": "middle", "baseline": 18, "containerId": "TfEyMabeUHphyWTL5BWG1", "originalText": "Manager", "lineHeight": 1.25 }, { "id": "WbV7-s4YvinNTzg5dEAd8", "type": "arrow", "x": 716, "y": 322, "width": 109, "height": 0.6917760933026216, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "seed": 159277555, "version": 122, "versionNonce": 1799537843, "isDeleted": false, "boundElements": null, "updated": 1701528460326, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 109, 0.6917760933026216 ] ], "lastCommittedPoint": null, "startBinding": null, "endBinding": { "elementId": "TfEyMabeUHphyWTL5BWG1", "gap": 8, "focus": -0.33886583679114796 }, "startArrowhead": null, "endArrowhead": "arrow" }, { "id": "HplPTFfk5pAmBwRJC3RPD", "type": "rectangle", "x": 837, "y": 363, "width": 161, "height": 137, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "seed": 1332978323, "version": 66, "versionNonce": 1925141501, "isDeleted": false, "boundElements": [ { "type": "text", "id": "rMqjgulXWepvTLz1mA7fD" } ], "updated": 1701528460326, "link": null, "locked": false }, { "id": "rMqjgulXWepvTLz1mA7fD", "type": "text", "x": 874.9300384521484, "y": 419, "width": 85.13992309570312, "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, "seed": 689060627, "version": 55, "versionNonce": 445494867, "isDeleted": false, "boundElements": null, "updated": 1701528460326, "link": null, "locked": false, "text": "Recorder", "fontSize": 20, "fontFamily": 1, "textAlign": "center", "verticalAlign": "middle", "baseline": 18, "containerId": "HplPTFfk5pAmBwRJC3RPD", "originalText": "Recorder", "lineHeight": 1.25 }, { "type": "rectangle", "version": 266, "versionNonce": 1728957533, "isDeleted": false, "id": "gSlCOsVwgi53081jkPoEF", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1006.5, "y": 429.5, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 161, "height": 137, "seed": 2119388211, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "1fCMhO5AwWR-RNyD2sMyA" } ], "updated": 1701528460326, "link": null, "locked": false }, { "type": "text", "version": 277, "versionNonce": 852063219, "isDeleted": false, "id": "1fCMhO5AwWR-RNyD2sMyA", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1035.5200424194336, "y": 473, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 102.95991516113281, "height": 50, "seed": 421613011, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1701528460326, "link": null, "locked": false, "fontSize": 20, "fontFamily": 1, "text": "Retail\nLogHandler", "textAlign": "center", "verticalAlign": "middle", "containerId": "gSlCOsVwgi53081jkPoEF", "originalText": "Retail\nLogHandler", "lineHeight": 1.25, "baseline": 43 }, { "type": "rectangle", "version": 320, "versionNonce": 1780973075, "isDeleted": false, "id": "PGPwp0WDskV9DOSBZv5mL", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1171.5, "y": 428.5, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 161, "height": 137, "seed": 68957437, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "0J7lF05d5oP3JtbmRLrmS" }, { "id": "W29-fhpcUW_H-TTXv-JZu", "type": "arrow" }, { "id": "2heO_-ZrTNX1YYjnin09m", "type": "arrow" } ], "updated": 1701528512028, "link": null, "locked": false }, { "type": "text", "version": 349, "versionNonce": 1945927059, "isDeleted": false, "id": "0J7lF05d5oP3JtbmRLrmS", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1200.5200424194336, "y": 472, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 102.95991516113281, "height": 50, "seed": 1546030429, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1701528460327, "link": null, "locked": false, "fontSize": 20, "fontFamily": 1, "text": "Classic \nLogHandler", "textAlign": "center", "verticalAlign": "middle", "containerId": "PGPwp0WDskV9DOSBZv5mL", "originalText": "Classic LogHandler", "lineHeight": 1.25, "baseline": 43 }, { "type": "rectangle", "version": 242, "versionNonce": 910301469, "isDeleted": false, "id": "w-ztniup6l59ROVzdYUH6", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1338.5, "y": 363.5, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 161, "height": 137, "seed": 1286256093, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "vLvBzoXNiHD6YvQ0aH5DA" } ], "updated": 1701528460327, "link": null, "locked": false }, { "type": "text", "version": 295, "versionNonce": 1499288371, "isDeleted": false, "id": "vLvBzoXNiHD6YvQ0aH5DA", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1381.2000427246094, "y": 394.5, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 75.59991455078125, "height": 75, "seed": 351400509, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1701528460327, "link": null, "locked": false, "fontSize": 20, "fontFamily": 1, "text": "Video\nProcess\nQueue", "textAlign": "center", "verticalAlign": "middle", "containerId": "w-ztniup6l59ROVzdYUH6", "originalText": "Video\nProcess\nQueue", "lineHeight": 1.25, "baseline": 68 }, { "type": "rectangle", "version": 276, "versionNonce": 1950018941, "isDeleted": false, "id": "HhwGJkSlnzJTzAu_APLy7", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1506.5, "y": 361.5, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 161, "height": 137, "seed": 1913862899, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "HQbhOlB49knR3cPeXx_-n" } ], "updated": 1701528460327, "link": null, "locked": false }, { "type": "text", "version": 336, "versionNonce": 861041875, "isDeleted": false, "id": "HQbhOlB49knR3cPeXx_-n", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1559.8200302124023, "y": 417.5, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 54.35993957519531, "height": 25, "seed": 1416979603, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1701528460327, "link": null, "locked": false, "fontSize": 20, "fontFamily": 1, "text": "Poller", "textAlign": "center", "verticalAlign": "middle", "containerId": "HhwGJkSlnzJTzAu_APLy7", "originalText": "Poller", "lineHeight": 1.25, "baseline": 18 }, { "type": "rectangle", "version": 276, "versionNonce": 953824733, "isDeleted": false, "id": "Yqd_zUKJ4L38vqyvb5mOA", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1010.5, "y": 363, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 322.00000000000006, "height": 62, "seed": 1544111219, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "De_9TutFKyFeuBZTIYzo0" } ], "updated": 1701528460327, "link": null, "locked": false }, { "type": "text", "version": 269, "versionNonce": 24273523, "isDeleted": false, "id": "De_9TutFKyFeuBZTIYzo0", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1120.0200424194336, "y": 381.5, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 102.95991516113281, "height": 25, "seed": 1978547731, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1701528460327, "link": null, "locked": false, "fontSize": 20, "fontFamily": 1, "text": "LogHandler", "textAlign": "center", "verticalAlign": "middle", "containerId": "Yqd_zUKJ4L38vqyvb5mOA", "originalText": "LogHandler", "lineHeight": 1.25, "baseline": 18 }, { "type": "rectangle", "version": 480, "versionNonce": 1328640573, "isDeleted": false, "id": "GQbWFYHSoW4ntp_uXnOl2", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1172, "y": 570, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 158.00000000000009, "height": 62, "seed": 1867737053, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "48Jl4ZKZXr6pyFaCvghNe" } ], "updated": 1701528460327, "link": null, "locked": false }, { "type": "text", "version": 553, "versionNonce": 209951763, "isDeleted": false, "id": "48Jl4ZKZXr6pyFaCvghNe", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1198.7500457763672, "y": 576, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 104.49990844726562, "height": 50, "seed": 62153789, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1701528460327, "link": null, "locked": false, "fontSize": 20, "fontFamily": 1, "text": "CombatLog\nWatcher", "textAlign": "center", "verticalAlign": "middle", "containerId": "GQbWFYHSoW4ntp_uXnOl2", "originalText": "CombatLog\nWatcher", "lineHeight": 1.25, "baseline": 43 }, { "id": "Wj3gwMwJsKxb19_flo_Zc", "type": "line", "x": 1262, "y": 293, "width": 139, "height": 183, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "seed": 1843723571, "version": 46, "versionNonce": 1081909683, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 139, -183 ] ], "lastCommittedPoint": null, "startBinding": null, "endBinding": null, "startArrowhead": null, "endArrowhead": null }, { "id": "wMnxVSOYvkPzghrToKGiS", "type": "text", "x": 1426, "y": 82, "width": 446.7396240234375, "height": 100, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, "seed": 1868174845, "version": 216, "versionNonce": 207472381, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "text": "The Manager is responsible for creating and \nmanaging all resources underneath it. Most \nnotably that is the recorder, which is also \nhandles configuration for. ", "fontSize": 20, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "baseline": 93, "containerId": null, "originalText": "The Manager is responsible for creating and \nmanaging all resources underneath it. Most \nnotably that is the recorder, which is also \nhandles configuration for. ", "lineHeight": 1.25 }, { "id": "tQBc-8CQmcx0Y1OakIJT0", "type": "line", "x": 913, "y": 474, "width": 221, "height": 30, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "seed": 744387933, "version": 89, "versionNonce": 1183052627, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "points": [ [ 0, 0 ], [ -221, 30 ] ], "lastCommittedPoint": null, "startBinding": null, "endBinding": null, "startArrowhead": null, "endArrowhead": null }, { "id": "IZxfWAE2zHQVy5IxVKt6d", "type": "text", "x": 506, "y": 511, "width": 359.259765625, "height": 50, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, "seed": 169539795, "version": 153, "versionNonce": 1248190301, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "text": "The Recorder class controls and is \nthe only interface to OBS.", "fontSize": 20, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "baseline": 43, "containerId": null, "originalText": "The Recorder class controls and is \nthe only interface to OBS.", "lineHeight": 1.25 }, { "id": "vWeeicgJeyuiz00dQgObD", "type": "text", "x": 440, "y": 200, "width": 347.3197021484375, "height": 50, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, "seed": 779601309, "version": 188, "versionNonce": 1592791283, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "text": "The entry-point to the application \nis main.ts. ", "fontSize": 20, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "baseline": 43, "containerId": null, "originalText": "The entry-point to the application \nis main.ts. ", "lineHeight": 1.25 }, { "id": "5uDUPon9MWxzXwbUxYMai", "type": "line", "x": 1620, "y": 413, "width": 128, "height": 38, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "seed": 1013052381, "version": 40, "versionNonce": 1418455997, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 128, -38 ] ], "lastCommittedPoint": null, "startBinding": null, "endBinding": null, "startArrowhead": null, "endArrowhead": null }, { "id": "fnHXpytcsDGlsfSvR46eh", "type": "text", "x": 1779, "y": 340, "width": 374.5596923828125, "height": 100, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, "seed": 470765715, "version": 186, "versionNonce": 2068282003, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "text": "The job of the Poller class is to \npoll the running processes on Windows \nperiodically and monitor if WoW is \nrunning or not.", "fontSize": 20, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "baseline": 93, "containerId": null, "originalText": "The job of the Poller class is to \npoll the running processes on Windows \nperiodically and monitor if WoW is \nrunning or not.", "lineHeight": 1.25 }, { "id": "_ttYycKQyJg6ZcAaI0cef", "type": "line", "x": 1471, "y": 478, "width": 105, "height": 120, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "seed": 1734908637, "version": 35, "versionNonce": 1525771293, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 105, 120 ] ], "lastCommittedPoint": null, "startBinding": null, "endBinding": null, "startArrowhead": null, "endArrowhead": null }, { "id": "78ucms-abl-OJhdL_IHwC", "type": "text", "x": 1443, "y": 619, "width": 571.91943359375, "height": 50, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, "seed": 762265971, "version": 182, "versionNonce": 406892595, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "text": "The VideoProcessQueue exists primarily to cut videos to \nsize when the appropriate LogHandler ends an activity. ", "fontSize": 20, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "baseline": 43, "containerId": null, "originalText": "The VideoProcessQueue exists primarily to cut videos to \nsize when the appropriate LogHandler ends an activity. ", "lineHeight": 1.25 }, { "id": "U3e4CxcLPjUX7Vq5RoWLD", "type": "line", "x": 1189, "y": 616, "width": 23, "height": 80, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "seed": 797628317, "version": 34, "versionNonce": 1524793203, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 23, 80 ] ], "lastCommittedPoint": null, "startBinding": null, "endBinding": null, "startArrowhead": null, "endArrowhead": null }, { "id": "kYjiMfgS4WhyUNeBS1wAT", "type": "text", "x": 1202, "y": 719, "width": 545.9395751953125, "height": 75, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, "seed": 459223219, "version": 195, "versionNonce": 438150461, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "text": "The CombatLogWatcher monitors the Log directory and\nemits events on writes to WoWCombatLog files. Those \nevents are processed by the appropriate LogHandler.", "fontSize": 20, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "baseline": 68, "containerId": null, "originalText": "The CombatLogWatcher monitors the Log directory and\nemits events on writes to WoWCombatLog files. Those \nevents are processed by the appropriate LogHandler.", "lineHeight": 1.25 }, { "id": "zf4WV4GTU62qgQCxtOKL4", "type": "line", "x": 1041, "y": 395, "width": 369, "height": 284, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "seed": 1398264285, "version": 77, "versionNonce": 1541937427, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "points": [ [ 0, 0 ], [ -369, 284 ] ], "lastCommittedPoint": null, "startBinding": null, "endBinding": null, "startArrowhead": null, "endArrowhead": null }, { "id": "oqp1QcPcrWwZZvsxZYA2E", "type": "text", "x": 407, "y": 686, "width": 499.1995849609375, "height": 75, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, "seed": 449320627, "version": 184, "versionNonce": 1225782685, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "text": "The LogHandler contains code for log parsing and \nhandling that is applicable to both classic and \nretail flavours.", "fontSize": 20, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "baseline": 68, "containerId": null, "originalText": "The LogHandler contains code for log parsing and \nhandling that is applicable to both classic and \nretail flavours.", "lineHeight": 1.25 }, { "id": "CehHcTZaeLe0OZVNGUy0Q", "type": "line", "x": 1043, "y": 536, "width": 107, "height": 247, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "seed": 998383933, "version": 35, "versionNonce": 139034291, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "points": [ [ 0, 0 ], [ -107, 247 ] ], "lastCommittedPoint": null, "startBinding": null, "endBinding": null, "startArrowhead": null, "endArrowhead": null }, { "id": "eN8enaDMGoiIjYYM16YFN", "type": "text", "x": 712, "y": 796, "width": 406.09967041015625, "height": 75, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, "seed": 9680563, "version": 182, "versionNonce": 437860861, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "text": "The Retail/Classic subclasses of the \nLogHandler have the flavour specific log \nhandling logic.", "fontSize": 20, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "baseline": 68, "containerId": null, "originalText": "The Retail/Classic subclasses of the \nLogHandler have the flavour specific log \nhandling logic.", "lineHeight": 1.25 }, { "type": "line", "version": 137, "versionNonce": 447557715, "isDeleted": false, "id": "dMxZL79RPVMzyNGkYngz9", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1199.7583394618518, "y": 528.0941630513779, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 263, "height": 255, "seed": 1319809757, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1701528460327, "link": null, "locked": false, "startBinding": null, "endBinding": null, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": null, "points": [ [ 0, 0 ], [ -263, 255 ] ] }, { "type": "rectangle", "version": 489, "versionNonce": 372961885, "isDeleted": false, "id": "v3u97w8VYCpceTSWcSV64", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1008, "y": 569, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 157.0000000000001, "height": 62, "seed": 145165117, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "iruo2VvKO88e0A8XYIMRh" } ], "updated": 1701528460327, "link": null, "locked": false }, { "type": "text", "version": 562, "versionNonce": 887729651, "isDeleted": false, "id": "iruo2VvKO88e0A8XYIMRh", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0, "x": 1034.2500457763672, "y": 575, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "width": 104.49990844726562, "height": 50, "seed": 763141021, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1701528460327, "link": null, "locked": false, "fontSize": 20, "fontFamily": 1, "text": "CombatLog\nWatcher", "textAlign": "center", "verticalAlign": "middle", "containerId": "v3u97w8VYCpceTSWcSV64", "originalText": "CombatLog\nWatcher", "lineHeight": 1.25, "baseline": 43 }, { "id": "LahIfYuX9B2ZiKGZlQ2nP", "type": "arrow", "x": 917.5714285714284, "y": 381.1428571428573, "width": 1.4285714285713311, "height": 61.428571428571445, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "seed": 982823763, "version": 41, "versionNonce": 709271229, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "points": [ [ 0, 0 ], [ -1.4285714285713311, -61.428571428571445 ] ], "lastCommittedPoint": null, "startBinding": null, "endBinding": null, "startArrowhead": null, "endArrowhead": "arrow" }, { "id": "gp_rS6cRfmA2wt8YVXyjm", "type": "line", "x": 918.9999999999999, "y": 334.00000000000006, "width": 54.285714285714334, "height": 187.14285714285705, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "seed": 2089582173, "version": 52, "versionNonce": 2047178643, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 54.285714285714334, -187.14285714285705 ] ], "lastCommittedPoint": null, "startBinding": null, "endBinding": null, "startArrowhead": null, "endArrowhead": null }, { "id": "IXZiS75k-UeYGCkWBob0j", "type": "text", "x": 674.7142857142858, "y": 22.571428571428726, "width": 698.279541015625, "height": 100, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": null, "seed": 661942067, "version": 355, "versionNonce": 752729885, "isDeleted": false, "boundElements": null, "updated": 1701528460327, "link": null, "locked": false, "text": "A difficult class of errors to deal with is the misbehaviour of OBS. \nIn the event it doesn't do what we want, the Recorder emits a crash\nevent up to the Manager; the Manager then shuts down the instance\nof OBS and recreates a new Recorder class.", "fontSize": 20, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "baseline": 93, "containerId": null, "originalText": "A difficult class of errors to deal with is the misbehaviour of OBS. \nIn the event it doesn't do what we want, the Recorder emits a crash\nevent up to the Manager; the Manager then shuts down the instance\nof OBS and recreates a new Recorder class.", "lineHeight": 1.25 }, { "id": "W29-fhpcUW_H-TTXv-JZu", "type": "arrow", "x": 1316.3214285714284, "y": 589.5357142857144, "width": 1.25, "height": 47.5, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "seed": 1373270419, "version": 33, "versionNonce": 1992806589, "isDeleted": false, "boundElements": null, "updated": 1701528508670, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 1.25, -47.5 ] ], "lastCommittedPoint": null, "startBinding": { "elementId": "PGPwp0WDskV9DOSBZv5mL", "focus": 0.8111111111111095, "gap": 24.035714285714448 }, "endBinding": null, "startArrowhead": null, "endArrowhead": "arrow" }, { "id": "2heO_-ZrTNX1YYjnin09m", "type": "arrow", "x": 1147.5714285714284, "y": 599.5357142857144, "width": 0, "height": 58.75, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", "fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "seed": 600737149, "version": 19, "versionNonce": 778732659, "isDeleted": false, "boundElements": null, "updated": 1701528512028, "link": null, "locked": false, "points": [ [ 0, 0 ], [ 0, -58.75 ] ], "lastCommittedPoint": null, "startBinding": null, "endBinding": { "elementId": "PGPwp0WDskV9DOSBZv5mL", "focus": 1.2972493345164169, "gap": 23.92857142857156 }, "startArrowhead": null, "endArrowhead": "arrow" } ], "appState": { "gridSize": null, "viewBackgroundColor": "#ffffff" }, "files": {} } ================================================ FILE: docs/example-obs-config/Output.json ================================================ [ { "nameSubCategory": "Untitled", "parameters": [ { "name": "Mode", "type": "OBS_PROPERTY_LIST", "description": "Output Mode", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "Advanced", "values": [ { "Simple": "Simple" }, { "Advanced": "Advanced" } ], "visible": true, "enabled": true, "masked": false } ] }, { "nameSubCategory": "Streaming", "parameters": [ { "name": "TrackIndex", "type": "OBS_PROPERTY_LIST", "description": "Audio Track", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "1", "values": [ { "1": "1" }, { "2": "2" }, { "3": "3" }, { "4": "4" }, { "5": "5" }, { "6": "6" } ], "visible": true, "enabled": true, "masked": false }, { "name": "VodTrackEnabled", "type": "OBS_PROPERTY_BOOL", "description": "Twitch VOD", "subType": "", "currentValue": false, "values": [], "visible": true, "enabled": true, "masked": false }, { "name": "Encoder", "type": "OBS_PROPERTY_LIST", "description": "Encoder", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "obs_x264", "values": [ { "Software (x264)": "obs_x264" }, { "Hardware (NVENC)": "ffmpeg_nvenc" }, { "Hardware (NVENC) (new)": "jim_nvenc" } ], "visible": true, "enabled": true, "masked": false }, { "name": "ApplyServiceSettings", "type": "OBS_PROPERTY_BOOL", "description": "Enforce streaming service encoder settings", "subType": "", "currentValue": true, "values": [], "visible": true, "enabled": true, "masked": false }, { "name": "Rescale", "type": "OBS_PROPERTY_BOOL", "description": "Rescale Output", "subType": "", "currentValue": false, "values": [], "visible": true, "enabled": true, "masked": false }, { "name": "rate_control", "type": "OBS_PROPERTY_LIST", "description": "Rate Control", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "CBR", "values": [ { "CBR": "CBR" }, { "ABR": "ABR" }, { "VBR": "VBR" }, { "CRF": "CRF" } ], "visible": true, "enabled": true, "masked": false }, { "name": "bitrate", "type": "OBS_PROPERTY_INT", "description": "Bitrate", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": 2500, "minVal": 50, "maxVal": 10000000, "stepVal": 50, "values": [ { "CBR": "CBR" }, { "ABR": "ABR" }, { "VBR": "VBR" }, { "CRF": "CRF" } ], "visible": true, "enabled": true, "masked": false }, { "name": "use_bufsize", "type": "OBS_PROPERTY_BOOL", "description": "Use Custom Buffer Size", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": false, "values": [ { "CBR": "CBR" }, { "ABR": "ABR" }, { "VBR": "VBR" }, { "CRF": "CRF" } ], "visible": true, "enabled": true, "masked": false }, { "name": "buffer_size", "type": "OBS_PROPERTY_INT", "description": "Buffer Size", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": 2500, "minVal": 0, "maxVal": 10000000, "stepVal": 1, "values": [ { "CBR": "CBR" }, { "ABR": "ABR" }, { "VBR": "VBR" }, { "CRF": "CRF" } ], "visible": false, "enabled": true, "masked": false }, { "name": "crf", "type": "OBS_PROPERTY_INT", "description": "CRF", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": 23, "minVal": 0, "maxVal": 51, "stepVal": 1, "values": [ { "CBR": "CBR" }, { "ABR": "ABR" }, { "VBR": "VBR" }, { "CRF": "CRF" } ], "visible": false, "enabled": true, "masked": false }, { "name": "keyint_sec", "type": "OBS_PROPERTY_INT", "description": "Keyframe Interval (seconds, 0=auto)", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": 0, "minVal": 0, "maxVal": 20, "stepVal": 1, "values": [ { "CBR": "CBR" }, { "ABR": "ABR" }, { "VBR": "VBR" }, { "CRF": "CRF" } ], "visible": true, "enabled": true, "masked": false }, { "name": "preset", "type": "OBS_PROPERTY_LIST", "description": "CPU Usage Preset (higher = less CPU)", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "veryfast", "values": [ { "ultrafast": "ultrafast" }, { "superfast": "superfast" }, { "veryfast": "veryfast" }, { "faster": "faster" }, { "fast": "fast" }, { "medium": "medium" }, { "slow": "slow" }, { "slower": "slower" }, { "veryslow": "veryslow" }, { "placebo": "placebo" } ], "visible": true, "enabled": true, "masked": false }, { "name": "profile", "type": "OBS_PROPERTY_LIST", "description": "Profile", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "", "values": [ { "(None)": "" }, { "baseline": "baseline" }, { "main": "main" }, { "high": "high" } ], "visible": true, "enabled": true, "masked": false }, { "name": "tune", "type": "OBS_PROPERTY_LIST", "description": "Tune", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "", "values": [ { "(None)": "" }, { "film": "film" }, { "animation": "animation" }, { "grain": "grain" }, { "stillimage": "stillimage" }, { "psnr": "psnr" }, { "ssim": "ssim" }, { "fastdecode": "fastdecode" }, { "zerolatency": "zerolatency" } ], "visible": true, "enabled": true, "masked": false }, { "name": "x264opts", "type": "OBS_PROPERTY_TEXT", "description": "x264 Options (separated by space)", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "", "values": [ { "(None)": "" }, { "film": "film" }, { "animation": "animation" }, { "grain": "grain" }, { "stillimage": "stillimage" }, { "psnr": "psnr" }, { "ssim": "ssim" }, { "fastdecode": "fastdecode" }, { "zerolatency": "zerolatency" } ], "visible": true, "enabled": true, "masked": false } ] }, { "nameSubCategory": "Recording", "parameters": [ { "name": "RecType", "type": "OBS_PROPERTY_LIST", "description": "Type", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "Standard", "values": [ { "Standard": "Standard" } ], "visible": true, "enabled": true, "masked": false }, { "name": "RecFilePath", "type": "OBS_PROPERTY_PATH", "description": "Recording Path", "subType": "", "currentValue": "D:\\wow-recorder-files/", "values": [], "visible": true, "enabled": true, "masked": false }, { "name": "RecFileNameWithoutSpace", "type": "OBS_PROPERTY_BOOL", "description": "Generate File Name without Space", "subType": "", "currentValue": false, "values": [], "visible": true, "enabled": true, "masked": false }, { "name": "RecFormat", "type": "OBS_PROPERTY_LIST", "description": "Recording Format", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "mp4", "values": [ { "flv": "flv" }, { "mp4": "mp4" }, { "mov": "mov" }, { "mkv": "mkv" }, { "ts": "ts" }, { "m3u8": "m3u8" } ], "visible": true, "enabled": true, "masked": false }, { "name": "RecTracks", "type": "OBS_PROPERTY_BITMASK", "description": "Audio Track", "subType": "", "currentValue": 63, "minVal": -200, "maxVal": 200, "stepVal": 1, "values": [], "visible": true, "enabled": true, "masked": false }, { "name": "RecEncoder", "type": "OBS_PROPERTY_LIST", "description": "Recording", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "jim_nvenc", "values": [ { "Use stream encoder": "none" }, { "Software (x264)": "obs_x264" }, { "Hardware (NVENC)": "ffmpeg_nvenc" }, { "Hardware (NVENC) (new)": "jim_nvenc" } ], "visible": true, "enabled": true, "masked": false }, { "name": "RecRescale", "type": "OBS_PROPERTY_BOOL", "description": "Rescale Output", "subType": "", "currentValue": false, "values": [], "visible": false, "enabled": true, "masked": false }, { "name": "RecMuxerCustom", "type": "OBS_PROPERTY_EDIT_TEXT", "description": "Custom Muxer Settings", "subType": "", "currentValue": "", "values": [], "visible": true, "enabled": true, "masked": false }, { "name": "Recrate_control", "type": "OBS_PROPERTY_LIST", "description": "Rate Control", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "CBR", "values": [ { "CBR": "CBR" }, { "CQP": "CQP" }, { "VBR": "VBR" }, { "Lossless": "lossless" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Recbitrate", "type": "OBS_PROPERTY_INT", "description": "Bitrate", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": 2500, "minVal": 50, "maxVal": 300000, "stepVal": 50, "values": [ { "CBR": "CBR" }, { "CQP": "CQP" }, { "VBR": "VBR" }, { "Lossless": "lossless" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Recmax_bitrate", "type": "OBS_PROPERTY_INT", "description": "Max Bitrate", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": 5000, "minVal": 50, "maxVal": 300000, "stepVal": 50, "values": [ { "CBR": "CBR" }, { "CQP": "CQP" }, { "VBR": "VBR" }, { "Lossless": "lossless" } ], "visible": false, "enabled": true, "masked": false }, { "name": "Reccqp", "type": "OBS_PROPERTY_INT", "description": "CQ Level", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": 20, "minVal": 1, "maxVal": 30, "stepVal": 1, "values": [ { "CBR": "CBR" }, { "CQP": "CQP" }, { "VBR": "VBR" }, { "Lossless": "lossless" } ], "visible": false, "enabled": true, "masked": false }, { "name": "Reckeyint_sec", "type": "OBS_PROPERTY_INT", "description": "Keyframe Interval (seconds, 0=auto)", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": 0, "minVal": 0, "maxVal": 10, "stepVal": 1, "values": [ { "CBR": "CBR" }, { "CQP": "CQP" }, { "VBR": "VBR" }, { "Lossless": "lossless" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Recpreset", "type": "OBS_PROPERTY_LIST", "description": "Preset", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "hq", "values": [ { "Max Quality": "mq" }, { "Quality": "hq" }, { "Performance": "default" }, { "Max Performance": "hp" }, { "Low-Latency": "ll" }, { "Low-Latency Quality": "llhq" }, { "Low-Latency Performance": "llhp" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Recprofile", "type": "OBS_PROPERTY_LIST", "description": "Profile", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "high", "values": [ { "high": "high" }, { "main": "main" }, { "baseline": "baseline" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Reclookahead", "type": "OBS_PROPERTY_BOOL", "description": "Look-ahead", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": false, "values": [ { "high": "high" }, { "main": "main" }, { "baseline": "baseline" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Recpsycho_aq", "type": "OBS_PROPERTY_BOOL", "description": "Psycho Visual Tuning", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": true, "values": [ { "high": "high" }, { "main": "main" }, { "baseline": "baseline" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Recgpu", "type": "OBS_PROPERTY_INT", "description": "GPU", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": 0, "minVal": 0, "maxVal": 8, "stepVal": 1, "values": [ { "high": "high" }, { "main": "main" }, { "baseline": "baseline" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Recbf", "type": "OBS_PROPERTY_INT", "description": "Max B-frames", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": 2, "minVal": 0, "maxVal": 4, "stepVal": 1, "values": [ { "high": "high" }, { "main": "main" }, { "baseline": "baseline" } ], "visible": true, "enabled": true, "masked": false } ] }, { "nameSubCategory": "Audio - Track 1", "parameters": [ { "name": "Track1Bitrate", "type": "OBS_PROPERTY_LIST", "description": "Audio Bitrate", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "160", "values": [ { "64": "64" }, { "96": "96" }, { "128": "128" }, { "160": "160" }, { "192": "192" }, { "224": "224" }, { "256": "256" }, { "288": "288" }, { "320": "320" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Track1Name", "type": "OBS_PROPERTY_EDIT_TEXT", "description": "Name", "subType": "", "currentValue": "Mixed: all sources", "values": [], "visible": true, "enabled": true, "masked": false } ] }, { "nameSubCategory": "Audio - Track 2", "parameters": [ { "name": "Track2Bitrate", "type": "OBS_PROPERTY_LIST", "description": "Audio Bitrate", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "160", "values": [ { "64": "64" }, { "96": "96" }, { "128": "128" }, { "160": "160" }, { "192": "192" }, { "224": "224" }, { "256": "256" }, { "288": "288" }, { "320": "320" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Track2Name", "type": "OBS_PROPERTY_EDIT_TEXT", "description": "Name", "subType": "", "currentValue": "BenQ GW2480 (NVIDIA High Definition Audio)", "values": [], "visible": true, "enabled": true, "masked": false } ] }, { "nameSubCategory": "Audio - Track 3", "parameters": [ { "name": "Track3Bitrate", "type": "OBS_PROPERTY_LIST", "description": "Audio Bitrate", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "160", "values": [ { "64": "64" }, { "96": "96" }, { "128": "128" }, { "160": "160" }, { "192": "192" }, { "224": "224" }, { "256": "256" }, { "288": "288" }, { "320": "320" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Track3Name", "type": "OBS_PROPERTY_EDIT_TEXT", "description": "Name", "subType": "", "currentValue": "Digital Audio (S/PDIF) (High Definition Audio Device)", "values": [], "visible": true, "enabled": true, "masked": false } ] }, { "nameSubCategory": "Audio - Track 4", "parameters": [ { "name": "Track4Bitrate", "type": "OBS_PROPERTY_LIST", "description": "Audio Bitrate", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "160", "values": [ { "64": "64" }, { "96": "96" }, { "128": "128" }, { "160": "160" }, { "192": "192" }, { "224": "224" }, { "256": "256" }, { "288": "288" }, { "320": "320" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Track4Name", "type": "OBS_PROPERTY_EDIT_TEXT", "description": "Name", "subType": "", "currentValue": "BenQ GW2480 (NVIDIA High Definition Audio)", "values": [], "visible": true, "enabled": true, "masked": false } ] }, { "nameSubCategory": "Audio - Track 5", "parameters": [ { "name": "Track5Bitrate", "type": "OBS_PROPERTY_LIST", "description": "Audio Bitrate", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "160", "values": [ { "64": "64" }, { "96": "96" }, { "128": "128" }, { "160": "160" }, { "192": "192" }, { "224": "224" }, { "256": "256" }, { "288": "288" }, { "320": "320" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Track5Name", "type": "OBS_PROPERTY_EDIT_TEXT", "description": "Name", "subType": "", "currentValue": "Speakers (5- G533 Gaming Headset)", "values": [], "visible": true, "enabled": true, "masked": false } ] }, { "nameSubCategory": "Audio - Track 6", "parameters": [ { "name": "Track6Bitrate", "type": "OBS_PROPERTY_LIST", "description": "Audio Bitrate", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "160", "values": [ { "64": "64" }, { "96": "96" }, { "128": "128" }, { "160": "160" }, { "192": "192" }, { "224": "224" }, { "256": "256" }, { "288": "288" }, { "320": "320" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Track6Name", "type": "OBS_PROPERTY_EDIT_TEXT", "description": "Name", "subType": "", "currentValue": "Microphone (5- G533 Gaming Headset)", "values": [], "visible": true, "enabled": true, "masked": false } ] }, { "nameSubCategory": "Replay Buffer", "parameters": [ { "name": "RecRB", "type": "OBS_PROPERTY_BOOL", "description": "Enable Replay Buffer", "subType": "", "currentValue": true, "values": [], "visible": true, "enabled": true, "masked": false }, { "name": "RecRBTime", "type": "OBS_PROPERTY_INT", "description": "Maximum Replay Time (Seconds)", "subType": "", "currentValue": 20, "minVal": 0, "maxVal": 21599, "stepVal": 0, "values": [], "visible": true, "enabled": true, "masked": false } ] } ] ================================================ FILE: docs/example-obs-config/Video.json ================================================ Video [ { "nameSubCategory": "Untitled", "parameters": [ { "name": "Base", "type": "OBS_INPUT_RESOLUTION_LIST", "description": "Base (Canvas) Resolution", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "1920x1080", "values": [ { "1920x1080": "1920x1080" }, { "1280x720": "1280x720" }, { "1080x1920": "1080x1920" } ], "visible": true, "enabled": true, "masked": false }, { "name": "Output", "type": "OBS_INPUT_RESOLUTION_LIST", "description": "Output (Scaled) Resolution", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "1920x1080", "values": [ { "1920x1080": "1920x1080" }, { "1536x864": "1536x864" }, { "1440x810": "1440x810" }, { "1280x720": "1280x720" }, { "1152x648": "1152x648" }, { "1096x616": "1096x616" }, { "960x540": "960x540" }, { "852x480": "852x480" }, { "768x432": "768x432" }, { "698x392": "698x392" }, { "640x360": "640x360" } ], "visible": true, "enabled": true, "masked": false }, { "name": "ScaleType", "type": "OBS_PROPERTY_LIST", "description": "Downscale Filter", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "bicubic", "values": [ { "Bilinear (Fastest, but blurry if scaling)": "bilinear" }, { "Bicubic (Sharpened scaling, 16 samples)": "bicubic" }, { "Lanczos (Sharpened scaling, 32 samples)": "lanczos" } ], "visible": true, "enabled": true, "masked": false }, { "name": "FPSType", "type": "OBS_PROPERTY_LIST", "description": "FPS Type", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "Common FPS Values", "values": [ { "Common FPS Values": "Common FPS Values" }, { "Integer FPS Value": "Integer FPS Value" }, { "Fractional FPS Value": "Fractional FPS Value" } ], "visible": true, "enabled": true, "masked": false }, { "name": "FPSCommon", "type": "OBS_PROPERTY_LIST", "description": "Common FPS Values", "subType": "OBS_COMBO_FORMAT_STRING", "currentValue": "50", "values": [ { "10": "10" }, { "20": "20" }, { "24 NTSC": "24 NTSC" }, { "29.97": "29.97" }, { "30": "30" }, { "48": "48" }, { "59.94": "59.94" }, { "60": "60" } ], "visible": true, "enabled": true, "masked": false } ] } ] ================================================ FILE: eslint.config.mjs ================================================ import globals from 'globals'; import pluginJs from '@eslint/js'; import tseslint from 'typescript-eslint'; import pluginReact from 'eslint-plugin-react'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; /** @type {import('eslint').Linter.Config[]} */ export default [ { files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] }, { languageOptions: { globals: globals.browser } }, pluginJs.configs.recommended, ...tseslint.configs.recommended, pluginReact.configs.flat.recommended, eslintPluginPrettierRecommended, { rules: { 'react/react-in-jsx-scope': 'off', '@typescript-eslint/no-require-imports': 'off', }, settings: { react: { version: 'detect' } }, ignores: [ 'logs', '*.log', 'pids', '*.pid', '*.seed', 'coverage', '.eslintcache', 'node_modules', '.DS_Store', 'release/app/dist', 'release/build', '.erb/dll', '.idea', 'npm-debug.log.*', '*.css.d.ts', '*.sass.d.ts', '*.scss.d.ts', '!.erb', ], }, ]; ================================================ FILE: installer.nsh ================================================ !macro customInstall NSISdl::download https://aka.ms/vs/17/release/vc_redist.x64.exe "$INSTDIR\vc_redist.x64.exe" ${If} ${FileExists} `$INSTDIR\vc_redist.x64.exe` ExecWait '$INSTDIR\vc_redist.x64.exe /passive /norestart' $1 ${If} $1 != '0' ${If} $1 != '3010' # The MSI "need to reboot" return code. ${If} $1 != '1638' # The MSI "already installed" return code. MessageBox MB_OK|MB_ICONEXCLAMATION 'WARNING: Warcraft Recorder was unable to install the latest Visual C++ Redistributable package from Microsoft.' ${EndIf} ${EndIf} ${EndIf} # ${If} $1 == '3010' # MessageBox MB_OK|MB_ICONEXCLAMATION 'You must restart your computer to complete the installation.' # ${EndIf} ${Else} MessageBox MB_OK|MB_ICONEXCLAMATION 'WARNING: Warcraft Recorder was unable to download the latest Visual C++ Redistributable package from Microsoft.' ${EndIf} FileOpen $0 "$INSTDIR\installername" w FileWrite $0 $EXEFILE FileClose $0 !macroend ================================================ FILE: package.json ================================================ { "name": "WarcraftRecorder", "description": "Warcraft Recorder", "keywords": [ "world", "of", "warcraft", "screen", "recorder" ], "homepage": "https://www.warcraftrecorder.com/", "bugs": { "url": "https://github.com/aza547/wow-recorder/issues" }, "repository": { "type": "git", "url": "git+https://github.com/aza547/wow-recorder.git" }, "license": "Creative Commons Attribution-NonCommercial", "author": { "name": "Alex K", "email": "warcraftrecorder@gmail.com", "url": "https://www.warcraftrecorder.com/" }, "main": "./.erb/dll/main.bundle.dev.js", "scripts": { "build": "concurrently \"npm run build:main\" \"npm run build:renderer\"", "build:dll": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts", "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts", "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts", "postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll", "lint": "cross-env NODE_ENV=development eslint ./src", "lint:fix": "cross-env NODE_ENV=development eslint ./src --fix", "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", "prestart": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.dev.ts", "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer", "start:main": "concurrently -k \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./.erb/configs/webpack.config.main.dev.ts\" \"electronmon .\"", "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts", "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts", "test": "jest --coverage --silent" }, "devEngines": { "runtime": { "name": "node", "version": ">=14.x", "onFail": "error" }, "packageManager": { "name": "npm", "version": ">=7.x", "onFail": "error" } }, "electronmon": { "patterns": [ "!**/**", "src/main/**", ".erb/dll/**", "!.erb/dll/logs/**", "!.erb/dll/osn-data/**" ], "logLevel": "quiet" }, "build": { "productName": "WarcraftRecorder", "appId": "org.WarcraftRecorder", "asar": true, "asarUnpack": "**\\**", "files": [ "dist", "node_modules", "package.json" ], "nsis": { "oneClick": true, "include": "installer.nsh", "deleteAppDataOnUninstall": true }, "win": { "signtoolOptions": { "certificateSubjectName": "Warcraft Recorder LTD" }, "artifactName": "WarcraftRecorder-Setup-${version}.exe", "target": [ "nsis" ] }, "directories": { "app": "release/app", "buildResources": "assets", "output": "release/build" }, "extraResources": [ "./assets/**", "./binaries/*" ], "publish": { "provider": "github", "owner": "aza547", "repo": "wow-recorder" } }, "jest": { "transform": { "\\.(ts|tsx|js|jsx)$": "ts-jest" } }, "dependencies": { "@electron/notarize": "^3.0.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@excalidraw/excalidraw": "^0.18.0", "@fortawesome/fontawesome-svg-core": "^6.6.0", "@fortawesome/free-brands-svg-icons": "^6.6.0", "@fortawesome/free-regular-svg-icons": "^6.6.0", "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", "@mui/icons-material": "^5.8.4", "@mui/material": "^5.6.4", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-hover-card": "^1.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", "@tanstack/react-query": "^5.90.7", "@tanstack/react-table": "^8.20.5", "atomic-queue": "^5.0.4", "axios": "^1.6.8", "check-disk-space": "^3.4.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", "dayjs": "^1.11.12", "electron-debug": "^4.1.0", "electron-log": "^5.3.2", "electron-store": "^8.0.1", "electron-updater": "^6.3.9", "fluent-ffmpeg": "^2.1.2", "fs": "^0.0.1-security", "history": "^5.3.0", "lodash": "^4.17.21", "lucide-react": "^0.428.0", "node-abi": "^4.14.0", "path": "^0.12.7", "re-resizable": "^6.9.11", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^5.0.0", "react-player": "^2.14.1", "react-router-dom": "^6.16.0", "react-tag-autocomplete": "^7.5.0", "react-tailwindcss-datepicker": "^1.7.3", "roughjs": "^4.6.6", "screenfull": "^6.0.2", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "tsc": "^2.0.4", "tss-react": "^4.1.1", "uuid": "^11.0.3", "wait-queue": "^1.1.4", "ws": "^8.18.2", "zod": "^3.24.2" }, "devDependencies": { "@electron/rebuild": "^4.0.1", "@eslint/js": "^9.17.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@svgr/webpack": "^8.1.0", "@teamsupercell/typings-for-css-modules-loader": "^2.5.2", "@testing-library/jest-dom": "^6.1.3", "@testing-library/react": "^14.0.0", "@types/fluent-ffmpeg": "^2.1.27", "@types/jest": "^29.5.5", "@types/lodash": "^4.17.13", "@types/node": "20.6.2", "@types/react": "^18.2.21", "@types/react-dom": "^18.2.7", "@types/react-test-renderer": "^18.0.1", "@types/webpack-bundle-analyzer": "^4.6.0", "autoprefixer": "^10.4.21", "browserslist": "^4.26.2", "browserslist-config-erb": "^0.0.3", "chalk": "^4.1.2", "concurrently": "^9.1.2", "core-js": "^3.41.0", "cross-env": "^7.0.3", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.2", "detect-port": "^2.1.0", "electron": "^38.1.2", "electron-builder": "^26.0.12", "electron-devtools-installer": "^4.0.0", "electron-notarize": "^1.2.1", "electronmon": "^2.0.3", "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-react": "^7.37.3", "file-loader": "^6.2.0", "globals": "^15.14.0", "html-webpack-plugin": "^5.6.3", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "mini-css-extract-plugin": "^2.9.2", "postcss": "^8.5.3", "postcss-loader": "^8.1.1", "prettier": "3.4.2", "react-refresh": "^0.14.0", "react-test-renderer": "^18.2.0", "rimraf": "^5.0.1", "sass": "^1.86.0", "sass-loader": "^16.0.5", "style-loader": "^4.0.0", "tailwindcss": "^3.4.10", "terser-webpack-plugin": "^5.3.14", "ts-jest": "^29.2.6", "ts-loader": "^9.5.2", "ts-node": "^10.9.2", "tsconfig-paths-webpack-plugin": "^4.2.0", "typescript": "^5.8.2", "typescript-eslint": "^8.19.0", "url-loader": "^4.1.1", "webpack": "^5.98.0", "webpack-bundle-analyzer": "^4.10.2", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.0", "webpack-merge": "^6.0.1" }, "browserslist": [ "extends browserslist-config-erb" ], "prettier": { "singleQuote": true, "overrides": [ { "files": [ ".prettierrc", ".eslintrc" ], "options": { "parser": "json" } } ] } } ================================================ FILE: postcss.config.js ================================================ /* eslint global-require: off */ module.exports = { plugins: [require('tailwindcss'), require('autoprefixer')], }; ================================================ FILE: release/app/package.json ================================================ { "name": "WarcraftRecorder", "version": "7.6.3", "description": "Warcraft Recorder", "main": "./dist/main/main.js", "author": { "name": "Alex K" }, "scripts": { "electron-rebuild": "node -r ts-node/register ../../.erb/scripts/electron-rebuild.js", "link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts", "postinstall": "npm run electron-rebuild && npm run link-modules" }, "dependencies": { "atomic-queue": "^5.0.4", "noobs": "^0.0.184", "uiohook-napi": "^1.5.2" }, "license": "MIT" } ================================================ FILE: src/__tests__/activitys/ArenaMatch.test.ts ================================================ import ArenaMatch from '../../activitys/ArenaMatch'; import { Flavour } from '../../main/types'; import { VideoCategory } from '../../types/VideoCategory'; import Combatant from '../../main/Combatant'; import TestConfigService from '../../utils/TestConfigService'; const cfg = new TestConfigService(); test('Basic Arena Match', () => { const startDate = new Date('2022-12-25T12:00:00'); const testCombatants = [ new Combatant('Player-1329-09C34603', 0, 253), new Combatant('Player-1084-08A89569', 0, 256), new Combatant('Player-1092-0A70E103', 1, 557), new Combatant('Player-5810-0A3E1BD5', 1, 105), ]; const arenaMatch = new ArenaMatch( startDate, VideoCategory.TwoVTwo, 1672, Flavour.Retail, cfg, ); arenaMatch.playerGUID = testCombatants[0].GUID; for (let i = 0; i < testCombatants.length; i++) { arenaMatch.addCombatant(testCombatants[i]); } const winningTeamID = 0; const endDate = new Date('2022-12-25T12:05:00'); arenaMatch.endArena(endDate, winningTeamID); const expectedDuration = (endDate.getTime() - startDate.getTime()) / 1000; const overrun = 3; const expectedResult = winningTeamID === testCombatants[0].teamID; expect(arenaMatch.duration).toBe(expectedDuration + overrun); expect(arenaMatch.result).toBe(expectedResult); expect(arenaMatch.resultInfo).toBe('Win'); expect(arenaMatch.zoneName).toBe("Blade's Edge"); expect(arenaMatch.getFileName()).toBe("2v2 Blade's Edge (Win)"); }); ================================================ FILE: src/__tests__/activitys/Battleground.test.ts ================================================ import { Flavour, PlayerDeathType } from '../../main/types'; import Battleground from '../../activitys/Battleground'; import { VideoCategory } from '../../types/VideoCategory'; import TestConfigService from '../../utils/TestConfigService'; const cfg = new TestConfigService(); const getPlayerDeath = () => { const playerDeath: PlayerDeathType = { name: 'Alexsmite', specId: 253, date: new Date('2022-12-25T12:01:00'), timestamp: 60, friendly: false, }; return playerDeath; }; test('Basic Battleground', () => { const startDate = new Date('2022-12-25T12:00:00'); const battleground = new Battleground( startDate, VideoCategory.Battlegrounds, 761, Flavour.Retail, cfg, ); const death = getPlayerDeath(); // Add an enemy death so we will estimate the BG is a win. battleground.addDeath(death); // We dont use the result in BGs so doesn't matter what it is. const endDate = new Date('2022-12-25T12:10:00'); battleground.end(endDate, false); const expectedDuration = (endDate.getTime() - startDate.getTime()) / 1000; const overrun = 3; expect(battleground.duration).toBe(expectedDuration + overrun); expect(battleground.battlegroundName).toBe('The Battle for Gilneas'); expect(battleground.getFileName()).toBe('The Battle for Gilneas (Win)'); expect(battleground.estimateResult()).toBe(true); }); ================================================ FILE: src/__tests__/activitys/ChallengeModeDungeon.test.ts ================================================ jest.mock( 'config/ConfigService', () => ({ __esModule: true, default: { getInstance: () => ({ get: () => 'English', }), }, }), { virtual: true }, ); import { Flavour, PlayerDeathType } from '../../main/types'; import ChallengeModeDungeon from '../../activitys/ChallengeModeDungeon'; import Combatant from '../../main/Combatant'; const getPlayerDeath = (deathDate: Date, rel: number, isFriendly: boolean) => { const playerDeath: PlayerDeathType = { name: 'Alexsmite', specId: 253, date: deathDate, timestamp: rel, friendly: isFriendly, }; return playerDeath; }; const getRelativeDate = (initialDate: Date, secs: number) => { return new Date(initialDate.getTime() + 1000 * secs); }; test('Basic Challenge Mode', () => { const startDate = new Date('2022-12-25T12:00:00'); const endDate = getRelativeDate(startDate, 30 * 60); const testCombatants = [ new Combatant('Player-1329-09C34603', 0, 253), new Combatant('Player-1084-08A89569', 0, 256), new Combatant('Player-1092-0A70E103', 0, 557), new Combatant('Player-5810-0A3E1BD5', 0, 105), new Combatant('Player-5810-0A3E1BD5', 0, 107), ]; const dungeon = new ChallengeModeDungeon( startDate, 1822, 353, 10, [159, 10, 152, 9], Flavour.Retail, ); for (let i = 0; i < testCombatants.length; i++) { dungeon.addCombatant(testCombatants[i]); } dungeon.playerGUID = testCombatants[0].GUID; for (let j = 0; j < 5; j++) { const relative = (j + 1) * 60; const death = getPlayerDeath( getRelativeDate(startDate, relative), relative, false, ); dungeon.addDeath(death); } dungeon.endChallengeMode(endDate, 1825, true); const expectedDuration = (endDate.getTime() - startDate.getTime()) / 1000; expect(dungeon.duration).toBe(expectedDuration); expect(dungeon.deaths.length).toBe(5); expect(dungeon.CMDuration).toBe(1825); // 5 deaths so +25 secs to timer expect(dungeon.result).toBe(true); expect(dungeon.upgradeLevel).toBe(1); }); test('Hard Depleted Challenge Mode', () => { const startDate = new Date('2022-12-25T12:00:00'); const endDate = getRelativeDate(startDate, 60 * 60); // 60 mins - way over time const testCombatants = [ new Combatant('Player-1329-09C34603', 0, 253), new Combatant('Player-1084-08A89569', 0, 256), new Combatant('Player-1092-0A70E103', 0, 557), new Combatant('Player-5810-0A3E1BD5', 0, 105), new Combatant('Player-5810-0A3E1BD5', 0, 107), ]; const dungeon = new ChallengeModeDungeon( startDate, 1822, 353, 10, [159, 10, 152, 9], Flavour.Retail, ); for (let i = 0; i < testCombatants.length; i++) { dungeon.addCombatant(testCombatants[i]); } dungeon.playerGUID = testCombatants[0].GUID; for (let j = 0; j < 5; j++) { const relative = (j + 1) * 60; const death = getPlayerDeath( getRelativeDate(startDate, relative), relative, false, ); dungeon.addDeath(death); } dungeon.endChallengeMode(endDate, 3625, true); const expectedDuration = (endDate.getTime() - startDate.getTime()) / 1000; expect(dungeon.duration).toBe(expectedDuration); expect(dungeon.deaths.length).toBe(5); expect(dungeon.CMDuration).toBe(3625); // 5 deaths so +25 secs to timer expect(dungeon.result).toBe(true); // not depleted not abandoned expect(dungeon.upgradeLevel).toBe(0); }); test('Peril Does Not Extend Timer', () => { const startDate = new Date('2022-12-25T12:00:00'); const endDate = getRelativeDate(startDate, 34 * 60); // Over the base time of 33 mins const testCombatants = [ new Combatant('Player-1329-09C34603', 0, 253), new Combatant('Player-1084-08A89569', 0, 256), new Combatant('Player-1092-0A70E103', 0, 557), new Combatant('Player-5810-0A3E1BD5', 0, 105), new Combatant('Player-5810-0A3E1BD5', 0, 107), ]; const dungeon = new ChallengeModeDungeon( startDate, 1822, 353, 10, [159, 10, 152, 9], Flavour.Retail, ); for (let i = 0; i < testCombatants.length; i++) { dungeon.addCombatant(testCombatants[i]); } dungeon.playerGUID = testCombatants[0].GUID; dungeon.endChallengeMode(endDate, 34 * 60, true); const expectedDuration = (endDate.getTime() - startDate.getTime()) / 1000; expect(dungeon.duration).toBe(expectedDuration); expect(dungeon.deaths.length).toBe(0); expect(dungeon.CMDuration).toBe(34 * 60); expect(dungeon.result).toBe(true); expect(dungeon.upgradeLevel).toBe(0); }); test('Peril Depleted Challenge Mode', () => { const startDate = new Date('2022-12-25T12:00:00'); const endDate = getRelativeDate(startDate, 34 * 60); // Over the base time of 33 mins const testCombatants = [ new Combatant('Player-1329-09C34603', 0, 253), new Combatant('Player-1084-08A89569', 0, 256), new Combatant('Player-1092-0A70E103', 0, 557), new Combatant('Player-5810-0A3E1BD5', 0, 105), new Combatant('Player-5810-0A3E1BD5', 0, 107), ]; const dungeon = new ChallengeModeDungeon( startDate, 1822, 353, 10, [159, 10, 152, 9], Flavour.Retail, ); for (let i = 0; i < testCombatants.length; i++) { dungeon.addCombatant(testCombatants[i]); } dungeon.playerGUID = testCombatants[0].GUID; for (let j = 0; j < 3; j++) { // Adding the deaths because why not but this isn't required for this test. const relative = (j + 1) * 60; const death = getPlayerDeath( getRelativeDate(startDate, relative), relative, false, ); dungeon.addDeath(death); } // Add 3 deaths, so +45s on the timer. dungeon.endChallengeMode(endDate, 34 * 60 + 45, true); const expectedDuration = (endDate.getTime() - startDate.getTime()) / 1000; expect(dungeon.duration).toBe(expectedDuration); expect(dungeon.deaths.length).toBe(3); expect(dungeon.CMDuration).toBe(34 * 60 + 45); expect(dungeon.result).toBe(true); expect(dungeon.upgradeLevel).toBe(0); }); test('Retail Challenge Mode Allows Timer Grace', () => { const startDate = new Date('2022-12-25T12:00:00'); const endDate = getRelativeDate(startDate, 34 * 60 + 0.999); const dungeon = new ChallengeModeDungeon( startDate, 2875, 558, 20, [], Flavour.Retail, ); dungeon.endChallengeMode(endDate, 34 * 60 + 0.999, true); expect(dungeon.result).toBe(true); expect(dungeon.upgradeLevel).toBe(1); }); test('Retail Challenge Mode Depletes At One Second Over Timer', () => { const startDate = new Date('2022-12-25T12:00:00'); const endDate = getRelativeDate(startDate, 34 * 60 + 1); const dungeon = new ChallengeModeDungeon( startDate, 2875, 558, 20, [], Flavour.Retail, ); dungeon.endChallengeMode(endDate, 34 * 60 + 1, true); expect(dungeon.result).toBe(true); expect(dungeon.upgradeLevel).toBe(0); }); ================================================ FILE: src/__tests__/activitys/RaidEncounter.test.ts ================================================ import { specializationById } from '../../main/constants'; import RaidEncounter from '../../activitys/RaidEncounter'; import { Flavour } from '../../main/types'; import Combatant from '../../main/Combatant'; import { Phrase } from '../../localisation/phrases'; import TestConfigService from '../../utils/TestConfigService'; const cfg = new TestConfigService(); const getRandomSpecID = () => { const keys = Object.keys(specializationById); return parseInt(keys[Math.floor(Math.random() * keys.length)], 10); }; const getCombatants = (n: number) => { const testCombatants: Combatant[] = []; for (let i = 0; i < n; i++) { const formattedNumber = n.toLocaleString('en-US', { minimumIntegerDigits: 2, }); const GUID = `Player-0000-000000${formattedNumber}`; const combatant = new Combatant(GUID, 0, getRandomSpecID()); testCombatants.push(combatant); } return testCombatants; }; test('Basic Raid Encounter', () => { const startDate = new Date('2022-12-25T12:00:00'); const raidEncounter = new RaidEncounter( startDate, 2607, 'Raszageth', 16, Flavour.Retail, cfg, ); const testCombatants = getCombatants(20); testCombatants.forEach((c) => { raidEncounter.addCombatant(c); }); const endDate = new Date('2022-12-25T12:10:00'); raidEncounter.end(endDate, true); expect(raidEncounter.duration).toBe(603); expect(raidEncounter.getFileName()).toBe( 'Vault of the Incarnates, Raszageth [M] (Kill)', ); expect(raidEncounter.difficulty).toStrictEqual({ difficultyID: 'mythic', difficulty: 'M', partyType: 'raid', phrase: Phrase.Mythic, }); expect(raidEncounter.resultInfo).toBe('Kill'); expect(raidEncounter.raid.name).toBe('Vault of the Incarnates'); expect(raidEncounter.zoneID).toBe(14030); expect(raidEncounter.encounterName).toBe('Raszageth'); }); ================================================ FILE: src/__tests__/activitys/SoloShuffle.test.ts ================================================ import { PlayerDeathType } from 'main/types'; import SoloShuffle from '../../activitys/SoloShuffle'; import Combatant from '../../main/Combatant'; import TestConfigService from '../../utils/TestConfigService'; const cfg = new TestConfigService(); const getPlayerDeath = (deathDate: Date, rel: number, isFriendly: boolean) => { const playerDeath: PlayerDeathType = { name: 'Alexsmite', specId: 253, date: deathDate, timestamp: rel, friendly: isFriendly, }; return playerDeath; }; const getRelativeDate = (initialDate: Date, secs: number) => { return new Date(initialDate.getTime() + 1000 * secs); }; test('Basic Solo Shuffle', () => { const startDate = new Date('2022-12-25T12:00:00'); const endDate = getRelativeDate(startDate, 6 * 60); const roundStartDates = [ getRelativeDate(startDate, 1 * 60), getRelativeDate(startDate, 2 * 60), getRelativeDate(startDate, 3 * 60), getRelativeDate(startDate, 4 * 60), getRelativeDate(startDate, 5 * 60), ]; const testCombatants = [ new Combatant('Player-1329-09C34603', 0, 253), new Combatant('Player-1084-08A89569', 0, 256), new Combatant('Player-1092-0A70E103', 0, 557), new Combatant('Player-5810-0A3E1BD5', 1, 105), new Combatant('Player-5810-0A3E1BD5', 1, 107), new Combatant('Player-5810-0A3E1BD5', 1, 104), ]; const soloShuffle = new SoloShuffle(startDate, 1672, cfg); // First round let death = getPlayerDeath(getRelativeDate(startDate, 45), 45, false); for (let j = 0; j < testCombatants.length; j++) { soloShuffle.addCombatant(testCombatants[j]); } soloShuffle.playerGUID = testCombatants[0].GUID; soloShuffle.addDeath(death); // Subsequent rounds for (let i = 0; i < 5; i++) { soloShuffle.startRound(roundStartDates[i]); for (let j = 0; j < testCombatants.length; j++) { soloShuffle.addCombatant(testCombatants[j]); } soloShuffle.playerGUID = testCombatants[0].GUID; death = getPlayerDeath(getRelativeDate(startDate, 45 + i * 60), 45, true); soloShuffle.addDeath(death); } soloShuffle.endGame(endDate); const expectedDuration = (endDate.getTime() - startDate.getTime()) / 1000; const overrun = 3; expect(soloShuffle.duration).toBe(expectedDuration + overrun); expect(soloShuffle.getFileName()).toBe("Solo Shuffle Blade's Edge (1-5)"); expect(soloShuffle.resultInfo).toBe('1-5'); expect(soloShuffle.roundsWon).toBe(1); expect(soloShuffle.zoneName).toBe("Blade's Edge"); }); ================================================ FILE: src/__tests__/localisation/Translate.test.ts ================================================ import { Flavour, Metadata } from '../../main/types'; import { convertKoreanVideoCategory } from '../../main/util'; import { VideoCategory } from '../../types/VideoCategory'; import KOREAN from '../../localisation/korean'; import ENGLISH from '../../localisation/english'; import { getLocaleCategoryLabel, getLocalePhrase, } from '../../localisation/translations'; import { Language, Phrase } from '../../localisation/phrases'; test('English Phrase', () => { const phrase = getLocalePhrase(Language.ENGLISH, Phrase.NoVideosSaved); expect(phrase).toBe(ENGLISH[Phrase.NoVideosSaved]); }); test('Korean Phrase', () => { const phrase = getLocalePhrase(Language.KOREAN, Phrase.NoVideosSaved); expect(phrase).toBe(KOREAN[Phrase.NoVideosSaved]); }); test('English Category', () => { const phrase = getLocaleCategoryLabel(Language.ENGLISH, VideoCategory.Raids); expect(phrase).toBe(ENGLISH[Phrase.VideoCategoryRaidsLabel]); }); test('Korean Category', () => { const phrase = getLocaleCategoryLabel(Language.KOREAN, VideoCategory.Raids); expect(phrase).toBe(KOREAN[Phrase.VideoCategoryRaidsLabel]); }); test('Convert Korean Category', () => { const metadata: Metadata = { category: '레이드' as VideoCategory, zoneID: 0, zoneName: 'Unknown Raid', flavour: Flavour.Retail, encounterID: 2922, encounterName: 'Queen Ansurek', difficultyID: 16, difficulty: 'M', duration: 307, result: false, player: { _GUID: 'Player-3674-0ACE9152', _teamID: 0, _specID: 264, _name: 'Alextides', _realm: 'TwistingNether', }, deaths: [], overrun: 0, combatants: [], start: 1734031729000, uniqueHash: 'f0f733f2b4b26d074f0682125f7826f0', }; convertKoreanVideoCategory(metadata); expect(metadata.category).toBe(VideoCategory.Raids); }); ================================================ FILE: src/__tests__/parser/Basic.test.ts ================================================ import LogLine from '../../parsing/LogLine'; import CombatLogWatcher from '../../parsing/CombatLogWatcher'; test('Basic Retail', async () => { const combatLogParser = new CombatLogWatcher('', 2); let promiseResolve: (value: LogLine | PromiseLike) => void; const testLogLinePromise: Promise = new Promise((resolve) => { promiseResolve = resolve; }); combatLogParser.on('ARENA_MATCH_START', (line: LogLine) => { promiseResolve(line); }); const arenaMatchStartLine = '8/3 22:12:04.000 ARENA_MATCH_START,2547,33,5v5,1'; combatLogParser.handleLogLine(arenaMatchStartLine); const testLogLine = await testLogLinePromise; const expectedDate = new Date('2025-08-03T22:12:04'); expect(testLogLine.date()).toStrictEqual(expectedDate); expect(testLogLine.type()).toBe('ARENA_MATCH_START'); expect(testLogLine.arg(1)).toBe('2547'); expect(testLogLine.arg(2)).toBe('33'); expect(testLogLine.arg(3)).toBe('5v5'); expect(testLogLine.arg(4)).toBe('1'); expect(testLogLine.toString()).toBe(arenaMatchStartLine); }); test('Date Parsing', async () => { // Pre The War Within expansion there were note years or timezones. const preTWW = '8/3 22:12:04.000 ARENA_MATCH_START,2547,33,5v5,1'; const parsedPreTWW = new LogLine(preTWW); const expectedPreTww = new Date('2025-08-03T22:12:04'); expect(parsedPreTWW.date()).toStrictEqual(expectedPreTww); // Year but no TZ. const twwNoTZ = '7/28/2025 17:35:43.4931 ARENA_MATCH_START,2547,33,5v5,1'; const parsedTwwNoTZ = new LogLine(twwNoTZ); const expectedTwwNoTZ = new Date('2025-07-28T17:35:43'); expect(parsedTwwNoTZ.date()).toStrictEqual(expectedTwwNoTZ); // Year and TZ. const twwTz = '7/28/2025 14:37:54.790-4 ARENA_MATCH_START,2547,33,5v5,1'; const parsedTwwTz = new LogLine(twwTz); const expectedTwwTz = new Date('2025-07-28T14:37:54'); expect(parsedTwwTz.date()).toStrictEqual(expectedTwwTz); }); ================================================ FILE: src/activitys/Activity.ts ================================================ import crypto from 'crypto'; import ConfigService from 'config/ConfigService'; import { PlayerDeathType, Flavour, Metadata } from '../main/types'; import Combatant from '../main/Combatant'; import { VideoCategory } from '../types/VideoCategory'; /** * Abstract activity class. */ export default abstract class Activity { protected _category: VideoCategory; protected _result: boolean; protected _combatantMap: Map; protected _startDate: Date; protected _deaths: PlayerDeathType[]; protected _flavour: Flavour; protected _endDate?: Date; protected _zoneID?: number; protected _playerGUID?: string; protected _overrun: number = 0; protected hash = crypto.createHash('md5'); protected cfg = ConfigService.getInstance(); constructor(startDate: Date, category: VideoCategory, flavour: Flavour) { this._result = false; this._combatantMap = new Map(); this._startDate = startDate; this._category = category; this._deaths = []; this._flavour = flavour; } abstract getMetadata(): Metadata; abstract getFileName(): string; get zoneID() { return this._zoneID; } set zoneID(zoneID) { this._zoneID = zoneID; } get category() { return this._category; } set category(category) { this._category = category; } get startDate() { return this._startDate; } set startDate(date) { this._startDate = date; } get result() { return this._result; } set result(result) { this._result = result; } get deaths() { return this._deaths; } get playerGUID() { return this._playerGUID; } set playerGUID(guid) { this._playerGUID = guid; } get endDate() { return this._endDate; } set endDate(date) { this._endDate = date; } get combatantMap() { return this._combatantMap; } set combatantMap(cm) { this._combatantMap = cm; } get flavour() { return this._flavour; } set flavour(flavour) { this._flavour = flavour; } get overrun() { return this._overrun; } set overrun(s) { console.info('[Activity] Setting overrun to', s); this._overrun = s; } get duration() { if (!this.endDate) { throw new Error('Failed to get duration of in-progress activity'); } const baseDuration = (this.endDate.getTime() - this.startDate.getTime()) / 1000; return baseDuration + this.overrun; } get player() { if (!this.playerGUID) { throw new Error('Failed to get player combatant, playerGUID not set'); } const player = this.getCombatant(this.playerGUID); if (!player) { throw new Error('Player not found in combatants'); } return player; } end(endDate: Date, result: boolean) { endDate.setTime(endDate.getTime()); this.endDate = endDate; this.result = result; } getCombatant(GUID: string) { return this.combatantMap.get(GUID); } addCombatant(combatant: Combatant) { this.combatantMap.set(combatant.GUID, combatant); } addDeath(death: PlayerDeathType) { this.deaths.push(death); } /** * Gets fields from the metadata that are deterministic and hashes them. This is used to * correlate videos; deliberately excludes fields that vary from player to player for this * reason. Does not include start time as I'm not sure it's totally fixed across multi povs; * it might vary slightly with local system clock. */ getUniqueHash(): string { const deterministicFields = [this.category, this.flavour, this.result].map( (f) => f.toString(), ); const sortedNames: string[] = []; Array.from(this.combatantMap.values()) .map((combatant) => combatant.name) .sort() .forEach((name) => { if (name) sortedNames.push(name); }); const uniqueString = deterministicFields.join(' ') + sortedNames.join(' '); return this.hash.update(uniqueString).digest('hex'); } } ================================================ FILE: src/activitys/ArenaMatch.ts ================================================ import Combatant from 'main/Combatant'; import { getLocalePhrase } from '../localisation/translations'; import { Language, Phrase } from '../localisation/phrases'; import { Flavour, Metadata } from '../main/types'; import { classicArenas, retailArenas } from '../main/constants'; import Activity from './Activity'; import { VideoCategory } from '../types/VideoCategory'; import { app } from 'electron'; /** * Arena match class. */ export default class ArenaMatch extends Activity { constructor( startDate: Date, category: VideoCategory, zoneID: number, flavour: Flavour, ) { super(startDate, category, flavour); this._zoneID = zoneID; this.overrun = 3; } get zoneID() { return this._zoneID; } get resultInfo() { if (this.result === undefined) { throw new Error('[ArenaMatch] Tried to get result info but no result'); } const language = this.cfg.get('language') as Language; if (this.result) { return getLocalePhrase(language, Phrase.Win); } return getLocalePhrase(language, Phrase.Loss); } get zoneName() { if (!this.zoneID) { throw new Error('[ArenaMatch] Tried to get zoneName but no zoneID'); } if (this.flavour === Flavour.Retail) { return retailArenas[this._zoneID as number]; } return classicArenas[this._zoneID as number]; } endArena(endDate: Date, winningTeamID: number) { const result = this.determineArenaMatchResult(winningTeamID); super.end(endDate, result); } determineArenaMatchResult(winningTeamID: number): boolean { if (!this.playerGUID) { console.error( "[ArenaMatch] Haven't identified player so no results possible", ); return false; } const player = this.getCombatant(this.playerGUID); if (!player) { console.error('[ArenaMatch] No player combatant so no results possible'); return false; } return player.teamID === winningTeamID; } getMetadata(): Metadata { const rawCombatants = Array.from(this.combatantMap.values()).map( (combatant: Combatant) => combatant.getRaw(), ); return { category: this.category, zoneID: this.zoneID, zoneName: this.zoneName, flavour: this.flavour, duration: this.duration, result: this.result, deaths: this.deaths, player: this.player.getRaw(), combatants: rawCombatants, overrun: this.overrun, start: this.startDate.getTime(), uniqueHash: this.getUniqueHash(), appVersion: app.getVersion(), }; } getFileName() { const language = this.cfg.get('language') as Language; let phrase; if (this.category === VideoCategory.TwoVTwo) { phrase = Phrase.VideoCategoryTwoVTwoLabel; } else if (this.category === VideoCategory.ThreeVThree) { phrase = Phrase.VideoCategoryThreeVThreeLabel; } else if (this.category === VideoCategory.FiveVFive) { phrase = Phrase.VideoCategoryFiveVFiveLabel; } else if (this.category === VideoCategory.Skirmish) { phrase = Phrase.VideoCategorySkirmishLabel; } else { console.error('Unrecognized arena category', this.category); throw new Error('Unrecongized arena category'); } const categoryText = getLocalePhrase(language, phrase); let fileName = `${categoryText} ${this.zoneName} (${this.resultInfo})`; try { if (this.player.name !== undefined) { fileName = `${this.player.name} - ${fileName}`; } } catch { console.warn('[ArenaMatch] Failed to get player combatant'); } return fileName; } addCombatant(combatant: Combatant) { super.addCombatant(combatant); if (this.flavour === Flavour.Classic) { // For classic, we need to determine the arena category based on the // number of combatants. So update it now. const combatantMapSize = this.combatantMap.size; if (combatantMapSize < 5) { console.log('[ArenaMatch] Setting arena category to 2v2'); this.category = VideoCategory.TwoVTwo; } else if (combatantMapSize < 7) { console.log('[ArenaMatch] Setting arena category to 3v3'); this.category = VideoCategory.ThreeVThree; } else { console.log('[ArenaMatch] Setting arena category to 5v5'); this.category = VideoCategory.FiveVFive; } } } } ================================================ FILE: src/activitys/Battleground.ts ================================================ import { Flavour, Metadata } from 'main/types'; import { Language, Phrase } from '../localisation/phrases'; import { getLocalePhrase } from '../localisation/translations'; import { classicBattlegrounds, retailBattlegrounds } from '../main/constants'; import { VideoCategory } from '../types/VideoCategory'; import Activity from './Activity'; import { app } from 'electron'; /** * Arena match class. */ export default class Battleground extends Activity { constructor( startDate: Date, category: VideoCategory, zoneID: number, flavour: Flavour, ) { super(startDate, category, flavour); this.zoneID = zoneID; this.overrun = 3; } get battlegroundName(): string { if (!this.zoneID) { throw new Error("zoneID not set, can't get battleground name"); } const isRetailBattleground = Object.prototype.hasOwnProperty.call( retailBattlegrounds, this.zoneID, ); if (isRetailBattleground) { return retailBattlegrounds[this.zoneID]; } const isClassicBattleground = Object.prototype.hasOwnProperty.call( classicBattlegrounds, this.zoneID, ); if (isClassicBattleground) { return classicBattlegrounds[this.zoneID]; } return 'Unknown Battleground'; } estimateResult() { // We decide who won by counting the deaths. The winner is the // team with the least deaths. Obviously this is a best effort // thing and might be wrong. const friendsDead = this.deaths.filter((d) => d.friendly).length; const enemiesDead = this.deaths.filter((d) => !d.friendly).length; console.info('[Battleground] Friendly deaths: ', friendsDead); console.info('[Battleground] Enemy deaths: ', enemiesDead); const result = friendsDead < enemiesDead; this.result = result; return result; } getMetadata(): Metadata { return { category: this.category, zoneID: this.zoneID, zoneName: this.battlegroundName, duration: this.duration, result: this.estimateResult(), flavour: this.flavour, player: this.player.getRaw(), overrun: this.overrun, combatants: [], start: this.startDate.getTime(), uniqueHash: this.getUniqueHash(), appVersion: app.getVersion(), }; } getFileName(): string { const language = this.cfg.get('language') as Language; const resultText = this.estimateResult() ? getLocalePhrase(language, Phrase.Win) : getLocalePhrase(language, Phrase.Loss); let fileName = `${this.battlegroundName} (${resultText})`; try { if (this.player.name !== undefined) { fileName = `${this.player.name} - ${fileName}`; } } catch { console.warn('[Battleground] Failed to get player combatant'); } return fileName; } } ================================================ FILE: src/activitys/ChallengeModeDungeon.ts ================================================ import Combatant from '../main/Combatant'; import { Language, Phrase } from '../localisation/phrases'; import { getLocalePhrase } from '../localisation/translations'; import { Flavour, Metadata } from '../main/types'; import { dungeonTimersByMapId, instanceNamesByZoneId, mopChallengeModes, mopChallengeModesTimers, } from '../main/constants'; import { VideoCategory } from '../types/VideoCategory'; import { ChallengeModeTimelineSegment, TimelineSegmentType, } from '../main/keystone'; import Activity from './Activity'; import { app } from 'electron'; export default class ChallengeModeDungeon extends Activity { private static readonly TIMER_GRACE_SECONDS = 1; private _mapID: number; private _level: number; private _timings: number[]; private _CMDuration: number = 0; private _timeline: ChallengeModeTimelineSegment[] = []; private affixes: number[] = []; constructor( startDate: Date, zoneID: number, mapID: number, level: number, affixes: number[], flavor: Flavour, ) { super(startDate, VideoCategory.MythicPlus, flavor); this._zoneID = zoneID; this._mapID = mapID; this._level = level; this.affixes = affixes; this._timings = dungeonTimersByMapId[mapID]; if (flavor === Flavour.Classic) { console.info('[ChallengeModeDungeon] Using Classic timers for', mapID); this._timings = mopChallengeModesTimers[mapID]; } this.overrun = 0; } get endDate() { return this._endDate; } set endDate(date) { this._endDate = date; } get CMDuration() { return this._CMDuration; } set CMDuration(duration) { this._CMDuration = duration; } get timings() { return this._timings; } get timeline() { return this._timeline; } get level() { return this._level; } get mapID() { return this._mapID; } get upgradeLevel(): number { if (!this.timings) { throw new Error("Don't have timings data for this dungeon."); } if (!this.CMDuration && this.flavour === Flavour.Retail) { console.info( "[ChallengeModeDungeon] Run didn't complete (abandoned, not a deplete)", ); return 0; } const durationForResult = this.CMDuration; for (let i = this.timings.length - 1; i >= 0; i--) { if ( durationForResult < this.timings[i] + ChallengeModeDungeon.TIMER_GRACE_SECONDS ) { return i + 1; } } return 0; } get currentSegment() { return this.timeline.at(-1); } get dungeonName(): string { if (!this.zoneID) { throw new Error("zoneID not set, can't get dungeon name"); } const isRecognisedMythicPlus = Object.prototype.hasOwnProperty.call( instanceNamesByZoneId, this.zoneID, ); if (isRecognisedMythicPlus) { return instanceNamesByZoneId[this.zoneID]; } if (this.flavour === Flavour.Classic && mopChallengeModes[this.mapID]) { return mopChallengeModes[this.mapID]; } return 'Unknown Dungeon'; } get resultInfo() { if (this.result === undefined) { throw new Error('[RaidEncounter] Tried to get result info but no result'); } if (this.result) { return `+${this.upgradeLevel}`; } const language = this.cfg.get('language') as Language; return getLocalePhrase(language, Phrase.Abandoned); } endChallengeMode(endDate: Date, CMDuration: number, result: boolean) { this.endCurrentTimelineSegment(endDate); const lastSegment = this.currentSegment; if (lastSegment && lastSegment.length() < 10000) { console.debug( "[ChallengeModeDungeon] Removing last timeline segment because it's too short.", ); this.removeLastTimelineSegment(); } this.CMDuration = CMDuration; super.end(endDate, result); } addTimelineSegment( segment: ChallengeModeTimelineSegment, endPrevious?: Date, ) { if (endPrevious) { this.endCurrentTimelineSegment(endPrevious); } this.timeline.push(segment); } endCurrentTimelineSegment(date: Date) { if (this.currentSegment) { this.currentSegment.logEnd = date; } } removeLastTimelineSegment() { this.timeline.pop(); } getLastBossEncounter(): ChallengeModeTimelineSegment | undefined { if (this.flavour !== Flavour.Retail) { return undefined; } return this.timeline .slice() .reverse() .find((v) => v.segmentType === TimelineSegmentType.BossEncounter); } getMetadata(): Metadata { const rawCombatants = Array.from(this.combatantMap.values()).map( (combatant: Combatant) => combatant.getRaw(), ); const rawSegments = this.timeline.map( (segment: ChallengeModeTimelineSegment) => segment.getRaw(), ); return { category: VideoCategory.MythicPlus, zoneID: this.zoneID, mapID: this.mapID, duration: this.duration, result: this.result, upgradeLevel: this.upgradeLevel, player: this.player.getRaw(), challengeModeTimeline: rawSegments, keystoneLevel: this.level, flavour: this.flavour, overrun: this.overrun, combatants: rawCombatants, affixes: this.affixes, deaths: this.deaths, start: this.startDate.getTime(), uniqueHash: this.getUniqueHash(), appVersion: app.getVersion(), }; } getFileName(): string { let fileName = `${this.dungeonName} +${this.level} (${this.resultInfo})`; try { if (this.player.name !== undefined) { fileName = `${this.player.name} - ${fileName}`; } } catch { console.warn('[ChallengeModeDungeon] Failed to get player combatant'); } return fileName; } } ================================================ FILE: src/activitys/Manual.ts ================================================ import { Flavour, Metadata } from 'main/types'; import { VideoCategory } from '../types/VideoCategory'; import Activity from './Activity'; import { app } from 'electron'; /** * Class representing a manual recording. */ export default class Manual extends Activity { constructor(startDate: Date, flavour: Flavour) { super(startDate, VideoCategory.Manual, flavour); } /** * Minimal set of valid metadata, we know nothing. */ getMetadata(): Metadata { return { category: VideoCategory.Manual, flavour: this.flavour, duration: this.duration, result: this.result, overrun: this.overrun, combatants: [], start: this.startDate.getTime(), uniqueHash: this.getUniqueHash(), appVersion: app.getVersion(), }; } getFileName(): string { return 'Manual'; } } ================================================ FILE: src/activitys/RaidEncounter.ts ================================================ import { Flavour, Metadata, RaidInstanceType } from 'main/types'; import Combatant from '../main/Combatant'; import { getLocalePhrase, Language } from '../localisation/translations'; import { instanceDifficulty, raidInstances } from '../main/constants'; import { VideoCategory } from '../types/VideoCategory'; import Activity from './Activity'; import { Phrase } from 'localisation/phrases'; import { app } from 'electron'; /** * Class representing a raid encounter. */ export default class RaidEncounter extends Activity { private _difficultyID: number; private _encounterID: number; private _encounterName: string; private currentHp = 1; private maxHp = 1; constructor( startDate: Date, encounterID: number, encounterName: string, difficultyID: number, flavour: Flavour, ) { super(startDate, VideoCategory.Raids, flavour); this._difficultyID = difficultyID; this._encounterID = encounterID; this._encounterName = encounterName; this.overrun = 3; // Even for wipes it's nice to have some overrun. } get difficultyID() { return this._difficultyID; } get encounterID() { return this._encounterID; } get encounterName() { return this._encounterName; } get zoneID(): number { if (!this.encounterID) { console.warn("[RaidEncounter] EncounterID not set, can't get zone ID"); } let zoneID = 0; raidInstances.every((raid) => { if (raid.encounters[this.encounterID]) { zoneID = raid.zoneId; return false; } return true; }); return zoneID; } get raid(): RaidInstanceType { const raids = raidInstances.filter((raid) => Object.prototype.hasOwnProperty.call(raid.encounters, this.encounterID), ); const raid = raids.pop(); if (!raid) { console.warn("Encounter not found in known raids, can't get raid name"); const unknownRaid: RaidInstanceType = { zoneId: 0, name: 'Unknown Raid', shortName: 'Unknown Raid', encounters: {}, }; return unknownRaid; } return raid; } get resultInfo() { if (this.result === undefined) { throw new Error('[RaidEncounter] Tried to get result info but no result'); } const language = this.cfg.get('language') as Language; if (this.result) { return getLocalePhrase(language, Phrase.Kill); } return getLocalePhrase(language, Phrase.Wipe); } get difficulty() { const isRecognisedDifficulty = Object.prototype.hasOwnProperty.call( instanceDifficulty, this.difficultyID, ); if (!isRecognisedDifficulty) { throw new Error( `[RaidEncounters] Unknown difficulty ID: ${this.difficultyID}`, ); } return instanceDifficulty[this.difficultyID]; } getMetadata(): Metadata { const rawCombatants = Array.from(this.combatantMap.values()).map( (combatant: Combatant) => combatant.getRaw(), ); const bossPercent = Math.round((100 * this.currentHp) / this.maxHp); return { category: VideoCategory.Raids, zoneID: this.zoneID, zoneName: this.raid.shortName, flavour: this.flavour, encounterID: this.encounterID, encounterName: this.encounterName, difficultyID: this.difficultyID, difficulty: this.difficulty.difficulty, duration: this.duration, result: this.result, player: this.player.getRaw(), deaths: this.deaths, overrun: this.overrun, combatants: rawCombatants, start: this.startDate.getTime(), uniqueHash: this.getUniqueHash(), bossPercent, appVersion: app.getVersion(), }; } getFileName(): string { let fileName = `${this.encounterName} [${this.difficulty.difficulty}] (${this.resultInfo})`; if (this.raid.name !== 'Unknown Raid') { fileName = `${this.raid.name}, ${fileName}`; } try { if (this.player.name !== undefined) { fileName = `${this.player.name} - ${fileName}`; } } catch { console.warn('[RaidEncounter] Failed to get player combatant'); } return fileName; } /** * Update the max and current HP of the boss. Used to calculate the * HP percentage at the end of the fight. * * The log handler doesn't have a way to tell if the unit is the boss or * not (atleast, not without hardcoding boss names), so we let the handler * call this this on any unit, but ignore any units with less than the max HP * of the boss. * * It's a fairly safe bet that the boss will always have the most HP in an * encounter. Can't think of any fights where this isn't true. */ public updateHp(current: number, max: number): void { if (max < this.maxHp) return; this.maxHp = max; this.currentHp = current; } } ================================================ FILE: src/activitys/SoloShuffle.ts ================================================ import Combatant from 'main/Combatant'; import { Language, Phrase } from '../localisation/phrases'; import { getLocalePhrase } from '../localisation/translations'; import { Flavour, Metadata, PlayerDeathType, SoloShuffleTimelineSegment, } from '../main/types'; import { classicArenas, retailArenas } from '../main/constants'; import Activity from './Activity'; import ArenaMatch from './ArenaMatch'; import { VideoCategory } from '../types/VideoCategory'; import { app } from 'electron'; /** * Class representing a Solo Shuffle. This is essentially a wrapper around * a list of ArenaMatch objects, where the winner of the nested ArenaMatch * objects are determined by whoever gets the first kill. * * @@@ TODO handle leaver players (i.e. self), might just need a test? */ export default class SoloShuffle extends Activity { private rounds: ArenaMatch[] = []; constructor(startDate: Date, zoneID: number) { super(startDate, VideoCategory.SoloShuffle, Flavour.Retail); this._zoneID = zoneID; this.overrun = 3; this.startRound(startDate); } get zoneID() { return this._zoneID; } get currentRound() { return this.rounds[this.rounds.length - 1]; } get zoneName() { if (!this.zoneID) { throw new Error('[ArenaMatch] Tried to get zoneName but no zoneID'); } if (this.flavour === Flavour.Retail) { return retailArenas[this._zoneID as number]; } return classicArenas[this._zoneID as number]; } get roundsWon() { let score = 0; this.rounds.forEach((arenaMatch) => { if (arenaMatch.result) score++; }); return score; } get resultInfo() { const win = this.roundsWon; const loss = this.rounds.length - this.roundsWon; return `${win}-${loss}`; } get playerGUID() { return this.currentRound.playerGUID; } set playerGUID(GUID) { this.currentRound.playerGUID = GUID; } get player() { if (!this.playerGUID) { throw new Error('Failed to get player combatant, playerGUID not set'); } const player = this.currentRound.getCombatant(this.playerGUID); if (!player) { throw new Error('Player not found in combatants'); } return player; } getCombatant(GUID: string) { const currentRound = this.rounds[this.rounds.length - 1]; return currentRound.getCombatant(GUID); } startRound(startDate: Date) { if (!this.zoneID) { throw new Error('[Solo Shuffle] No zoneID set'); } const newRound = new ArenaMatch( startDate, VideoCategory.SoloShuffle, this.zoneID, Flavour.Retail, this.cfg, ); this.rounds.push(newRound); } endRound(endDate: Date, winningTeamID: number) { this.currentRound.endArena(endDate, winningTeamID); } addDeath(death: PlayerDeathType) { console.info('[Solo Shuffle] Adding death to solo shuffle', death); if (this.currentRound.deaths.length > 0) { console.info( '[Solo Shuffle] Already have a death in this round', this.currentRound.deaths, ); return; } if (!this.player || this.player.teamID === undefined) { console.error( "[Solo Shuffle] Tried to add a death but don't know the player", ); return; } let winningTeamID; if (!death.friendly) { console.info('[Solo Shuffle] Adding enemy death'); winningTeamID = this.player.teamID; } else { console.info('[Solo Shuffle] Adding friendly death'); if (this.player.teamID === 0) { winningTeamID = 1; } else { winningTeamID = 0; } } this.currentRound.addDeath(death); this.endRound(death.date, winningTeamID); super.addDeath(death); } addCombatant(combatant: Combatant) { this.currentRound.addCombatant(combatant); } endGame(endDate: Date) { console.info('[Solo Shuffle] Ending game'); for (let i = 0; i < this.rounds.length; i++) { console.info('[Solo Shuffle] Round', i, ':', this.rounds[i].resultInfo); } super.end(endDate, true); } getTimelineSegments(): SoloShuffleTimelineSegment[] { const segments = []; for (let i = 0; i < this.rounds.length; i++) { const gameStartTime = this.startDate.getTime(); const roundStartTime = this.rounds[i].startDate.getTime(); const roundEndTime = this.rounds[i].endDate?.getTime(); if (roundEndTime === undefined) { segments.push({ round: i + 1, timestamp: (roundStartTime - gameStartTime) / 1000, result: this.rounds[i].result, }); } else { segments.push({ round: i + 1, timestamp: (roundStartTime - gameStartTime) / 1000, result: this.rounds[i].result, duration: (roundEndTime - roundStartTime) / 1000, }); } } return segments; } getMetadata(): Metadata { const rawCombatants = Array.from( // Just use the combatants from the final round. this.currentRound.combatantMap.values(), ).map((combatant: Combatant) => combatant.getRaw()); return { category: this.category, zoneID: this.zoneID, zoneName: this.zoneName, flavour: this.flavour, duration: this.duration, result: this.result, deaths: this.deaths, player: this.player.getRaw(), soloShuffleRoundsWon: this.roundsWon, soloShuffleRoundsPlayed: this.rounds.length, soloShuffleTimeline: this.getTimelineSegments(), combatants: rawCombatants, overrun: this.overrun, start: this.startDate.getTime(), uniqueHash: this.getUniqueHash(), appVersion: app.getVersion(), }; } getFileName() { const language = this.cfg.get('language') as Language; const category = getLocalePhrase( language, Phrase.VideoCategorySoloShuffleLabel, ); let fileName = `${category} ${this.zoneName} (${this.resultInfo})`; try { if (this.player.name !== undefined) { fileName = `${this.player.name} - ${fileName}`; } } catch { console.warn('[SoloShuffle] Failed to get player combatant'); } return fileName; } } ================================================ FILE: src/config/ConfigService.ts ================================================ import ElectronStore from 'electron-store'; import { ipcMain } from 'electron'; import path from 'path'; import { EventEmitter } from 'stream'; import { configSchema, ConfigurationSchema } from './configSchema'; import _ from 'lodash'; /** * Interface for the ConfigService class. */ export interface IConfigService extends EventEmitter { /** * Check if a specific configuration key exists. * @param key - The configuration key to check. */ has(key: keyof ConfigurationSchema): boolean; /** * Get the value of a specific configuration key. * @param key - The configuration key to retrieve. */ get(key: keyof ConfigurationSchema): T; /** * Set the value of a specific configuration key. * @param key - The configuration key to set. * @param value - The value to set for the key. */ set(key: keyof ConfigurationSchema, value: any): void; /** * Get the value of a configuration key as a number. * @param key - The configuration key to retrieve. */ getNumber(key: keyof ConfigurationSchema): number; /** * Get the value of a configuration key as a string. * @param key - The configuration key to retrieve. */ getString(key: keyof ConfigurationSchema): string; /** * Get the value of a configuration key formatted as a file path. * @param key - The configuration key to retrieve. */ getPath(key: keyof ConfigurationSchema): string; } export default class ConfigService extends EventEmitter implements IConfigService { /** * Singleton instance of class. */ private static instance: ConfigService; private _store = new ElectronStore({ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore 'schema' is "wrong", but it really isn't. configSchema, name: 'config-v3', }); /** * Get the instance of the class as a singleton. * There should only ever be one instance created and this method facilitates that. */ static getInstance(): ConfigService { if (!this.instance) this.instance = new this(); return this.instance; } private constructor() { super(); this.cleanupStore(); const loggable = this._store.store; if (loggable.cloudAccountPassword) { loggable.cloudAccountPassword = '**********'; } console.info('[Config Service] Using configuration', loggable); this._store.onDidAnyChange((newValue: any, oldValue: any) => { this.emit('configChanged', oldValue, newValue); }); /** * Getter and setter config listeners. */ ipcMain.on('config', (event, args) => { switch (args[0]) { case 'get': { const value = this.get(args[1]); event.returnValue = value; return; } case 'set': { const [key, value] = [args[1], args[2]]; if (!this.configValueChanged(key, value)) { return; } this.set(key, value); this.emit('change', key, value); ConfigService.logConfigChanged({ [key]: value }); return; } case 'set_values': { const configObject = args[1]; const configKeys = Object.keys(configObject); const newConfigValues: { [key: string]: any } = {}; configKeys.forEach((key: string) => { if (!this.configValueChanged(key, configObject[key])) { return; } newConfigValues[key] = configObject[key]; }); Object.keys(newConfigValues).forEach((key: any) => { const value = newConfigValues[key]; this.set(key, value); this.emit('change', key, value); }); ConfigService.logConfigChanged(newConfigValues); return; } default: { console.error( '[ConfigService] Unrecognised config call, should be one of get, set or set_values', ); } } }); } has(key: keyof ConfigurationSchema): boolean { return this._store.has(key); } get(key: keyof ConfigurationSchema): T { if (!configSchema[key]) { throw Error( `[Config Service] Attempted to get invalid configuration key '${key}'`, ); } const value = this._store.get(key); if (!this._store.has(key) || value === null || value === undefined) { if (configSchema[key] && configSchema[key].default !== undefined) { return configSchema[key].default as T; } } return value as T; } set(key: keyof ConfigurationSchema, value: any): void { if (!configSchema[key]) { throw Error( `[Config Service] Attempted to set invalid configuration key '${key}'`, ); } if (value === null || value === undefined) { this._store.delete(key); return; } this._store.set(key, value); } getPath(key: keyof ConfigurationSchema): string { const value = this.getString(key); if (!value) { return ''; } return path.join(value, path.sep); } getNumber(key: keyof ConfigurationSchema): number { return this.has(key) ? parseInt(this.get(key), 10) : NaN; } getString(key: keyof ConfigurationSchema): string { return this.has(key) ? (this.get(key) as string) : ''; } /** * Ensure that only keys specified in the `configSchema` exists in the store * and delete any that are no longer relevant. This is necessary to keep the * config store up to date when config keys occasionally change/become obsolete. */ private cleanupStore(): void { const configSchemaKeys = Object.keys(configSchema); const keysToDelete = Object.keys(this._store.store).filter( (k) => !configSchemaKeys.includes(k), ); if (!keysToDelete.length) { return; } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore complains about 'string' not being assignable to // keyof ConfigurationSchema, which is true but also moot since we're // trying to remove keys that _don't_ exist in the schema. keysToDelete.forEach((k) => this._store.delete(k)); console.info( '[Config Service] Deleted deprecated keys from configuration store', keysToDelete, ); } /** * Determine whether a configuration value has changed. */ private configValueChanged(key: string, value: any): boolean { // We're checking for null here because we don't allow storing // null values and as such if we get one, it's because it's empty/shouldn't // be saved. if (value === null) return false; // Lodash handles deep array equality checks here, which is nice for more // complex config (i.e. audio sources). return !_.isEqual(this._store.get(key), value); } private static logConfigChanged(newConfig: { [key: string]: any }): void { if (newConfig.cloudAccountPassword) { newConfig.cloudAccountPassword = '**********'; } console.info('[Config Service] Configuration changed:', newConfig); } } ================================================ FILE: src/config/configSchema.ts ================================================ import { Phrase } from 'localisation/phrases'; import { AudioSource, AudioSourceType } from 'main/types'; export type ConfigurationSchema = { storagePath: string; bufferStoragePath: string; separateBufferPath: boolean; retailLogPath: string; retailPtrLogPath: string; classicLogPath: string; classicPtrLogPath: string; eraLogPath: string; maxStorage: number; monitorIndex: number; selectedCategory: number; audioSources: AudioSource[]; minEncounterDuration: number; startUp: boolean; startMinimized: boolean; obsOutputResolution: string; obsFPS: number; obsForceMono: boolean; obsQuality: string; obsCaptureMode: string; // 'window_capture' or 'game_capture' or 'monitor_capture' obsRecEncoder: string; recordRetail: boolean; recordRetailPtr: boolean; recordClassic: boolean; recordClassicPtr: boolean; recordEra: boolean; recordRaids: boolean; recordDungeons: boolean; recordTwoVTwo: boolean; recordThreeVThree: boolean; recordFiveVFive: boolean; recordSkirmish: boolean; recordSoloShuffle: boolean; recordBattlegrounds: boolean; captureCursor: boolean; minKeystoneLevel: number; recordChallengeModes: boolean; minRaidDifficulty: string; minimizeOnQuit: boolean; minimizeToTray: boolean; chatOverlayEnabled: boolean; chatOverlayOwnImage: boolean; chatOverlayOwnImagePath: string; chatOverlayScale: number; chatOverlayXPosition: number; chatOverlayYPosition: number; chatOverlayCropX: number; chatOverlayCropY: number; deathMarkers: number; encounterMarkers: boolean; roundMarkers: boolean; pushToTalk: boolean; pushToTalkKey: number; pushToTalkMouseButton: number; pushToTalkModifiers: string; pushToTalkReleaseDelay: number; obsAudioSuppression: boolean; raidOverrun: number; dungeonOverrun: number; cloudStorage: boolean; cloudUpload: boolean; cloudUploadRetail: boolean; cloudUploadClassic: boolean; cloudUploadRateLimit: boolean; cloudUploadRateLimitMbps: number; cloudAccountName: string; cloudAccountPassword: string; cloudGuildName: string; cloudUpload2v2: boolean; cloudUpload3v3: boolean; cloudUpload5v5: boolean; cloudUploadSkirmish: boolean; cloudUploadSoloShuffle: boolean; cloudUploadDungeons: boolean; cloudUploadRaids: boolean; cloudUploadBattlegrounds: boolean; cloudUploadRaidMinDifficulty: string; cloudUploadDungeonMinLevel: number; cloudUploadClips: boolean; language: string; hideEmptyCategories: boolean; hardwareAcceleration: boolean; recordCurrentRaidEncountersOnly: boolean; uploadCurrentRaidEncountersOnly: boolean; forceSdr: boolean; videoSourceScale: number; videoSourceXPosition: number; videoSourceYPosition: number; manualRecord: boolean; manualRecordHotKey: number; manualRecordHotKeyModifiers: string; manualRecordSoundAlert: boolean; manualRecordUpload: boolean; firstTimeSetup: boolean; chatUserNameAgreed: string; validateLogPaths: boolean; }; export type ConfigurationSchemaKey = keyof ConfigurationSchema; /** * Config schema. The descriptions included here may get displayed in the UI. */ export const configSchema = { storagePath: { description: Phrase.StoragePathDescription, type: 'string', default: '', }, separateBufferPath: { description: Phrase.SeparateBufferPathDescription, type: 'boolean', default: false, }, bufferStoragePath: { description: Phrase.BufferStoragePathDescription, type: 'string', default: '', }, retailLogPath: { description: Phrase.RetailLogPathDescription, type: 'string', default: '', }, classicLogPath: { description: Phrase.ClassicLogPathDescription, type: 'string', default: '', }, classicPtrLogPath: { description: Phrase.ClassicPtrLogPathDescription, type: 'string', default: '', }, eraLogPath: { description: Phrase.EraLogPathDescription, type: 'string', default: '', }, retailPtrLogPath: { description: Phrase.RetailPtrLogPathDescription, type: 'string', default: '', }, maxStorage: { description: Phrase.MaxStorageDescription, type: 'integer', default: 50, minimum: 0, }, monitorIndex: { description: Phrase.MonitorIndexDescription, type: 'integer', default: 0, minimum: 1, maximum: 4, }, selectedCategory: { description: Phrase.SelectedCategoryDescription, type: 'integer', default: 1, }, audioSources: { description: Phrase.AudioProcessDevicesDescription, type: 'array', default: [ { id: 'WCR Audio Source 1', friendly: 'default', device: 'default', volume: 1, type: AudioSourceType.OUTPUT, }, { id: 'WCR Audio Source 2', friendly: 'default', device: 'default', volume: 1, type: AudioSourceType.INPUT, }, ], }, minEncounterDuration: { description: Phrase.MinEncounterDurationDescription, type: 'integer', default: 15, maximum: 10000, }, startUp: { description: Phrase.StartUpDescription, type: 'boolean', default: false, }, startMinimized: { description: Phrase.StartMinimizedDescription, type: 'boolean', default: false, }, obsOutputResolution: { description: Phrase.ObsOutputResolutionDescription, type: 'string', default: '1920x1080', }, obsFPS: { description: Phrase.ObsFPSDescription, type: 'integer', default: 60, minimum: 15, maximum: 60, }, obsForceMono: { description: Phrase.ObsForceMonoDescription, type: 'boolean', default: true, }, obsQuality: { description: Phrase.ObsQualityDescription, type: 'string', default: 'Moderate', }, obsCaptureMode: { description: Phrase.ObsCaptureModeDescription, type: 'string', default: 'window_capture', }, obsRecEncoder: { description: Phrase.ObsRecEncoderDescription, type: 'string', default: 'obs_x264', }, recordRetail: { description: Phrase.RecordRetailDescription, type: 'boolean', default: false, }, recordClassic: { description: Phrase.RecordClassicDescription, type: 'boolean', default: false, }, recordClassicPtr: { description: Phrase.RecordClassicPtrDescription, type: 'boolean', default: false, }, recordEra: { description: Phrase.RecordEraDescription, type: 'boolean', default: false, }, recordRetailPtr: { description: Phrase.RecordRetailPtrDescription, type: 'boolean', default: false, }, recordRaids: { description: Phrase.RecordRaidsDescription, type: 'boolean', default: true, }, recordDungeons: { description: Phrase.RecordDungeonsDescription, type: 'boolean', default: true, }, recordTwoVTwo: { description: Phrase.RecordTwoVTwoDescription, type: 'boolean', default: true, }, recordThreeVThree: { description: Phrase.RecordThreeVThreeDescription, type: 'boolean', default: true, }, recordFiveVFive: { description: Phrase.RecordFiveVFiveDescription, type: 'boolean', default: true, }, recordSkirmish: { description: Phrase.RecordSkirmishDescription, type: 'boolean', default: true, }, recordSoloShuffle: { description: Phrase.RecordSoloShuffleDescription, type: 'boolean', default: true, }, recordBattlegrounds: { description: Phrase.RecordBattlegroundsDescription, type: 'boolean', default: true, }, captureCursor: { description: Phrase.CaptureCursorDescription, type: 'boolean', default: false, }, minKeystoneLevel: { description: Phrase.MinKeystoneLevelDescription, type: 'integer', default: 2, }, recordChallengeModes: { description: Phrase.ChallengeModeDescription, type: 'boolean', default: true, }, minRaidDifficulty: { description: Phrase.MinRaidDifficultyDescription, type: 'string', default: 'LFR', }, minimizeOnQuit: { description: Phrase.MinimizeOnQuitDescription, type: 'boolean', default: true, }, minimizeToTray: { description: Phrase.MinimizeToTrayDescription, type: 'boolean', default: true, }, chatOverlayEnabled: { description: Phrase.ChatOverlayEnabledDescription, type: 'boolean', default: false, }, chatOverlayOwnImage: { description: Phrase.ChatOverlayOwnImageDescription, type: 'boolean', default: false, }, chatOverlayOwnImagePath: { description: Phrase.ChatOverlayOwnImagePathDescription, type: 'string', default: '', }, chatOverlayScale: { description: Phrase.ChatOverlayScaleDescription, type: 'integer', default: 1, }, chatOverlayXPosition: { description: Phrase.ChatOverlayXPositionDescription, type: 'integer', default: 0, }, chatOverlayYPosition: { description: Phrase.ChatOverlayYPositionDescription, type: 'integer', default: 0, }, chatOverlayCropX: { description: Phrase.ChatOverlayWidthDescription, type: 'integer', default: 0, }, chatOverlayCropY: { description: Phrase.ChatOverlayHeightDescription, type: 'integer', default: 0, }, deathMarkers: { description: Phrase.DeathMarkersDescription, type: 'integer', default: 1, }, encounterMarkers: { description: Phrase.EncounterMarkersDescription, type: 'integer', default: true, }, roundMarkers: { description: Phrase.RoundMarkersDescription, type: 'boolean', default: true, }, pushToTalk: { description: Phrase.PushToTalkDescription, type: 'boolean', default: false, }, pushToTalkKey: { description: Phrase.PushToTalkKeyDescription, type: 'integer', default: -1, }, pushToTalkMouseButton: { description: Phrase.PushToTalkMouseButtonDescription, type: 'integer', default: -1, }, pushToTalkModifiers: { description: Phrase.PushToTalkModifiersDescription, type: 'string', default: '', }, pushToTalkReleaseDelay: { description: Phrase.PushToTalkReleaseDelayDescription, type: 'integer', default: 0, minimum: 0, maximum: 2000, }, obsAudioSuppression: { description: Phrase.ObsAudioSuppressionDescription, type: 'boolean', default: true, }, raidOverrun: { description: Phrase.RaidOverrunDescription, type: 'integer', default: 15, minimum: 0, maximum: 60, }, dungeonOverrun: { description: Phrase.DungeonOverrunDescription, type: 'integer', default: 5, minimum: 0, maximum: 60, }, cloudStorage: { description: Phrase.CloudStorageDescription, type: 'boolean', default: false, }, cloudUpload: { description: Phrase.CloudUploadDescription, type: 'boolean', default: false, }, cloudUploadRetail: { description: Phrase.CloudUploadRetailDescription, type: 'boolean', default: true, }, cloudUploadClassic: { description: Phrase.CloudUploadClassicDescription, type: 'boolean', default: true, }, cloudUploadRateLimit: { description: Phrase.CloudUploadRateLimitDescription, type: 'boolean', default: false, }, cloudUploadRateLimitMbps: { description: Phrase.CloudUploadRateLimitMbpsDescription, type: 'integer', default: 100, }, cloudAccountName: { description: Phrase.CloudAccountNameDescription, type: 'string', default: '', }, cloudAccountPassword: { description: Phrase.CloudAccountPasswordDescription, type: 'string', default: '', }, cloudGuildName: { description: Phrase.CloudGuildNameDescription, type: 'string', default: '', }, cloudUpload2v2: { description: Phrase.CloudUpload2v2Description, type: 'boolean', default: true, }, cloudUpload3v3: { description: Phrase.CloudUpload3v3Description, type: 'boolean', default: true, }, cloudUpload5v5: { description: Phrase.CloudUpload5v5Description, type: 'boolean', default: true, }, cloudUploadSkirmish: { description: Phrase.CloudUploadSkirmishDescription, type: 'boolean', default: true, }, cloudUploadSoloShuffle: { description: Phrase.CloudUploadSoloShuffleDescription, type: 'boolean', default: true, }, cloudUploadDungeons: { description: Phrase.CloudUploadDungeonsDescription, type: 'boolean', default: true, }, cloudUploadRaids: { description: Phrase.CloudUploadRaidsDescription, type: 'boolean', default: true, }, cloudUploadBattlegrounds: { description: Phrase.CloudUploadBattlegroundsDescription, type: 'boolean', default: true, }, cloudUploadRaidMinDifficulty: { description: Phrase.CloudUploadRaidMinDifficultyDescription, type: 'string', default: 'LFR', }, cloudUploadDungeonMinLevel: { description: Phrase.CloudUploadDungeonMinLevelDescription, type: 'integer', default: 2, }, cloudUploadClips: { description: Phrase.CloudUploadClipsDescription, type: 'boolean', default: true, }, language: { description: Phrase.LanguageDescription, type: 'string', default: 'English', }, hideEmptyCategories: { description: Phrase.HideEmptyCategoriesDescription, type: 'boolean', default: false, }, hardwareAcceleration: { description: Phrase.HardwareAccelerationDescription, type: 'boolean', default: false, }, recordCurrentRaidEncountersOnly: { description: Phrase.RecordCurrentRaidsOnlyDescription, type: 'boolean', default: false, }, uploadCurrentRaidEncountersOnly: { description: Phrase.UploadCurrentRaidsOnlyDescription, type: 'boolean', default: false, }, forceSdr: { description: Phrase.ForceSdrDescription, type: 'boolean', default: false, }, videoSourceScale: { description: Phrase.VideoSourceScaleDescription, type: 'number', default: 1, }, videoSourceXPosition: { description: Phrase.VideoSourceXPositionDescription, type: 'number', default: 0, }, videoSourceYPosition: { description: Phrase.VideoSourceYPositionDescription, type: 'number', default: 0, }, manualRecord: { description: Phrase.ManualRecordDescription, type: 'boolean', default: false, }, manualRecordHotKey: { description: Phrase.ManualRecordHotKeyDescription, type: 'integer', default: -1, }, manualRecordHotKeyModifiers: { description: Phrase.ManualRecordHotKeyDescription, type: 'string', default: '', }, manualRecordSoundAlert: { description: Phrase.ManualRecordSoundAlertDescription, type: 'boolean', default: true, }, manualRecordUpload: { description: Phrase.ManualRecordUploadDescription, type: 'boolean', default: true, }, firstTimeSetup: { description: Phrase.FirstTimeSetupDescription, type: 'boolean', default: true, }, chatUserNameAgreed: { description: Phrase.Unknown, // Not actually exposed. type: 'string', default: '', }, validateLogPaths: { description: Phrase.ValidateLogPathsDescription, type: 'boolean', default: true, }, }; ================================================ FILE: src/localisation/chineseSimplified.ts ================================================ import { Translations, Phrase } from './phrases'; /* eslint-disable prettier/prettier */ const CHINESE_SIMPLIFIED: Translations = { [Phrase.NoVideosSaved]: '你还没有为此分类保存任何视频', [Phrase.FirstTimeHere]: '如果这是你第一次使用,下面的链接中可以找到设置说明。如果你遇到问题,请在 Discord 的 #help 频道寻求支持。', [Phrase.SetupInstructions]: '设置说明', [Phrase.ClipsDisplayedHere]: '你剪辑的视频将显示在这里。', [Phrase.NoClipsSaved]: '你还没有保存任何剪辑', [Phrase.StoragePathDescription]: '用于存储录制文件的位置。Warcraft Recorder 将对该目录进行管理,初始设置时应为空,请勿在其中直接修改内容。', [Phrase.SeparateBufferPathDescription]: '启用在另一个位置存储临时录制文件。此位置应始终是本地目录。该功能主要给那些想把最终录制文件存放到网络存储(NFS)但又不想一直通过网络写入的人使用。', [Phrase.BufferStoragePathDescription]: '存储临时录制文件的位置。如果未设置,默认为存储路径下的一个文件夹。', [Phrase.RetailLogPathDescription]: '你魔兽世界正式服安装目录的 Logs 文件夹路径,例如 "D:\\World of Warcraft\\_retail_\\Logs"。', [Phrase.ClassicLogPathDescription]: '你魔兽世界怀旧服安装目录的 Logs 文件夹路径,例如 "D:\\World of Warcraft\\_classic_\\Logs"。', [Phrase.EraLogPathDescription]: '你魔兽世界经典旧世服安装目录的 Logs 文件夹路径,例如 "D:\\World of Warcraft\\_classic_era_\\Logs"。', [Phrase.MaxStorageDescription]: '应用程序可用于视频文件的最大存储容量。为了保持在此限制内,旧视频将按顺序被删除,但录制不会停止。如果设置为 0,则表示不限制。', [Phrase.MonitorIndexDescription]: '需要录制的显示器序号,仅在选择屏幕捕获模式时有效。', [Phrase.SelectedCategoryDescription]: '界面中最后选择的视频分类。', [Phrase.AudioInputDevicesDescription]: '录制中包含的音频输入设备。', [Phrase.AudioOutputDevicesDescription]: '录制中包含的音频输出设备。', [Phrase.MinEncounterDurationDescription]: '低于此时长的首领战不会被录制。此设置旨在避免保存首领战的快速重置。', [Phrase.StartUpDescription]: 'Windows 启动时自动启动此应用程序。', [Phrase.StartMinimizedDescription]: '应用程序以最小化托盘的形式打开。', [Phrase.ObsOutputResolutionDescription]: '视频在硬盘上保存时的分辨率。可设置为魔兽世界所用显示器的大小,或者更低的分辨率以缩小视频。', [Phrase.ObsFPSDescription]: '录制视频的帧率。较低的帧率会减小视频体积,但回放画面也会更加卡顿。', [Phrase.ObsForceMonoDescription]: '是否将音频输入设备强制为单声道。如果你的麦克风声音只从一个立体声声道输出,请启用此选项。', [Phrase.ObsQualityDescription]: '录制质量。更高的质量会增加对编码器的压力,并且会产生更大的视频文件。', [Phrase.ObsCaptureModeDescription]: 'OBS 用于录制的捕获模式。更多详细信息可见 Discord 中的 #faq 频道。', [Phrase.ObsRecEncoderDescription]: '要使用的视频编码器。通常来说,硬件编码器表现更好,但它依赖于你的显卡。', [Phrase.RecordRetailDescription]: '是否录制正式服。', [Phrase.RecordClassicDescription]: '是否录制怀旧服。', [Phrase.RecordEraDescription]: '是否录制经典旧世服。', [Phrase.RecordRaidsDescription]: '是否录制团队副本。', [Phrase.RecordDungeonsDescription]: '是否录制大秘境。', [Phrase.RecordTwoVTwoDescription]: '是否录制 2v2。', [Phrase.RecordThreeVThreeDescription]: '是否录制 3v3。', [Phrase.RecordFiveVFiveDescription]: '是否录制 5v5。', [Phrase.RecordSkirmishDescription]: '是否录制练习赛。', [Phrase.RecordSoloShuffleDescription]: '是否录制单排竞技场。', [Phrase.RecordBattlegroundsDescription]: '是否录制战场。', [Phrase.CaptureCursorDescription]: '是否在录制中包含鼠标光标。', [Phrase.MinKeystoneLevelDescription]: '需要录制的大秘境最低钥石等级。', [Phrase.ChallengeModeDescription]: '是否录制挑战模式。', [Phrase.MinRaidDifficultyDescription]: '要录制的最低团队副本难度(仅适用于正式服)。', [Phrase.MinimizeOnQuitDescription]: '当点击关闭按钮时,是否将程序最小化而不是退出。', [Phrase.MinimizeToTrayDescription]: '当点击最小化按钮时,是否最小化到系统托盘而不是任务栏。', [Phrase.ChatOverlayEnabledDescription]: '是否在场景中添加一个聊天覆盖层。', [Phrase.ChatOverlayOwnImageDescription]: '是否使用自定义图像作为聊天覆盖层。此功能仅对 Pro 用户开放。', [Phrase.ChatOverlayOwnImagePathDescription]: '用作聊天覆盖层的 PNG 文件。此功能仅对 Pro 用户开放。', [Phrase.ChatOverlayWidthDescription]: '聊天覆盖层的宽度。', [Phrase.ChatOverlayHeightDescription]: '聊天覆盖层的高度。', [Phrase.ChatOverlayScaleDescription]: '聊天覆盖层的缩放比例。', [Phrase.ChatOverlayXPositionDescription]: '聊天覆盖层的 X 位置。', [Phrase.ChatOverlayYPositionDescription]: '聊天覆盖层的 Y 位置。', [Phrase.SpeakerVolumeDescription]: '录制中扬声器的音量(0 到 1)。', [Phrase.MicVolumeDescription]: '录制中麦克风的音量(0 到 1)。', [Phrase.DeathMarkersDescription]: '在视频时间轴上显示的死亡标记。', [Phrase.EncounterMarkersDescription]: '在视频时间轴上显示的首领战标记。', [Phrase.RoundMarkersDescription]: '在视频时间轴上显示的回合标记。', [Phrase.PushToTalkDescription]: '音频输入设备是否一直录音,还是只在按住热键时录音。', [Phrase.PushToTalkKeyDescription]: '按键说话热键的键值代码。', [Phrase.PushToTalkMouseButtonDescription]: '按键说话的鼠标按键。', [Phrase.PushToTalkModifiersDescription]: '设置需要与按键说话热键一起按下的修饰键(用逗号分隔)。', [Phrase.PushToTalkReleaseDelayDescription]: '在您松开按下通话键(Push To Talk)后,麦克风保持激活的时长。', [Phrase.ObsAudioSuppressionDescription]: '抑制麦克风拾取的背景噪音,这有助于减少键盘敲击、呼吸声等。', [Phrase.RaidOverrunDescription]: '击杀首领后额外录制的秒数。', [Phrase.DungeonOverrunDescription]: '完成大秘境后额外录制的秒数。', [Phrase.CloudStorageDescription]: '启用从云端播放视频的功能。', [Phrase.CloudUploadDescription]: '将录制的视频上传到云端,这既支持在录制完成后自动上传,也允许手动上传现有视频。', [Phrase.CloudUploadRetailDescription]: '如果零售录音应该上传到云端。', [Phrase.CloudUploadClassicDescription]: '如果经典录音应该上传到云端。', [Phrase.CloudUploadRateLimitDescription]: '是否对上传云端进行速率限制。如果上传导致游戏延迟,可以考虑启用。', [Phrase.CloudUploadRateLimitMbpsDescription]: '上传速率限制(MB/s)', [Phrase.CloudAccountNameDescription]: '你的 Warcraft Recorder 账号用户名。', [Phrase.CloudAccountPasswordDescription]: '你的 Warcraft Recorder 账号密码。', [Phrase.CloudGuildNameDescription]: '你的账号所属的公会或团队名称。', [Phrase.CloudUpload2v2Description]: '是否将 2v2 的录制文件自动上传至云端。', [Phrase.CloudUpload3v3Description]: '是否将 3v3 的录制文件自动上传至云端。', [Phrase.CloudUpload5v5Description]: '是否将 5v5 的录制文件自动上传至云端。', [Phrase.CloudUploadSkirmishDescription]: '是否将练习赛的录制文件自动上传至云端。', [Phrase.CloudUploadSoloShuffleDescription]: '是否将单排竞技场的录制文件自动上传至云端。', [Phrase.CloudUploadDungeonsDescription]: '是否将大秘境的录制文件自动上传至云端。', [Phrase.CloudUploadRaidsDescription]: '是否将团队副本首领战的录制文件自动上传至云端。', [Phrase.CloudUploadBattlegroundsDescription]: '是否将战场录制文件自动上传至云端。', [Phrase.CloudUploadRaidMinDifficultyDescription]: '自动上传团队副本时要求的最低难度。', [Phrase.CloudUploadDungeonMinLevelDescription]: '自动上传大秘境时要求的最低钥石等级。', [Phrase.LanguageDescription]: '应用程序使用的语言。', [Phrase.RecordingsHeading]: '录制文件', [Phrase.SettingsHeading]: '设置', [Phrase.GeneralButtonText]: '常规', [Phrase.SceneButtonText]: '场景', [Phrase.Version]: '版本', [Phrase.VideoCategoryTwoVTwoLabel]: '2v2', [Phrase.VideoCategoryThreeVThreeLabel]: '3v3', [Phrase.VideoCategoryFiveVFiveLabel]: '5v5', [Phrase.VideoCategorySkirmishLabel]: '练习赛', [Phrase.VideoCategorySoloShuffleLabel]: '单排', [Phrase.VideoCategoryMythicPlusLabel]: '大秘境', [Phrase.VideoCategoryRaidsLabel]: '团队副本', [Phrase.VideoCategoryBattlegroundsLabel]: '战场', [Phrase.VideoCategoryClipsLabel]: '剪辑', [Phrase.LogsButtonLabel]: '日志', [Phrase.DiscordButtonLabel]: 'Discord', [Phrase.TestButtonUnable]: '目前无法进行测试。要运行测试,魔兽世界必须在运行,设置必须正确,并且你不能处于任何活动中。', [Phrase.GeneralSettingsLabel]: '常规设置', [Phrase.DiskStorageFolderLabel]: '磁盘存储文件夹', [Phrase.SeparateBufferFolderLabel]: '独立的缓冲文件夹', [Phrase.BufferFolderLabel]: '缓冲文件夹', [Phrase.MaxDiskStorageLabel]: '磁盘存储上限 (GB)', [Phrase.WindowsSettingsLabel]: '应用 设置', [Phrase.RunOnStartupLabel]: '开机自动启动', [Phrase.StartMinimizedLabel]: '启动时最小化', [Phrase.MinimizeOnQuitLabel]: '关闭时最小化', [Phrase.MinimizeToTrayLabel]: '最小化到托盘', [Phrase.LocaleSettingsLabel]: '语言设置', [Phrase.LanguageLabel]: '语言', [Phrase.GameSettingsLabel]: '游戏设置', [Phrase.RecordRetailLabel]: '录制正式服', [Phrase.RetailLogPathLabel]: '正式服日志路径', [Phrase.RecordClassicLabel]: '录制怀旧服', [Phrase.ClassicLogPathLabel]: '怀旧服日志路径', [Phrase.RecordClassicEraLabel]: '录制经典旧世服', [Phrase.ClassicEraLogPathLabel]: '经典旧世服日志路径', [Phrase.PVESettingsLabel]: 'PvE 设置', [Phrase.RecordRaidsLabel]: '录制团队副本', [Phrase.MinimumEncounterDurationLabel]: '首领战最短时长 (秒)', [Phrase.RaidOverrunLabel]: '团队副本额外录制 (秒)', [Phrase.MinimumRaidDifficultyLabel]: '最低团队副本难度', [Phrase.RecordMythicPlusLabel]: '录制大秘境', [Phrase.MinimumKeystoneLevelLabel]: '最低钥石等级', [Phrase.ChallengeModeLabel]: '录制挑战模式', [Phrase.MythicPlusOverrunLabel]: '大秘境额外录制 (秒)', [Phrase.PVPSettingsLabel]: 'PvP 设置', [Phrase.Record2v2Label]: '录制 2v2', [Phrase.Record3v3Label]: '录制 3v3', [Phrase.Record5v5Label]: '录制 5v5', [Phrase.RecordSkirmishLabel]: '录制练习赛', [Phrase.RecordSoloShuffleLabel]: '录制单排竞技场', [Phrase.RecordBattlegroundsLabel]: '录制战场', [Phrase.CloudSettingsLabel]: '云端设置', [Phrase.CloudPlaybackLabel]: '云端回放', [Phrase.UserEmailLabel]: '用户或邮箱', [Phrase.PasswordLabel]: '密码', [Phrase.GuildNameLabel]: '公会名称', [Phrase.CloudUploadLabel]: '云端上传', [Phrase.CloudUploadRetailLabel]: '上传零售', [Phrase.CloudUploadClassicLabel]: '上传经典', [Phrase.UploadRateLimitToggleLabel]: '上传速率限制', [Phrase.UploadRateLimitValueLabel]: '上传速率限制 (MB/s)', [Phrase.UploadRaidsLabel]: '上传团队副本', [Phrase.UploadDifficultyThresholdLabel]: '上传难度阈值', [Phrase.UploadMythicPlusLabel]: '上传大秘境', [Phrase.UploadLevelThresholdLabel]: '上传钥石等级阈值', [Phrase.Upload2v2Label]: '上传 2v2', [Phrase.Upload3v3Label]: '上传 3v3', [Phrase.Upload5v5Label]: '上传 5v5', [Phrase.UploadSkirmishLabel]: '上传练习赛', [Phrase.UploadSoloShuffleLabel]: '上传单排', [Phrase.UploadBattlgroundsLabel]: '上传战场', [Phrase.SettingsDisabledText]: '在录制进行时,无法修改这些设置。', [Phrase.SomeSettingsDisabledText]: '此分类中的部分设置在录制进行时会被隐藏,无法修改。', [Phrase.CloudSettingsDisabledText]: '云端设置无法在上传或下载进行时修改。', [Phrase.InvalidRetailLogPathText]: '无效的正式服日志路径', [Phrase.InvalidClassicLogPathText]: '无效的怀旧服日志路径。', [Phrase.InvalidClassicEraLogPathText]: '无效的经典旧世服日志路径。', [Phrase.CannotBeEmpty]: '不能为空', [Phrase.OneOrGreater]: '必须大于或等于 1', [Phrase.SourceHeading]: '来源', [Phrase.VideoHeading]: '视频', [Phrase.AudioHeading]: '音频', [Phrase.OverlayHeading]: '覆盖层', [Phrase.CaptureModeLabel]: '捕获模式', [Phrase.WindowCaptureValue]: '窗口', [Phrase.GameCaptureValue]: '游戏', [Phrase.MonitorCaptureValue]: '显示器', [Phrase.MonitorLabel]: '显示器', [Phrase.CaptureCursorLabel]: '捕获鼠标光标', [Phrase.FPSLabel]: '帧率 (FPS)', [Phrase.CanvasResolutionLabel]: '画布分辨率', [Phrase.QualityLabel]: '质量', [Phrase.VideoEncoderLabel]: '视频编码器', [Phrase.SpeakersLabel]: '扬声器', [Phrase.MicrophonesLabel]: '麦克风', [Phrase.AudioSuppressionLabel]: '音频抑制', [Phrase.MonoInputLabel]: '单声道输入', [Phrase.PushToTalkLabel]: '按键说话', [Phrase.PushToTalkKeyLabel]: '按键说话键', [Phrase.PressAnyKeyCombination]: '按任意键组合...', [Phrase.ClickToBind]: '点击绑定', [Phrase.ClickToRebind]: '点击重新绑定', [Phrase.Mouse]: '鼠标', [Phrase.ChatOverlayLabel]: '聊天覆盖层', [Phrase.OwnImageLabel]: '自定义图像', [Phrase.ImagePathLabel]: '图像路径', [Phrase.WidthLabel]: '宽度', [Phrase.HeightLabel]: '高度', [Phrase.HorizontalLabel]: '水平', [Phrase.VerticalLabel]: '垂直', [Phrase.ScaleLabel]: '缩放', [Phrase.TableHeaderEncounter]: '首领', [Phrase.TableHeaderResult]: '结果', [Phrase.TableHeaderPull]: '尝试次数', [Phrase.TableHeaderDifficulty]: '难度', [Phrase.TableHeaderDuration]: '时长', [Phrase.TableHeaderDate]: '日期', [Phrase.TableHeaderViewpoints]: '视角', [Phrase.TableHeaderMap]: '地图', [Phrase.TableHeaderType]: '类型', [Phrase.TableHeaderTag]: '标签', [Phrase.SearchLabel]: '搜索', [Phrase.SearchSuggestionMythicPlus]: '试试:限时神庙 昨天 +18 牧师 已收藏 强韧', [Phrase.SearchSuggestionRaid]: '试试:击杀 今天 正式服 史诗 毁灭 已收藏', [Phrase.SearchSuggestionBattlegrounds]: '试试:战歌峡谷 已收藏', [Phrase.SearchSuggestionSoloShuffle]: '试试:达拉然 6-0 已收藏', [Phrase.SearchSuggestionDefault]: '试试:胜利 谜团 熔炉 奥术 已收藏', [Phrase.ShowRoundsLabel]: '显示回合', [Phrase.ShowDeathsLabel]: '显示死亡', [Phrase.ShowEncountersLabel]: '显示首领战', [Phrase.FullScreenTooltip]: '全屏', [Phrase.PlaybackSpeedTooltip]: '回放速度', [Phrase.ClipTooltip]: '剪辑', [Phrase.ClipUnavailableTooltip]: '只能剪辑本地保存的视频', [Phrase.ConfirmTooltip]: '确认', [Phrase.CancelTooltip]: '取消', [Phrase.TagButtonTooltip]: '添加标签', [Phrase.StarButtonTooltip]: '标记为永久保留', [Phrase.UnstarButtonTooltip]: '取消永久保留', [Phrase.OpenFolderButtonTooltip]: '打开位置', [Phrase.DeleteButtonTooltip]: '删除', [Phrase.BulkDeleteButtonTooltip]: '删除选中项', [Phrase.ShareLinkButtonTooltip]: '获取可分享的链接', [Phrase.CloudButtonTooltip]: '使用云端版本', [Phrase.DiskButtonTooltip]: '使用本地磁盘版本', [Phrase.UploadButtonTooltip]: '上传至云端', [Phrase.DownloadButtonTooltip]: '下载到本地', [Phrase.StatusTitleRecording]: '正在录制', [Phrase.StatusTitleWaiting]: '等待中', [Phrase.StatusTitleInvalid]: '无效', [Phrase.StatusTitleReady]: '已就绪', [Phrase.StatusTitleFatalError]: '错误', [Phrase.StatusTitleOverrunning]: '额外录制中', [Phrase.StatusTitleReconfiguring]: '重新配置中', [Phrase.StatusDescriptionRecording]: 'Warcraft Recorder 当前正在录制', [Phrase.StatusDescriptionForceEnd]: '你可以强制结束录制。通常无需这样做,但在某些情况下可用于结束无法正常完成的大秘境录制。', [Phrase.StatusDescriptionWaiting]: '正在等待魔兽世界启动', [Phrase.StatusDescriptionConfiguredToRecord]: 'Warcraft Recorder 已配置好录制', [Phrase.StatusDescriptionMisconfigured]: 'Warcraft Recorder 配置错误', [Phrase.StatusDescriptionResolveError]: '请解决以下错误', [Phrase.StatusDescriptionDetectedRunning]: '检测到魔兽世界正在运行', [Phrase.StatusDescriptionWatchingLogs]: 'Warcraft Recorder 正在等待可录制的战斗日志事件。监控的日志路径如下', [Phrase.StatusDescriptionTip]: '提示', [Phrase.StatusDescriptionIfNoRecording]: '如果没有开始录制,请检查游戏内的日志设置,并确认你配置的日志路径正确。', [Phrase.StatusDescriptionFatalError]: 'Warcraft Recorder 遇到致命错误', [Phrase.StatusDescriptionPleaseResolve]: '请尝试解决以下错误,然后重启应用程序。', [Phrase.StatusDescriptionIfRecurring]: '如果此问题一直发生,请在 Discord 中寻求帮助。查看 #help 频道顶部的固定帖子了解如何获取帮助。', [Phrase.StatusDescriptionOverrunning]: 'Warcraft Recorder 检测到活动已完成,正在额外录制几秒钟以捕捉收尾。', [Phrase.StatusDescriptionNothing]: '无。你可能需要在游戏设置中启用一些游戏模式。', [Phrase.StatusHeading]: '状态', [Phrase.StatusButtonForceEndLabel]: '强制停止', [Phrase.Retail]: '正式服', [Phrase.Classic]: '怀旧服', [Phrase.Era]: '旧世服', [Phrase.MicListeningTooltip]: '监听中', [Phrase.MicMutedTooltip]: '已静音', [Phrase.CrashHappenedText]: 'OBS 出现崩溃并已自动恢复。通常不应该发生。你可能需要在 Discord 中分享 WCR 和 OBS 日志以寻求帮助。', [Phrase.SettingsPageApplicationHeader]: '应用程序', [Phrase.SettingsPageGameHeader]: '游戏', [Phrase.SettingsPageProHeader]: '专业版', [Phrase.TestButtonHeading]: '选择一个要测试的分类', [Phrase.SystemTrayOpen]: '打开', [Phrase.SystemTrayQuit]: '退出', [Phrase.Kill]: '击杀', [Phrase.Wipe]: '团灭', [Phrase.Win]: '胜利', [Phrase.Loss]: '失败', [Phrase.Abandoned]: '放弃', [Phrase.Depleted]: '未限时', [Phrase.AreYouSure]: '确定吗?', [Phrase.ThisWillPermanentlyDelete]: '这将永久删除', [Phrase.Recordings]: '个录制文件', [Phrase.From]: '从', [Phrase.Rows]: '行', [Phrase.Death]: '死亡', [Phrase.AddADescription]: '添加说明', [Phrase.TagDialogText]: '此说明可在搜索框中使用。', [Phrase.Clear]: '清除', [Phrase.Save]: '保存', [Phrase.ShareableLinkTitle]: '可分享链接已生成并复制到剪贴板', [Phrase.ShareableLinkText]: '只要视频仍存储在云端,此链接就有效', [Phrase.ShareableLinkFailedTitle]: '生成链接失败', [Phrase.ShareableLinkFailedText]: '详情请查看日志', [Phrase.CloudUsageDescription]: '云端使用情况', [Phrase.DiskUsageDescription]: '磁盘使用情况', [Phrase.Hardware]: '硬件', [Phrase.Software]: '软件', [Phrase.All]: '全部', [Phrase.Own]: '自己的', [Phrase.None]: '无', [Phrase.On]: '开', [Phrase.Off]: '关', [Phrase.Ultra]: '超高', [Phrase.High]: '高', [Phrase.Moderate]: '中', [Phrase.Low]: '低', [Phrase.LFR]: '随机团队', [Phrase.Normal]: '普通', [Phrase.Heroic]: '英雄', [Phrase.Mythic]: '史诗', [Phrase.Pvp]: 'PvP', [Phrase.ErrorAccountEmpty]: '账号名称不能为空。', [Phrase.ErrorPasswordEmpty]: '账号密码不能为空。', [Phrase.ErrorGuildEmpty]: '公会名称不能为空。', [Phrase.ErrorUserNotAuthorizedPlayback]: '该用户无权访问此公会。', [Phrase.ErrorUserNotAuthorizedUpload]: '该用户无权向此公会上传视频。', [Phrase.ErrorStoragePathInvalid]: '存储路径无效。', [Phrase.ErrorBufferPathInvalid]: '缓冲文件存储路径无效。', [Phrase.ErrorStoragePathSameAsBufferPath]: '存储路径与缓冲路径相同。', [Phrase.ErrorCustomOverlayNotAllowed]: '要使用自定义覆盖层,请启用云端存储。', [Phrase.ErrorNoCustomImage]: '未提供自定义覆盖层所需的图像文件。', [Phrase.ErrorCustomImageFileType]: '覆盖层图像必须是 .png 或 .gif 文件。', [Phrase.ErrorCustomImageNotExist]: '指定的文件不存在。', [Phrase.InvalidRetailLogPath]: '无效的正式服日志路径。', [Phrase.InvalidClassicLogPath]: '无效的怀旧服日志路径。', [Phrase.InvalidEraLogPath]: '无效的经典旧世服日志路径。', [Phrase.SelectAnOutputDevice]: '请选择一个输出设备', [Phrase.SelectAnInputDevice]: '请选择一个输入设备', [Phrase.ClickToSelectAll]: '单击以选择全部', [Phrase.ClickToSortAsc]: '点击升序排序', [Phrase.ClickToSortDec]: '点击降序排序', [Phrase.ClickToClearSort]: '单击以清除排序', [Phrase.Start]: '开始', [Phrase.End]: '结尾', [Phrase.Cloud]: "云端", [Phrase.Disk]: "本地", [Phrase.Starred]: "已收藏", [Phrase.NotStarred]: "未收藏", [Phrase.Tagged]: "标签", [Phrase.Today]: "今天", [Phrase.Yesterday]: "昨天", [Phrase.Chests]: "宝箱", [Phrase.Timed]: "定时", [Phrase.Activity]: "活动", [Phrase.Unknown]: "未知", [Phrase.NoneTagged]: "该行不包含标记的录制", [Phrase.MultipleTagged]: "该行包含多个标记的录制", [Phrase.NoneStarred]: "该行不包含收藏的录制", [Phrase.SomeStarred]: '该行至少包含一个收藏的录制', [Phrase.UploadClipsLabel]: '上传剪辑', [Phrase.CloudUploadClipsDescription]: '是否应将剪辑的录制上传到云端', [Phrase.RetailPtrLogPathDescription]: '你魔兽世界正式服安装目录的 Logs 文件夹路径,例如 "D:\\World of Warcraft\\_xptr\\Logs"。', [Phrase.RecordRetailPtrDescription]: '是否记录官方测试服务器。', [Phrase.RetailPtr]: '正式测试服务', [Phrase.RecordRetailPtrLabel]: '录制官方测试服务器', [Phrase.RetailPtrLogPathLabel]: '官方测试服务器日志路径', [Phrase.InvalidRetailPtrLogPathText]: '官方测试服务器日志路径无效', [Phrase.Details]: '细节', [Phrase.PlayerModeLabel]: '玩家模式', [Phrase.MultiPlayerModeHeading]: '多人模式', [Phrase.MultiPlayerModeAdvice1]: '使用左侧的网格一次选择/取消选择最多 4 名玩家', [Phrase.MultiPlayerModeAdvice2]: '要恢复此处通常显示的按钮,请返回单人游戏模式', [Phrase.UpdateAvailableTooltip]: '有可用更新。单击即可安装。', [Phrase.UpdateAvailableTitle]: '可用更新', [Phrase.UpdateAvailableText]: '有一个可用更新并可供安装。', [Phrase.UpdateAvailableInstallButtonText]: '立即安装', [Phrase.UpdateAvailableRemindButtonText]: '稍后提醒我', [Phrase.Saving]: '保存', [Phrase.StartTyping]: '开始打字', [Phrase.ToggleDrawingMode]: '切换绘图模式', [Phrase.StarSelected]: '锁定选定的行', [Phrase.UnstarSelected]: '解锁选定的行', [Phrase.Selection]: '选择', [Phrase.NoCombatants]: '没有可用的战斗数据', [Phrase.DateFilter]: "日期过滤器", [Phrase.DateFilterSeparator]: "到", [Phrase.Last7Days]: "过去 7 天", [Phrase.Last30Days]: "过去 30 天", [Phrase.ThisMonth]: "本月", [Phrase.LastMonth]: "上个月", [Phrase.Cancel]: "取消", [Phrase.Apply]: "应用", [Phrase.January]: "一月", [Phrase.February]: "二月", [Phrase.March]: "三月", [Phrase.April]: "四月", [Phrase.May]: "五月", [Phrase.June]: "六月", [Phrase.July]: "七月", [Phrase.August]: "八月", [Phrase.September]: "九月", [Phrase.October]: "十月", [Phrase.November]: "十一月", [Phrase.December]: "十二月", [Phrase.Sunday]: "星期日", [Phrase.Monday]: "星期一", [Phrase.Tuesday]: "星期二", [Phrase.Wednesday]: "星期三", [Phrase.Thursday]: "星期四", [Phrase.Friday]: "星期五", [Phrase.Saturday]: "星期六", [Phrase.PermissionLabel]: "权限", [Phrase.PermissionDescription]: "公会管理员授予您在当前选定公会内的访问级别。", [Phrase.PermissionReadLabel]: "读", [Phrase.PermissionReadDescription]: "允许播放视频。", [Phrase.PermissionWriteLabel]: "写", [Phrase.PermissionWriteDescription]: "允许上传、标记和锁定视频。", [Phrase.PermissionDeleteLabel]: "删除", [Phrase.PermissionDeleteDescription]: "允许删除和解锁视频。", [Phrase.ButtonDiskOnlyDescription]: "仅将按钮应用于本地视频,忽略任何云视频和公会访问权限", [Phrase.StorageFilterLabel]: "存储过滤器", [Phrase.ShowDiskOnlyTooltip]: "仅显示磁盘视频", [Phrase.ShowCloudOnlyTooltip]: "仅显示云视频", [Phrase.ShowBothTooltip]: "将磁盘和云视频分组并显示所有内容", [Phrase.GuildNoPermission]: "公会权限不足,无法执行此操作。", [Phrase.RemoveTagFromList]: "从列表中删除 %value%", [Phrase.DownloadUploadDisabledDueToFilter]: "由于当前选择的存储过滤器而被禁用。", [Phrase.ProcessesLabel]: "应用程序", [Phrase.SelectProcess]: "选择一个应用程序", [Phrase.AudioProcessDevicesDescription]: "除了任何选定的扬声器和麦克风之外,还可以从中捕获音频的应用程序。", [Phrase.ProcessVolumeDescription]: "录音中应用程序的音量,从0到1。", [Phrase.HideEmptyCategoriesLabel]: "隐藏空类别", [Phrase.HideEmptyCategoriesDescription]: "隐藏侧面菜单中没有视频的类别。", [Phrase.HardwareAccelerationLabel]: "硬件加速", [Phrase.HardwareAccelerationDescription]: "启用应用程序的硬件加速渲染。建议大多数用户(尤其是使用 AV1 编码的用户)启用此功能,但在某些系统上可能会导致问题。", [Phrase.RecordCurrentRaidsOnlyLabel]: "仅记录当前突袭", [Phrase.RecordCurrentRaidsOnlyDescription]: "仅记录当前等级的突袭遭遇,这仅适用于零售突袭遭遇。", [Phrase.UploadCurrentRaidsOnlyLabel]: "仅上传当前突袭", [Phrase.UploadCurrentRaidsOnlyDescription]: "仅上传当前等级的突袭遭遇战,这仅适用于零售突袭遭遇战。", [Phrase.MustNotBeEmpty]: "不能为空", [Phrase.PushToTalkReleaseDelayLabel]: "释放延迟", [Phrase.ForceSdrLabel]: "强制 SDR", [Phrase.ForceSdrDescription]: "强制视频以 SDR 而非 HDR 格式渲染。", [Phrase.VideoSourceScaleDescription]: "视频源的缩放比例。", [Phrase.VideoSourceXPositionDescription]: "视频源的 X 位置。", [Phrase.VideoSourceYPositionDescription]: "视频源的 Y 位置。", [Phrase.VideoCategoryManualLabel]: '手动', [Phrase.ManualRecordSettingsLabel]: '手动录制设置', [Phrase.ManualRecordSwitchLabel]: '手动录制', [Phrase.ManualRecordHotKeyLabel]: '开始/停止热键', [Phrase.ManualRecordUploadLabel]: '上传手动录制', [Phrase.ManualRecordDescription]: '启用手动录制,用户可以按需开始和停止录制那些战斗日志无法捕获的内容。', [Phrase.ManualRecordHotKeyDescription]: '设置一个热键来手动开始和停止录制。', [Phrase.ManualRecordUploadDescription]: '自动将手动开始的录制上传到云端。', [Phrase.ManualRecordSoundAlertLabel]: '声音提醒', [Phrase.ManualRecordSoundAlertDescription]: '当手动录制开始或停止时播放声音提醒。', [Phrase.StatusTitleRec]: '录制器', [Phrase.StatusTitlePro]: '专业版', [Phrase.StatusTitleConnected]: '已连接', [Phrase.StatusDescrConnected]: '公会连接已建立。专业版功能已启用。', [Phrase.StatusTitleDisconnected]: '已断开', [Phrase.StatusDescrDisconnected]: '要使用专业版功能,请购买公会订阅或加入现有公会,并在专业版设置页面配置您的登录详细信息。', [Phrase.StatusTitleNotAuthenticated]: '未认证', [Phrase.StatusDescrNotAuthenticated]: '登录失败,请检查您的凭据是否有效。', [Phrase.StatusTitleNoGuild]: '没有公会', [Phrase.StatusDescrNoGuild]: '您的登录成功,但您尚未选择公会。', [Phrase.StatusTitleNotAuthorized]: '无法访问', [Phrase.StatusDescrNotAuthorized]: '您的登录成功,但您无权访问所选公会。', [Phrase.FirstTimeSetupDescription]: '这是用户第一次启动应用程序。', [Phrase.AutoSelectEncoderTooltip]: '自动从可用的编码器中选择一个合理的编码器。对于大多数用户来说,这是一个不错的选择。', [Phrase.CloudRefreshGuildTooltip]: '刷新可用公会的列表。', [Phrase.SourceSnappingSwitchText]: '捕捉', [Phrase.SourceSnappingSwitchTooltip]: '切换源捕捉。', [Phrase.ResetOverlayButtonText]: '重置覆盖层', [Phrase.ResetGameButtonText]: '重置游戏', [Phrase.GameWindowLabel]: '游戏窗口', [Phrase.OverlayLabel]: '聊天窗口', [Phrase.AddSpeakerButtonText]: '扬声器', [Phrase.AddMicrophoneButtonText]: '麦克风', [Phrase.AddApplicationButtonText]: '应用程序', [Phrase.NoAudioSourcesText]: '添加一个源以录制音频。', [Phrase.SelectADevice]: '选择一个设备...', [Phrase.SelectAnApplication]: '选择一个应用程序...', [Phrase.BulkUploadButtonTooltip]: '将所有选中的剪辑从本地磁盘上传到云端。', [Phrase.BulkDownloadButtonTooltip]: '将所有选中的剪辑从云端下载到本地磁盘。', [Phrase.BulkUploadDialogText]: '这将把所有选中的剪辑加入上传队列到云端。这可能需要很长时间才能完成。可以通过应用程序左上角的状态卡监控进度。', [Phrase.BulkDownloadDialogText]: '这将把所有选中的剪辑从云端加入下载队列。可以通过应用程序左上角的状态卡监控进度。', [Phrase.BulkTransferWarningText]: '这可能需要很长时间才能完成。如果您使用按流量计费的连接,可能会产生数据费用。关闭计算机将中断此过程。', [Phrase.UploadButtonText]: '队列上传', [Phrase.DownloadButtonText]: '队列下载', [Phrase.ChatTypeMessageText]: '添加评论……使用 MM:SS 时间戳。', [Phrase.ChatUploadToCloudText]: '上传到云端以启用视频聊天。', [Phrase.ChatNoMessagesText]: '没有可显示的消息。', [Phrase.ChatErrorLoadingText]: '加载聊天消息时出错。', [Phrase.ChatUserText1]: '要使用聊天功能,请确认您同意将用户名显示给可能阅读此聊天的公会成员。', [Phrase.ChatUserText2]: '为避免泄露您的电子邮件地址,您可以在以下网站设置自定义用户名:', [Phrase.ChatUserText3]: ',然后在应用设置中更新您的用户名。', [Phrase.ChatUserText4]: '以此用户名聊天', [Phrase.ChatForClipsComingSoon]: '剪辑聊天功能即将推出。', [Phrase.ClassicPtrLogPathDescription]: '经典 PTR 安装的魔兽世界日志文件夹位置,例如 "D:\\World of Warcraft\\_classic_ptr_\\Logs"。', [Phrase.RecordClassicPtrDescription]: '是否记录经典 PTR。该功能取决于暴雪对 PTR 战斗日志的兼容性,可能会不稳定。', [Phrase.RecordClassicPtrLabel]: '记录经典 PTR', [Phrase.ClassicPtrLogPathLabel]: '经典 PTR 日志路径', [Phrase.InvalidClassicPtrLogPathText]: '无效的经典 PTR 日志路径。', [Phrase.ClassicPtr]: '经典 PTR', [Phrase.InvalidClassicPtrLogPath]: '无效的经典 PTR 日志路径。', [Phrase.PatreonButtonLabel]: '在 Patreon 订阅', [Phrase.SelectDifficulty]: '选择难度', [Phrase.SelectResolution]: '选择分辨率', [Phrase.SelectQuality]: '选择质量', [Phrase.SelectEncoder]: '选择编码器', [Phrase.SelectGuild]: '选择公会', [Phrase.SelectOptions]: '选择选项', [Phrase.SelectLanguage]: '选择语言', [Phrase.ChatDeleteMessageTooltip]: '删除此聊天消息', [Phrase.ValidateLogPathLabel]: '验证日志路径', [Phrase.ValidateLogPathsDescription]: 'Warcraft Recorder 会检查你设置的日志路径是否指向受支持的 WoW 安装。你可以禁用此检查以允许设置不受支持的游戏模式。禁用此功能需自行承担风险。', [Phrase.StartManualRecordingTooltip]: '开始手动录制', [Phrase.StopManualRecordingTooltip]: '停止手动录制', [Phrase.AdvancedCombatLoggingDisabledWarning]: '高级战斗日志未启用。请在魔兽世界中启用:系统 > 网络 > 高级战斗日志。注意:启用后需完全关闭魔兽世界 - 该设置仅在退出时写入 Config.wtf。', [Phrase.RecordedAt]: '录制时间', [Phrase.KillVideoCreatorTooltip]: '创建多视角视频。', [Phrase.KillVideoCreatorTooltipNotEnoughLocal]: '此功能只能用于本地录制。请先使用下载按钮。', [Phrase.KillVideoCreatorTooltipNotEnoughPov]: '使用此功能至少需要 2 个视角。', [Phrase.KillVideoCreatorTitle]: '视频编辑器', [Phrase.KillVideoSingleAudioTrackLabel]: '单一音轨', [Phrase.KillVideoSingleAudioTrackTooltip]: '使用单一音轨。禁用时将随视频切换音轨。', [Phrase.KillVideoAudioTrackLabel]: '音轨', [Phrase.KillVideoAudioTrackTooltip]: '选择要使用的音轨。', [Phrase.KillVideoCreating]: '正在编码多视角视频...', [Phrase.KillVideoDescription]: '将同一场战斗的多个视角合成为一个视频,并自动通过平滑过渡拼接各个片段。需要重新编码,这对 CPU 负载较高,通常需要几分钟。完成后的视频将出现在 Clips 分类中。', [Phrase.KillVideoRemove]: '拖到此处以移除', [Phrase.Reset]: '重置', [Phrase.Render]: '渲染', [Phrase.Preparing]: '准备中', }; export default CHINESE_SIMPLIFIED; ================================================ FILE: src/localisation/english.ts ================================================ import { Translations, Phrase } from './phrases'; /* eslint-disable prettier/prettier */ const ENGLISH: Translations = { [Phrase.NoVideosSaved]: 'You have no videos saved for this category', [Phrase.FirstTimeHere]: 'If it is your first time here, setup instructions can be found at the link below. If you have problems, please use the Discord #help channel to get support.', [Phrase.SetupInstructions]: 'Setup Instructions', [Phrase.ClipsDisplayedHere]: 'Videos you clip will be displayed here.', [Phrase.NoClipsSaved]: 'You have no clips saved', [Phrase.StoragePathDescription]: 'Location to store the recordings. Warcraft Recorder takes ownership of this directory, it should be empty on initial setup and you should not modify the contents in-place.', [Phrase.SeparateBufferPathDescription]: 'Enable storing temporary recordings in a seperate location. This should always be a local location. This feature is intended for people who want their final recordings to be on an NFS drive but not incur the network traffic of constantly recording to it.', [Phrase.BufferStoragePathDescription]: 'Location to store temporary recordings. If left unset this will default to a folder inside the Storage Path.', [Phrase.RetailLogPathDescription]: 'Location of the World of Warcraft logs folder for your retail installation, e.g. "D:\\World of Warcraft\\_retail_\\Logs".', [Phrase.ClassicLogPathDescription]: 'Location of the World of Warcraft logs folder for your classic installation, e.g. "D:\\World of Warcraft\\_classic_\\Logs".', [Phrase.ClassicPtrLogPathDescription]: 'Location of the World of Warcraft logs folder for your classic PTR installation, e.g. "D:\\World of Warcraft\\_classic_ptr_\\Logs". ', [Phrase.EraLogPathDescription]: 'Location of the World of Warcraft logs folder for your classic era installation, e.g. "D:\\World of Warcraft\\_classic_era_\\Logs".', [Phrase.MaxStorageDescription]: 'Maximum allowed storage that the application will consume for video files. The oldest videos will be deleted one by one to remain under the limit. Recording will not stop. Set to 0 to signify unlimited.', [Phrase.MonitorIndexDescription]: 'The monitor to record. Only applicible if monitor capture is selected.', [Phrase.SelectedCategoryDescription]: 'Last selected video category in the UI.', [Phrase.AudioInputDevicesDescription]: 'Audio input devices to be included in the recording.', [Phrase.AudioOutputDevicesDescription]: 'Audio output devices to be included in the recording.', [Phrase.MinEncounterDurationDescription]: 'Encounters shorter than this duration will not be recorded. This setting is aimed at avoiding saving boss resets.', [Phrase.StartUpDescription]: 'Automatically start the application when Windows starts.', [Phrase.StartMinimizedDescription]: 'Open to the Windows system tray.', [Phrase.ObsOutputResolutionDescription]: 'Resolution of videos as saved on disk. Set this to the size of your WoW monitor, or less if you want to scale down.', [Phrase.ObsFPSDescription]: 'The number of frames per second to record the video at. Lower FPS gives smaller video size, but also more choppy playback.', [Phrase.ObsForceMonoDescription]: 'Whether to force the audio of your input device to mono. Enable if your microphone audio is only playing out of one stereo channel.', [Phrase.ObsQualityDescription]: 'Quality to record at. Higher quality works your encoder harder and uses more disk space per video.', [Phrase.ObsCaptureModeDescription]: 'The capture mode OBS should use to record. See the #faq channel in discord for more details.', [Phrase.ObsRecEncoderDescription]: 'The video encoder to use. Hardware encoders are typically preferable, usually giving better performance, but are specific to your graphics card.', [Phrase.RecordRetailDescription]: 'Whether the application should record retail.', [Phrase.RecordClassicDescription]: 'Whether the application should record classic.', [Phrase.RecordClassicPtrDescription]: 'Whether the application should record classic PTR. This feature is provided on a best-effort basis and depends on Blizzard maintaining compatibility with the PTR combat log. As a result, functionality may be unpredictable.', [Phrase.RecordEraDescription]: 'Whether the application should record classic era.', [Phrase.RecordRaidsDescription]: 'Whether the application should record raids.', [Phrase.RecordDungeonsDescription]: 'Whether the application should record Mythic+.', [Phrase.RecordTwoVTwoDescription]: 'Whether the application should record 2v2.', [Phrase.RecordThreeVThreeDescription]: 'Whether the application should record 3v3.', [Phrase.RecordFiveVFiveDescription]: 'Whether the application should record 5v5.', [Phrase.RecordSkirmishDescription]: 'Whether the application should record skirmishes.', [Phrase.RecordSoloShuffleDescription]: 'Whether the application should record solo shuffle.', [Phrase.RecordBattlegroundsDescription]: 'Whether the application should record battlegrounds.', [Phrase.CaptureCursorDescription]: 'Whether the cursor should be included in recordings.', [Phrase.MinKeystoneLevelDescription]: 'The minimum keystone level to record.', [Phrase.ChallengeModeDescription]: 'Whether to record challenge modes.', [Phrase.MinRaidDifficultyDescription]: 'The minimum raid difficulty to record. Only applies to retail.', [Phrase.MinimizeOnQuitDescription]: 'Whether the close button should minimize rather than quit.', [Phrase.MinimizeToTrayDescription]: 'Whether the minimize button should minimize to the system tray or the taskbar.', [Phrase.ChatOverlayEnabledDescription]: 'If a chat overlay should be added to the scene.', [Phrase.ChatOverlayOwnImageDescription]: 'If a custom image should be used as the chat overlay. This feature is only available to Pro users.', [Phrase.ChatOverlayOwnImagePathDescription]: 'The PNG file to use as a chat overlay. This feature is only available to Pro users.', [Phrase.ChatOverlayWidthDescription]: 'Crop the width of the chat overlay.', [Phrase.ChatOverlayHeightDescription]: 'Crop the height of the chat overlay.', [Phrase.ChatOverlayScaleDescription]: 'The scale of the chat overlay.', [Phrase.ChatOverlayXPositionDescription]: 'The x-position of the chat overlay.', [Phrase.ChatOverlayYPositionDescription]: 'The y-position of the chat overlay.', [Phrase.SpeakerVolumeDescription]: 'The volume of your speakers in the recording, from 0 to 1.', [Phrase.MicVolumeDescription]: 'The volume of your mic in the recording, from 0 to 1.', [Phrase.DeathMarkersDescription]: 'Death markers to display on the video timeline.', [Phrase.EncounterMarkersDescription]: 'Encounter markers to display on the video timeline.', [Phrase.RoundMarkersDescription]: 'Round markers to display on the video timeline.', [Phrase.PushToTalkDescription]: 'If the input audio devices should be recorded all the time, or only when a hotkey is held down.', [Phrase.PushToTalkKeyDescription]: 'The push to talk hotkey, represented by the key code.', [Phrase.PushToTalkMouseButtonDescription]: 'The push to talk mouse button.', [Phrase.PushToTalkModifiersDescription]: 'A comma seperated list of modifiers required in conjunction with the push to talk hotkey.', [Phrase.PushToTalkReleaseDelayDescription]: 'How long your microphone stays active after you release the Push To Talk key.', [Phrase.ObsAudioSuppressionDescription]: 'Suppress background noise picked up by your microphone, this can help reduce keyboard clacking, breathing, etc.', [Phrase.RaidOverrunDescription]: 'Number of seconds to record after a boss has been killed.', [Phrase.DungeonOverrunDescription]: 'Number of seconds to record after a dungeon has been completed.', [Phrase.CloudStorageDescription]: 'Enable the ability to play videos from the cloud.', [Phrase.CloudUploadDescription]: 'Upload your videos to the cloud, this enables both automatic upload on completion of a recording, as well as the ability to manually upload existing videos.', [Phrase.CloudUploadRetailDescription]: 'If Retail recordings should be uploaded to the cloud.', [Phrase.CloudUploadClassicDescription]: 'If Classic recordings should be uploaded to the cloud.', [Phrase.CloudUploadRateLimitDescription]: 'If upload to the cloud should be rate limited. Useful if you are finding uploading is causing you to lag.', [Phrase.CloudUploadRateLimitMbpsDescription]: 'The upload rate limit in MB/s ', [Phrase.CloudAccountNameDescription]: 'Your Warcraft Recorder account username. This will be shown as your display name when using the chat feature.', [Phrase.CloudAccountPasswordDescription]: 'Your Warcraft Recorder account password.', [Phrase.CloudGuildNameDescription]: 'The guild or group your account is affiliated with.', [Phrase.CloudUpload2v2Description]: 'If 2v2 recordings should be uploaded to the cloud.', [Phrase.CloudUpload3v3Description]: 'If 3v3 recordings should be uploaded to the cloud.', [Phrase.CloudUpload5v5Description]: 'If 5v5 recordings should be uploaded to the cloud.', [Phrase.CloudUploadSkirmishDescription]: 'If skirmish recordings should be uploaded to the cloud.', [Phrase.CloudUploadSoloShuffleDescription]: 'If solo shuffle recordings should be uploaded to the cloud.', [Phrase.CloudUploadDungeonsDescription]: 'If mythic+ recordings should be uploaded to the cloud.', [Phrase.CloudUploadRaidsDescription]: 'If raid encounter recordings should be uploaded to the cloud.', [Phrase.CloudUploadBattlegroundsDescription]: 'If battleground recordings should be uploaded to the cloud.', [Phrase.CloudUploadRaidMinDifficultyDescription]: 'The minimum raid encounter difficulty for automatic cloud uploading.', [Phrase.CloudUploadDungeonMinLevelDescription]: 'The minimum keystone level for automatic cloud uploading.', [Phrase.LanguageDescription]: 'The language to use in the application.', [Phrase.RecordingsHeading]: 'Recordings', [Phrase.SettingsHeading]: 'Settings', [Phrase.GeneralButtonText]: 'General', [Phrase.SceneButtonText]: 'Scene', [Phrase.Version]: 'Version', [Phrase.VideoCategoryTwoVTwoLabel]: '2v2', [Phrase.VideoCategoryThreeVThreeLabel]: '3v3', [Phrase.VideoCategoryFiveVFiveLabel]: '5v5', [Phrase.VideoCategorySkirmishLabel]: 'Skirmish', [Phrase.VideoCategorySoloShuffleLabel]: 'Solo Shuffle', [Phrase.VideoCategoryMythicPlusLabel]: 'Mythic+', [Phrase.VideoCategoryRaidsLabel]: 'Raids', [Phrase.VideoCategoryBattlegroundsLabel]: 'Battlegrounds', [Phrase.VideoCategoryClipsLabel]: 'Clips', [Phrase.LogsButtonLabel]: 'Logs', [Phrase.DiscordButtonLabel]: 'Discord', [Phrase.TestButtonUnable]: 'Unable to run a test right now. To run a test, World of Warcraft must be running, your settings must be valid, and you must not currently be in an activity.', [Phrase.GeneralSettingsLabel]: 'General Settings', [Phrase.DiskStorageFolderLabel]: 'Disk Storage Folder', [Phrase.SeparateBufferFolderLabel]: 'Separate Buffer Folder', [Phrase.BufferFolderLabel]: 'Buffer Folder', [Phrase.MaxDiskStorageLabel]: 'Max Disk Storage (GB)', [Phrase.WindowsSettingsLabel]: 'Windows Settings', [Phrase.RunOnStartupLabel]: 'Run on Startup', [Phrase.StartMinimizedLabel]: 'Start Minimized', [Phrase.MinimizeOnQuitLabel]: 'Minimize on Quit', [Phrase.MinimizeToTrayLabel]: 'Minimize To Tray', [Phrase.LocaleSettingsLabel]: 'Locale Settings', [Phrase.LanguageLabel]: 'Language', [Phrase.GameSettingsLabel]: 'Game Settings', [Phrase.RecordRetailLabel]: 'Record Retail', [Phrase.RetailLogPathLabel]: 'Retail Log Path', [Phrase.RecordClassicLabel]: 'Record Classic', [Phrase.ClassicLogPathLabel]: 'Classic Log Path', [Phrase.RecordClassicPtrLabel]: 'Record Classic PTR', [Phrase.ClassicPtrLogPathLabel]: 'Classic PTR Log Path', [Phrase.RecordClassicEraLabel]: 'Record Classic Era', [Phrase.ClassicEraLogPathLabel]: 'Classic Era Log Path', [Phrase.PVESettingsLabel]: 'PvE Settings', [Phrase.RecordRaidsLabel]: 'Record Raids', [Phrase.MinimumEncounterDurationLabel]: 'Minimum Encounter Duration (sec)', [Phrase.RaidOverrunLabel]: 'Raid Overrun (sec)', [Phrase.MinimumRaidDifficultyLabel]: 'Minimum Raid Difficulty', [Phrase.RecordMythicPlusLabel]: 'Record Mythic+', [Phrase.MinimumKeystoneLevelLabel]: 'Minimum Keystone Level', [Phrase.ChallengeModeLabel]: 'Record Challenge Modes', [Phrase.MythicPlusOverrunLabel]: 'Mythic+ Overrun (sec)', [Phrase.PVPSettingsLabel]: 'PvP Settings', [Phrase.Record2v2Label]: 'Record 2v2', [Phrase.Record3v3Label]: 'Record 3v3', [Phrase.Record5v5Label]: 'Record 5v5', [Phrase.RecordSkirmishLabel]: 'Record Skirmish', [Phrase.RecordSoloShuffleLabel]: 'Record Solo Shuffle', [Phrase.RecordBattlegroundsLabel]: 'Record Battlegrounds', [Phrase.CloudSettingsLabel]: 'Cloud Settings', [Phrase.CloudPlaybackLabel]: 'Cloud Playback', [Phrase.UserEmailLabel]: 'User or Email', [Phrase.PasswordLabel]: 'Password', [Phrase.GuildNameLabel]: 'Guild', [Phrase.CloudUploadLabel]: 'Cloud Upload', [Phrase.CloudUploadRetailLabel]: 'Upload Retail', [Phrase.CloudUploadClassicLabel]: 'Upload Classic', [Phrase.UploadRateLimitToggleLabel]: 'Upload Rate Limit', [Phrase.UploadRateLimitValueLabel]: 'Upload Rate Limit (MB/s)', [Phrase.UploadRaidsLabel]: 'Upload Raids', [Phrase.UploadDifficultyThresholdLabel]: 'Upload Difficulty Threshold', [Phrase.UploadMythicPlusLabel]: 'Upload Mythic+', [Phrase.UploadLevelThresholdLabel]: 'Upload Level Threshold', [Phrase.Upload2v2Label]: 'Upload 2v2', [Phrase.Upload3v3Label]: 'Upload 3v3', [Phrase.Upload5v5Label]: 'Upload 5v5', [Phrase.UploadSkirmishLabel]: 'Upload Skirmish', [Phrase.UploadSoloShuffleLabel]: 'Upload Solo Shuffle', [Phrase.UploadBattlgroundsLabel]: 'Upload Battlegrounds', [Phrase.SettingsDisabledText]: 'These settings cannot be modified while a recording is active.', [Phrase.SomeSettingsDisabledText]: 'Some settings in this category are currently hidden as they cannot be modified while a recording is active.', [Phrase.CloudSettingsDisabledText]: 'These settings cannot be modified while uploads or downloads are in progress.', [Phrase.InvalidRetailLogPathText]: 'Invalid retail log path', [Phrase.InvalidClassicLogPathText]: 'Invalid classic log path.', [Phrase.InvalidClassicPtrLogPathText]: 'Invalid classic PTR log path.', [Phrase.InvalidClassicEraLogPathText]: 'Invalid classic era log path.', [Phrase.CannotBeEmpty]: 'Cannot be empty', [Phrase.OneOrGreater]: 'Must be 1 or greater', [Phrase.SourceHeading]: 'Source', [Phrase.VideoHeading]: 'Video', [Phrase.AudioHeading]: 'Audio', [Phrase.OverlayHeading]: 'Overlay', [Phrase.CaptureModeLabel]: 'Capture Mode', [Phrase.WindowCaptureValue]: 'Window', [Phrase.GameCaptureValue]: 'Game', [Phrase.MonitorCaptureValue]: 'Monitor', [Phrase.MonitorLabel]: 'Monitor', [Phrase.CaptureCursorLabel]: 'Capture Cursor', [Phrase.FPSLabel]: 'FPS', [Phrase.CanvasResolutionLabel]: 'Canvas Resolution', [Phrase.QualityLabel]: 'Quality', [Phrase.VideoEncoderLabel]: 'Video Encoder', [Phrase.SpeakersLabel]: 'Speakers', [Phrase.MicrophonesLabel]: 'Microphones', [Phrase.AudioSuppressionLabel]: 'Audio Suppression', [Phrase.MonoInputLabel]: 'Mono Input', [Phrase.PushToTalkLabel]: 'Push To Talk', [Phrase.PushToTalkKeyLabel]: 'Push To Talk Key', [Phrase.PressAnyKeyCombination]: 'Press any key combination...', [Phrase.ClickToBind]: 'Click to bind', [Phrase.ClickToRebind]: 'Click to rebind', [Phrase.Mouse]: 'Mouse', [Phrase.ChatOverlayLabel]: 'Chat Overlay', [Phrase.OwnImageLabel]: 'Own Image', [Phrase.ImagePathLabel]: 'Image Path', [Phrase.WidthLabel]: 'Crop X', [Phrase.HeightLabel]: 'Crop Y', [Phrase.HorizontalLabel]: 'Horizontal', [Phrase.VerticalLabel]: 'Vertical', [Phrase.ScaleLabel]: 'Scale', [Phrase.TableHeaderEncounter]: 'Encounter', [Phrase.TableHeaderResult]: 'Result', [Phrase.TableHeaderPull]: 'Pull', [Phrase.TableHeaderDifficulty]: 'Difficulty', [Phrase.TableHeaderDuration]: 'Duration', [Phrase.TableHeaderDate]: 'Date', [Phrase.TableHeaderViewpoints]: 'Viewpoints', [Phrase.TableHeaderMap]: 'Map', [Phrase.TableHeaderType]: 'Type', [Phrase.TableHeaderTag]: 'Tag', [Phrase.SearchLabel]: 'Search Filter', [Phrase.SearchSuggestionMythicPlus]: 'Try: timed temple yesterday +18 priest bookmarked fortified', [Phrase.SearchSuggestionRaid]: 'Try: kill today retail mythic destruction bookmarked', [Phrase.SearchSuggestionBattlegrounds]: 'Try: warsong gulch bookmarked', [Phrase.SearchSuggestionSoloShuffle]: 'Try: dalaran 6-0 bookmarked', [Phrase.SearchSuggestionDefault]: 'Try: win enigma crucible arcane bookmarked', [Phrase.ShowRoundsLabel]: 'Show Rounds', [Phrase.ShowDeathsLabel]: 'Show Deaths', [Phrase.ShowEncountersLabel]: 'Show Encounters', [Phrase.FullScreenTooltip]: 'Fullscreen', [Phrase.PlaybackSpeedTooltip]: 'Playback Speed', [Phrase.ClipTooltip]: 'Clip', [Phrase.ClipUnavailableTooltip]: 'You can only clip locally saved videos, while in single player mode', [Phrase.ConfirmTooltip]: 'Confirm', [Phrase.CancelTooltip]: 'Cancel', [Phrase.TagButtonTooltip]: 'Add a custom tag.', [Phrase.StarButtonTooltip]: 'Never age out', [Phrase.UnstarButtonTooltip]: 'Age out', [Phrase.OpenFolderButtonTooltip]: 'Open location', [Phrase.DeleteButtonTooltip]: 'Delete', [Phrase.BulkDeleteButtonTooltip]: 'Permanently delete selected rows, including local and cloud stored videos.', [Phrase.ShareLinkButtonTooltip]: 'Get shareable link', [Phrase.CloudButtonTooltip]: 'Use cloud version', [Phrase.DiskButtonTooltip]: 'Use local disk version', [Phrase.UploadButtonTooltip]: 'Upload to cloud', [Phrase.DownloadButtonTooltip]: 'Download to disk', [Phrase.StatusTitleRecording]: 'Recording', [Phrase.StatusTitleWaiting]: 'Waiting', [Phrase.StatusTitleInvalid]: 'Invalid', [Phrase.StatusTitleReady]: 'Ready', [Phrase.StatusTitleFatalError]: 'Error', [Phrase.StatusTitleOverrunning]: 'Overrunning', [Phrase.StatusTitleReconfiguring]: 'Reconfiguring', [Phrase.StatusDescriptionRecording]: 'Warcraft Recorder is currently recording', [Phrase.StatusDescriptionForceEnd]: 'You can force the recording to end. Normally this should not be required. This can help end a failed Mythic+ run that would otherwise need a few minutes to wrap up.', [Phrase.StatusDescriptionWaiting]: 'Waiting for World of Warcraft to start', [Phrase.StatusDescriptionConfiguredToRecord]: 'Warcraft Recorder is configured to record', [Phrase.StatusDescriptionMisconfigured]: 'Warcraft Recorder is misconfigured', [Phrase.StatusDescriptionResolveError]: 'Please resolve the error below', [Phrase.StatusDescriptionDetectedRunning]: 'Detected World of Warcraft is running', [Phrase.StatusDescriptionWatchingLogs]: 'Warcraft Recorder is waiting for a recordable event to appear in the combat log. Watching log paths', [Phrase.StatusDescriptionTip]: 'Tip', [Phrase.StatusDescriptionIfNoRecording]: 'If recordings do not start, check your logging settings in-game and confirm your log path configuration is correct.', [Phrase.StatusDescriptionFatalError]: 'Warcraft Recorder has hit a fatal error', [Phrase.StatusDescriptionPleaseResolve]: 'Please try to resolve the error below, then restart the application.', [Phrase.StatusDescriptionIfRecurring]: 'If this problem is recurring, please ask for help in Discord. See the pins in the #help channel for advice on getting help.', [Phrase.StatusDescriptionOverrunning]: 'Warcraft Recorder has detected an activity has completed successfuly and is recording a few seconds extra to catch the aftermath.', [Phrase.StatusDescriptionNothing]: 'nothing. You likely want to enable some game modes in the game settings tab.', [Phrase.StatusHeading]: 'Status', [Phrase.StatusButtonForceEndLabel]: 'Force Stop', [Phrase.Retail]: 'Retail', [Phrase.Classic]: 'Classic', [Phrase.ClassicPtr]: 'Classic PTR', [Phrase.Era]: 'Era', [Phrase.MicListeningTooltip]: 'Listening', [Phrase.MicMutedTooltip]: 'Muted', [Phrase.CrashHappenedText]: 'An error has occured. This can result in dropped videos. You may wish to seek help by sharing your WCR and OBS logs in discord.', [Phrase.SettingsPageApplicationHeader]: 'Application', [Phrase.SettingsPageGameHeader]: 'Game', [Phrase.SettingsPageProHeader]: 'Pro', [Phrase.TestButtonHeading]: 'Select a category to test', [Phrase.SystemTrayOpen]: 'Open', [Phrase.SystemTrayQuit]: 'Quit', [Phrase.Kill]: 'Kill', [Phrase.Wipe]: 'Wipe', [Phrase.Win]: 'Win', [Phrase.Loss]: 'Loss', [Phrase.Abandoned]: 'Abandoned', [Phrase.Depleted]: 'Depleted', [Phrase.AreYouSure]: 'Are you sure?', [Phrase.ThisWillPermanentlyDelete]: 'This will permanently delete', [Phrase.Recordings]: 'recording(s)', [Phrase.From]: 'from', [Phrase.Rows]: 'row(s)', [Phrase.Death]: 'Death', [Phrase.AddADescription]: 'Add a Description', [Phrase.TagDialogText]: 'This description is queryable in the search bar.', [Phrase.Clear]: 'Clear', [Phrase.Save]: 'Save', [Phrase.ShareableLinkTitle]: 'Link Copied to Clipboard', [Phrase.ShareableLinkText]: 'This link will be valid as long as the video remains stored in the cloud.', [Phrase.ShareableLinkFailedTitle]: 'Failed to Generate Link', [Phrase.ShareableLinkFailedText]: 'Please see logs for more details', [Phrase.CloudUsageDescription]: 'Cloud Usage', [Phrase.DiskUsageDescription]: 'Disk Usage', [Phrase.Hardware]: 'Hardware', [Phrase.Software]: 'Software', [Phrase.All]: 'All', [Phrase.Own]: 'Own', [Phrase.None]: 'None', [Phrase.On]: 'On', [Phrase.Off]: 'Off', [Phrase.Ultra]: 'Ultra', [Phrase.High]: 'High', [Phrase.Moderate]: 'Moderate', [Phrase.Low]: 'Low', [Phrase.LFR]: 'Lfr', [Phrase.Normal]: 'Normal', [Phrase.Heroic]: 'Heroic', [Phrase.Mythic]: 'Mythic', [Phrase.Pvp]: 'PVP', [Phrase.ErrorAccountEmpty]: 'Account name must not be empty.', [Phrase.ErrorPasswordEmpty]: 'Account Password must not be empty.', [Phrase.ErrorGuildEmpty]: 'Guild name must not be empty.', [Phrase.ErrorUserNotAuthorizedPlayback]: 'User is not authorized to access the guild.', [Phrase.ErrorUserNotAuthorizedUpload]: 'User is not authorized to upload to the guild.', [Phrase.ErrorStoragePathInvalid]: 'Storage path is invalid.', [Phrase.ErrorBufferPathInvalid]: 'Buffer Storage Path is invalid.', [Phrase.ErrorStoragePathSameAsBufferPath]: 'Storage Path is the same as Buffer Path.', [Phrase.ErrorCustomOverlayNotAllowed]: 'To use a custom overlay, login to your Pro account.', [Phrase.ErrorNoCustomImage]: 'Overlay image was not provided for custom overlay.', [Phrase.ErrorCustomImageFileType]: 'Overlay image must be a .png or .gif file.', [Phrase.ErrorCustomImageNotExist]: 'Specified file does not exist.', [Phrase.InvalidRetailLogPath]: 'Invalid retail log path.', [Phrase.InvalidClassicLogPath]: 'Invalid classic log path.', [Phrase.InvalidClassicPtrLogPath]: 'Invalid classic PTR log path.', [Phrase.InvalidEraLogPath]: 'Invalid classic era log path.', [Phrase.SelectAnOutputDevice]: 'Select an output device', [Phrase.SelectAnInputDevice]: 'Select an input device', [Phrase.ClickToSelectAll]: 'Click to select all', [Phrase.ClickToSortAsc]: 'Click to sort ascending', [Phrase.ClickToSortDec]: 'Click to sort descending', [Phrase.ClickToClearSort]: 'Click to clear sort', [Phrase.Start]: 'Start', [Phrase.End]: 'End', [Phrase.Cloud]: "Cloud", [Phrase.Disk]: "Disk", [Phrase.Starred]: "Locked", [Phrase.NotStarred]: "Unlocked", [Phrase.Tagged]: "Tagged", [Phrase.Today]: "Today", [Phrase.Yesterday]: "Yesterday", [Phrase.Chests]: "Chests", [Phrase.Timed]: "Timed", [Phrase.Activity]: "Activity", [Phrase.Unknown]: "Unknown", [Phrase.NoneTagged]: "This row contains no tagged recordings.", [Phrase.MultipleTagged]: "This row contains multiple tagged recordings.", [Phrase.NoneStarred]: "This row is not locked in. It may be automatically aged out to make space for new recordings.", [Phrase.SomeStarred]: 'This row is locked in. It will not be automatically aged out.', [Phrase.UploadClipsLabel]: 'Upload Clips', [Phrase.CloudUploadClipsDescription]: 'If clipped recordings should be uploaded to the cloud.', [Phrase.RetailPtrLogPathDescription]: 'Location of the World of Warcraft logs folder for your retail PTR installation, e.g. "C:\\Program Files\\World of Warcraft\\_xptr_\\Logs". ', [Phrase.RecordRetailPtrDescription]: 'Whether the application should record retail PTR. This feature is provided on a best-effort basis and depends on Blizzard maintaining compatibility with the PTR combat log. As a result, functionality may be unpredictable.', [Phrase.RetailPtr]: 'Retail PTR', [Phrase.RecordRetailPtrLabel]: 'Record Retail PTR', [Phrase.RetailPtrLogPathLabel]: 'Retail PTR Log Path', [Phrase.InvalidRetailPtrLogPathText]: 'Invalid retail PTR log path', [Phrase.Details]: 'Details', [Phrase.PlayerModeLabel]: 'Player Mode', [Phrase.MultiPlayerModeHeading]: 'Multiplayer Mode', [Phrase.MultiPlayerModeAdvice1]: 'Select/deselect up to 4 players at once using the grid to the left.', [Phrase.MultiPlayerModeAdvice2]: 'To restore buttons normally displayed here, return to single player mode.', [Phrase.UpdateAvailableTooltip]: 'An update is available. Click to install.', [Phrase.UpdateAvailableTitle]: 'Update Available', [Phrase.UpdateAvailableText]: 'There is an update available and ready to install.', [Phrase.UpdateAvailableInstallButtonText]: 'Install Now', [Phrase.UpdateAvailableRemindButtonText]: 'Remind Me Later', [Phrase.Saving]: 'Saving...', [Phrase.StartTyping]: 'Start typing...', [Phrase.ToggleDrawingMode]: 'Toggle drawing mode', [Phrase.StarSelected]: 'Lock selected rows. Locked rows will not be automatically aged out.', [Phrase.UnstarSelected]: 'Unlock selected rows. Unlocked rows may be automatically aged out to make space for new recordings.', [Phrase.Selection]: 'Selection', [Phrase.NoCombatants]: 'No combatant data available.', [Phrase.DateFilter]: "Date Filter", [Phrase.DateFilterSeparator]: "to", [Phrase.Last7Days]: "Last 7 days", [Phrase.Last30Days]: "Last 30 days", [Phrase.ThisMonth]: "This month", [Phrase.LastMonth]: "Last month", [Phrase.Cancel]: "Cancel", [Phrase.Apply]: "Apply", [Phrase.January]: "January", [Phrase.February]: "February", [Phrase.March]: "March", [Phrase.April]: "April", [Phrase.May]: "May", [Phrase.June]: "June", [Phrase.July]: "July", [Phrase.August]: "August", [Phrase.September]: "September", [Phrase.October]: "October", [Phrase.November]: "November", [Phrase.December]: "December", [Phrase.Sunday]: "Sunday", [Phrase.Monday]: "Monday", [Phrase.Tuesday]: "Tuesday", [Phrase.Wednesday]: "Wednesday", [Phrase.Thursday]: "Thursday", [Phrase.Friday]: "Friday", [Phrase.Saturday]: "Saturday", [Phrase.PermissionLabel]: "Permissions", [Phrase.PermissionDescription]: "The access level you have within the currently selected guild as granted by the guild admin.", [Phrase.PermissionReadLabel]: "Read", [Phrase.PermissionReadDescription]: "Allows playback of videos.", [Phrase.PermissionWriteLabel]: "Write", [Phrase.PermissionWriteDescription]: "Allows uploading, tagging, and locking of videos.", [Phrase.PermissionDeleteLabel]: "Delete", [Phrase.PermissionDeleteDescription]: "Allows deletion and unlocking of videos.", [Phrase.ButtonDiskOnlyDescription]: "Your user has limited access to the configured guild. Enable to apply selection buttons to local videos only, bypassing guild access permissions.", [Phrase.StorageFilterLabel]: "Storage Filter", [Phrase.ShowDiskOnlyTooltip]: "Show disk videos only.", [Phrase.ShowCloudOnlyTooltip]: "Show cloud videos only.", [Phrase.ShowBothTooltip]: "Group disk and cloud videos and show everything.", [Phrase.GuildNoPermission]: "Insufficient guild permissions to perform this action.", [Phrase.RemoveTagFromList]: "Remove %value% from the list", [Phrase.DownloadUploadDisabledDueToFilter]: "Disabled due to currently selected storage filter.", [Phrase.ProcessesLabel]: "Applications", [Phrase.SelectProcess]: "Select an application", [Phrase.AudioProcessDevicesDescription]: "Applications to capture audio from, in addition to any selected speakers and microphones.", [Phrase.ProcessVolumeDescription]: "The volume of the application in the recording, from 0 to 1.", [Phrase.HideEmptyCategoriesLabel]: "Hide Empty Categories", [Phrase.HideEmptyCategoriesDescription]: "Hides categories in the side menu that have no videos in them.", [Phrase.HardwareAccelerationLabel]: "Hardware Rendering", [Phrase.HardwareAccelerationDescription]: "Enable hardware accelerated rendering of the application. This is recommended for users using AV1 encoding, but may cause issues on some systems. Requires a restart of the application to take effect.", [Phrase.RecordCurrentRaidsOnlyLabel]: "Current Tier Only", [Phrase.RecordCurrentRaidsOnlyDescription]: "Only record raid encounters from the current tier, this only applies to retail raid encounters.", [Phrase.UploadCurrentRaidsOnlyLabel]: "Current Tier Only", [Phrase.UploadCurrentRaidsOnlyDescription]: "Only upload raid encounters from the current tier, this only applies to retail raid encounters.", [Phrase.MustNotBeEmpty]: "Must not be empty", [Phrase.PushToTalkReleaseDelayLabel]: "Release Delay", [Phrase.ForceSdrLabel]: "Force SDR", [Phrase.ForceSdrDescription]: "Forces the video to be rendered in SDR instead of HDR.", [Phrase.VideoSourceScaleDescription]: "The scale of the video source.", [Phrase.VideoSourceXPositionDescription]: "The X position of the video source.", [Phrase.VideoSourceYPositionDescription]: "The Y position of the video source.", [Phrase.VideoCategoryManualLabel]: 'Manual', [Phrase.ManualRecordSettingsLabel]: 'Manual Settings', [Phrase.ManualRecordSwitchLabel]: 'Manual Recording', [Phrase.ManualRecordHotKeyLabel]: 'Start/Stop Hotkey', [Phrase.ManualRecordUploadLabel]: 'Upload Manual Recordings', [Phrase.ManualRecordDescription]: 'Enables manual recording, which a user can start and stop on demand to record things not captured by the combat log.', [Phrase.ManualRecordHotKeyDescription]: 'Set a hotkey to manually start and stop recording.', [Phrase.ManualRecordUploadDescription]: 'Automatically upload recordings started manually to the cloud.', [Phrase.ManualRecordSoundAlertLabel]: 'Sound Alert', [Phrase.ManualRecordSoundAlertDescription]: 'Play a sound alert when manual recording starts or stops.', [Phrase.StatusTitleRec]: 'Recorder', [Phrase.StatusTitlePro]: 'Pro', [Phrase.StatusTitleConnected]: 'Connected', [Phrase.StatusDescrConnected]: 'Guild connection established. Pro features are enabled.', [Phrase.StatusTitleDisconnected]: 'Disconnected', [Phrase.StatusDescrDisconnected]: 'To use Pro features, purchase a guild subscription or join an existing guild and configure your login details in the Pro settings page.', [Phrase.StatusTitleNotAuthenticated]: 'Login Failed', [Phrase.StatusDescrNotAuthenticated]: 'Login failed, please check your credentials are valid.', [Phrase.StatusTitleNoGuild]: 'No Guild', [Phrase.StatusDescrNoGuild]: 'Your login succeeded but you have not selected a guild.', [Phrase.StatusTitleNotAuthorized]: 'No Access', [Phrase.StatusDescrNotAuthorized]: 'Your login succeeded but you are not authorized to access the selected guild.', [Phrase.FirstTimeSetupDescription]: 'This is the first time the user has launched the application.', [Phrase.AutoSelectEncoderTooltip]: 'Automatically select a sensible encoder from those available. A good option for most users.', [Phrase.CloudRefreshGuildTooltip]: 'Refresh the list of available guilds.', [Phrase.SourceSnappingSwitchText]: 'Snapping', [Phrase.SourceSnappingSwitchTooltip]: 'Toggle source snapping.', [Phrase.ResetOverlayButtonText]: 'Reset Overlay', [Phrase.ResetGameButtonText]: 'Auto-Fit Game', [Phrase.GameWindowLabel]: 'Game Window', [Phrase.OverlayLabel]: 'Chat Window', [Phrase.AddSpeakerButtonText]: 'Speaker', [Phrase.AddMicrophoneButtonText]: 'Microphone', [Phrase.AddApplicationButtonText]: 'Application', [Phrase.NoAudioSourcesText]: 'Add a source to record audio.', [Phrase.SelectADevice]: 'Select a device...', [Phrase.SelectAnApplication]: 'Select an application...', [Phrase.BulkUploadButtonTooltip]: 'Upload all selected clips from your local disk to the cloud.', [Phrase.BulkDownloadButtonTooltip]: 'Download all selected clips from the cloud to your local disk.', [Phrase.BulkUploadDialogText]: 'This will queue the upload of all selected clips to the cloud. This may take a long time to complete. The progress can be monitored via the status card in the top-left of the application.', [Phrase.BulkDownloadDialogText]: 'This will queue the download of all selected clips from the cloud. The progress can be monitored via the status card in the top-left of the application.', [Phrase.BulkTransferWarningText]: 'This may take a long time to complete. Data charges may apply if you are on a metered connection. Switching off your computer will interrupt the process.', [Phrase.UploadButtonText]: 'Queue Uploads', [Phrase.DownloadButtonText]: 'Queue Downloads', [Phrase.ChatTypeMessageText]: 'Add a comment... use MM:SS for a timestamp.', [Phrase.ChatUploadToCloudText]: 'Upload to the cloud to enable video chat.', [Phrase.ChatNoMessagesText]: 'No messages to display.', [Phrase.ChatErrorLoadingText]: 'Error loading chat messages.', [Phrase.ChatUserText1]: 'To use chat, please confirm you are happy for your username to be exposed to other members of your guild.', [Phrase.ChatUserText2]: 'To avoid exposing your email address, you may set a custom username on ', [Phrase.ChatUserText3]: ', and then update your username in the app settings.', [Phrase.ChatUserText4]: 'Chat as', [Phrase.ChatForClipsComingSoon]: 'Chat for clips is coming soon.', [Phrase.PatreonButtonLabel]: 'Support us on Patreon', [Phrase.SelectDifficulty]: 'Select a difficulty', [Phrase.SelectResolution]: 'Select a resolution', [Phrase.SelectQuality]: 'Select quality', [Phrase.SelectEncoder]: 'Select encoder', [Phrase.SelectGuild]: 'Select a guild', [Phrase.SelectOptions]: 'Select options', [Phrase.SelectLanguage]: 'Select language', [Phrase.ChatDeleteMessageTooltip]: 'Delete this chat message', [Phrase.ValidateLogPathLabel]: 'Validate Log Paths', [Phrase.ValidateLogPathsDescription]: 'Warcraft Recorder checks the log paths you set point to a supported WoW installation. You can disable this checking to allow you to set unsupported game modes. Disable this at your own risk.', [Phrase.StartManualRecordingTooltip]: 'Start a manual recording.', [Phrase.StopManualRecordingTooltip]: 'Stop the current manual recording.', [Phrase.RecordedAt]: 'Recorded at', [Phrase.AdvancedCombatLoggingDisabledWarning]: 'Advanced Combat Logging is not enabled. Enable it in-game and restart WoW.', [Phrase.KillVideoCreatorTooltip]: 'Create a multiview video.', [Phrase.KillVideoCreatorTooltipNotEnoughLocal]: 'This feature can only operate on local recordings. Use the download button first.', [Phrase.KillVideoCreatorTooltipNotEnoughPov]: 'Must have atleast 2 viewpoints available to use this feature.', [Phrase.KillVideoCreatorTitle]: 'Video Editor', [Phrase.KillVideoSingleAudioTrackLabel]: 'Single Audio Track', [Phrase.KillVideoSingleAudioTrackTooltip]: 'Use a single audio track. Leave disabled to switch audio tracks with video.', [Phrase.KillVideoAudioTrackLabel]: 'Audio Track', [Phrase.KillVideoAudioTrackTooltip]: 'Select the audio track to use.', [Phrase.KillVideoCreating]: 'Encoding multiview video...', [Phrase.KillVideoDescription]: 'Combine multiple viewpoints of the same encounter into a single video, automatically splicing clips together with smooth transitions. Requires re-encoding, which is CPU-intensive and may take a few minutes. The finished video will appear in the Clips category.', [Phrase.KillVideoRemove]: 'Drop here to remove', [Phrase.Reset]: 'Reset', [Phrase.Render]: 'Render', [Phrase.Preparing]: 'Preparing', }; export default ENGLISH; ================================================ FILE: src/localisation/german.ts ================================================ import { Translations, Phrase } from './phrases'; /* eslint-disable prettier/prettier */ const GERMAN: Translations = { [Phrase.NoVideosSaved]: 'Du hast keine Videos in dieser Kategorie gespeichert.', [Phrase.FirstTimeHere]: 'Wenn du das erste Mal die Anwendung gestartet hast, können Installationsanweisungen dem folgenden Link entnommen werden. Falls du Probleme haben solltest, nutz bitte den Discord #help Kanal um Unterstützung zu bekommen.', [Phrase.SetupInstructions]: 'Installationsanweisungen', [Phrase.ClipsDisplayedHere]: 'Videos, die du clipst werden hier angezeigt.', [Phrase.NoClipsSaved]: 'Du hast keine gespeicherten Videoclips', [Phrase.StoragePathDescription]: 'Speicherort der Aufnahmen. Warcraft Recorder nimmt Besitz von dem Ordner. Er sollte beim installieren leer sein und du solltest keine Dateien in dem Ordner manuell verändern.', [Phrase.SeparateBufferPathDescription]: 'Anschalten der Option temporär Videos an einem Puffer-Ort zu speichern. Dieser sollte immer lokal sein. Dieses Feature ist für Leute gedacht, die ihr abgeschlossenes Video auf einem NFS speichern wollen, aber nicht ihr Netzwerk konstant beim aufnehmen belasten wollen.', [Phrase.BufferStoragePathDescription]: 'Speicherort für Puffer Aufnahmen. Wenn dieser nicht gesetzt ist, wird der normale Speicherort genutzt.', [Phrase.RetailLogPathDescription]: 'Speicherort deines Logs Ordners von deiner Retail World of Warcraft Installation, e.g. "D:\\World of Warcraft\\_retail_\\Logs".', [Phrase.ClassicLogPathDescription]: 'Speicherort deines Logs Ordners von deiner Classic World of Warcraft Installation, e.g. "D:\\World of Warcraft\\_classic_\\Logs".', [Phrase.EraLogPathDescription]: 'Speicherort deines Logs Ordners von deiner Classic Era World of Warcraft Installation, e.g. "D:\\World of Warcraft\\_classic_era_\\Logs".', [Phrase.MaxStorageDescription]: 'Maximal erlaubte Speichergröße, den die Anwendungen für Videodateien nutzen darf. Die ältesten Videos werden nach und nach gelöscht, um sicherzustellen, dass man unter der gesetzten Speichergröße bleibt. Aufnahmen werden nicht gestoppt. Auf 0 setzen, um unbegrenzten Speicherplatz zu erlauben.', [Phrase.MonitorIndexDescription]: 'Der Monitor wird aufgenommen. Nur anwendbar, wenn Monitor Aufnahme ausgewählt ist.', [Phrase.SelectedCategoryDescription]: 'Zuletzt ausgewählte Video Kategorie in der Benutzeroberfläche.', [Phrase.AudioInputDevicesDescription]: 'Audioeingabegeräte, die in Aufnahme inbegriffen sein sollen.', [Phrase.AudioOutputDevicesDescription]: 'Audioausgabegeräte, die in Aufnahme inbegriffen sein sollen.', [Phrase.MinEncounterDurationDescription]: 'Bosskämpfe die kürzer als die gesetzte Dauer sind werden nicht aufgenommen. Diese Einstellung soll vermeiden, dass Boss Resets aufgenommen werden.', [Phrase.StartUpDescription]: 'Starte die Anwendung automatisch, wenn Windows startet..', [Phrase.StartMinimizedDescription]: 'Öffnet die Anwendung minimiert zur Taskleiste.', [Phrase.ObsOutputResolutionDescription]: 'Auflösung von Aufnahmen, die auf der Festplatte gespeichert werden.Stell dies auf die Auflösung deines Monitors oder auf weniger, wenn du es runterskalieren möchtest.', [Phrase.ObsFPSDescription]: 'Die Anzahl der Bilder pro Sekunde(FPS) mit der das Video aufgenommen werden soll. Niedrigere FPs erzeugen kleinere Videodateien, aber auch zu einer abgehackteren Wiedergabe.', [Phrase.ObsForceMonoDescription]: 'Legt fest, ob das Audioeingabegerät in Mono aufgenommen werden soll. Option einschalten, wenn dein Mikrofon nur über einen Stereokanal ausgegeben wird.', [Phrase.ObsQualityDescription]: 'Qualität in der aufgenommen werden soll. Höhere Qualität belastet den Encoder stärker und verbraucht mehr Speicherplatz pro Video.', [Phrase.ObsCaptureModeDescription]: 'Der Aufnahmemodus der von OBS zur Aufnahme genutzt werden soll. Finde mehr Informationen dazu im Discord #faq Kanal.', [Phrase.ObsRecEncoderDescription]: 'Der Video-Encoder, der genutzt werden soll. Hardware Encoder werden in der Regel bevorzugt und gibt bessere Leistung, aber dies ist spezifisch du deiner Grafikkarte.', [Phrase.RecordRetailDescription]: 'Ob die Anwendung Retail aufnehmen soll.', [Phrase.RecordClassicDescription]: 'Ob die Anwendung Classic aufnehmen soll', [Phrase.RecordEraDescription]: 'Ob die Anwendung Classic Era aufnehmen soll', [Phrase.RecordRaidsDescription]: 'Ob die Anwendung Schlachtzüge aufnehmen soll.', [Phrase.RecordDungeonsDescription]: 'Ob die Anwendung Mythisch+ aufnehmen soll.', [Phrase.RecordTwoVTwoDescription]: 'Ob die Anwendung 2v2 aufnehmen soll.', [Phrase.RecordThreeVThreeDescription]: 'Ob die Anwendung 3v3 aufnehmen soll.', [Phrase.RecordFiveVFiveDescription]: 'Ob die Anwendung 5v5 aufnehmen soll.', [Phrase.RecordSkirmishDescription]: 'Ob die Anwendung Geplänkel aufnehmen soll.', [Phrase.RecordSoloShuffleDescription]: 'Ob die Anwendung Gemischtes Einzel aufnehmen soll', [Phrase.RecordBattlegroundsDescription]: 'Ob die Anwendung Schlachtfelder aufnehmen soll.', [Phrase.CaptureCursorDescription]: 'Ob die Anwendung Mauszeiger aufnehmen soll', [Phrase.MinKeystoneLevelDescription]: 'Die Schlüsselsteinstufe ab dem aufgenommen werden soll..', [Phrase.ChallengeModeDescription]: 'Ob Herausforderungsmodi aufgenommen werden sollen.', [Phrase.MinRaidDifficultyDescription]: 'Die Schwierigkeitsstufe ab dem ein Schlachtzug aufgenommen werden soll - wird nur auf Retail angewendet.', [Phrase.MinimizeOnQuitDescription]: 'Ob die Schließen-Schaltfläche die Anwendung minimieren soll anstatt zu beenden.', [Phrase.MinimizeToTrayDescription]: 'Ob die Minimieren-Schaltfläche die Anwendung zur System Tray oder Taskleiste minimiert werden soll', [Phrase.ChatOverlayEnabledDescription]: 'Ob ein Chat Overlay zur Szene hingefügt werden soll.', [Phrase.ChatOverlayOwnImageDescription]: 'Ob ein benutzerdefiniertes Bild als Chat Overlay genutzt werden soll. Dieses Feature ist nur zugänglich für Pro-Nutzer.', [Phrase.ChatOverlayOwnImagePathDescription]: 'Die PNG Datei, die als Chat Overlay genutzt werden soll. Dieses Feature ist nur zugänglich für Pro-Nutzer.', [Phrase.ChatOverlayWidthDescription]: 'Die Breite des Chat Overlays', [Phrase.ChatOverlayHeightDescription]: 'Die Höhe des Chat Overlays.', [Phrase.ChatOverlayScaleDescription]: 'Die Skalierung des Chat Overlays', [Phrase.ChatOverlayXPositionDescription]: 'Die X-Position von dem Chat Overlay.', [Phrase.ChatOverlayYPositionDescription]: 'Die Y-Position von dem Chat Overlay.', [Phrase.SpeakerVolumeDescription]: 'Die Lautstärke mit der dein Audioausgabegerät aufgenommen werden soll, von 0 bis 1.', [Phrase.MicVolumeDescription]: 'Die Lautstärke mit der ein Audioeingabegerät aufgenommen werden soll, von 0 bis 1.', [Phrase.DeathMarkersDescription]: 'Todes Markierungen in der Video Zeitleiste anzeigen.', [Phrase.EncounterMarkersDescription]: 'Bosskampf Markierungen in der Video Zeitleiste anzeigen.', [Phrase.RoundMarkersDescription]: 'Runden Markierungen in der Video Zeitleiste anzeigen.', [Phrase.PushToTalkDescription]: 'Ob das Audioeingabegerät durchgehend aufgenommen werden soll oder nur, wenn ein Hotkey runter gedrückt wird', [Phrase.PushToTalkKeyDescription]: 'Der Hotkey, dargestellt durch die Tastenbezeichnung/Tastenkombination', [Phrase.PushToTalkMouseButtonDescription]: 'Die Drücken zum sprechen Maustaste.', [Phrase.PushToTalkModifiersDescription]: 'Eine Kommata getrennte Liste von Modifizierungstasten in Verbindung mit deinem Drücken zum sprechen Hotkey.', [Phrase.PushToTalkReleaseDelayDescription]: 'Wie lange Ihr Mikrofon aktiv bleibt, nachdem Sie die Push-to-Talk-Taste losgelassen haben.', [Phrase.ObsAudioSuppressionDescription]: 'Unterdrückung von Hintergrundgeräuschen, die dein Mirkofon aufnimmt. Dies kann dafür sorgen, dass man z.B. den Tastenschlag,Atmung, etc. weniger hört.', [Phrase.RaidOverrunDescription]: 'Anzahl der Sekunden die nach einem Bosskill noch aufgenommen werden soll.', [Phrase.DungeonOverrunDescription]: 'Anzahl der Sekunden die nach einem Instanzabschluss noch aufgenommen werden soll.', [Phrase.CloudStorageDescription]: 'Anschalten der Cloud Wiedergabe.', [Phrase.CloudUploadDescription]: 'Lade deine Videos in die Cloud hoch. Dies schaltet sowohl das automatische hochladen von Aufnahmen als auch die manuelle Option bestehende Videos hochzuladen ein', [Phrase.CloudUploadRetailDescription]: 'Wenn Retail in die Cloud hochgeladen werden sollen.', [Phrase.CloudUploadClassicDescription]: 'Wenn Classic in die Cloud hochgeladen werden sollen.', [Phrase.CloudUploadRateLimitDescription]: 'Ob das Hochladen in die Cloud limitiert werden soll. Sinnvoll, wenn das Hochladen für Lags sorgt', [Phrase.CloudUploadRateLimitMbpsDescription]: 'Das Hochladen-Limit in MB/s ', [Phrase.CloudAccountNameDescription]: 'Dein Warcraft Recorder Account Benutzername.', [Phrase.CloudAccountPasswordDescription]: 'Dein Warcraft Recorder Account Password.', [Phrase.CloudGuildNameDescription]: 'Die Gilde oder Gruppe, die dein Account zugehörig ist.', [Phrase.CloudUpload2v2Description]: 'Ob die 2v2 Aufnahmen in die Cloud hochgeladen werden sollen.', [Phrase.CloudUpload3v3Description]: 'Ob die 3v3 Aufnahmen in die Cloud hochgeladen werden sollen.', [Phrase.CloudUpload5v5Description]: 'Ob die 5v5 Aufnahmen in die Cloud hochgeladen werden sollen.', [Phrase.CloudUploadSkirmishDescription]: 'Ob die Geplänkel Aufnahmen in die Cloud hochgeladen werden sollen.', [Phrase.CloudUploadSoloShuffleDescription]: 'Ob die Gemischtes Einzel Aufnahmen in die Cloud hochgeladen werden sollen.', [Phrase.CloudUploadDungeonsDescription]: 'Ob die Mythisch+ Aufnahmen in die Cloud hochgeladen werden sollen.', [Phrase.CloudUploadRaidsDescription]: 'Ob die Schlachtzug Aufnahmen in die Cloud hochgeladen werden sollen.', [Phrase.CloudUploadBattlegroundsDescription]: 'Ob die Schlachtfelder Aufnahmen in die Cloud hochgeladen werden sollen.', [Phrase.CloudUploadRaidMinDifficultyDescription]: 'Die Schwierigkeitsstufe ab der automatisch in die Cloud hochgeladen werden soll', [Phrase.CloudUploadDungeonMinLevelDescription]: 'Die Schlüsselsteinstufe ab der automatisch in die Cloud hochgeladen werden soll', [Phrase.LanguageDescription]: 'Die Sprache der Anwendung.', [Phrase.RecordingsHeading]: 'Aufnahmen', [Phrase.SettingsHeading]: 'Einstellungen', [Phrase.GeneralButtonText]: 'Allgemein', [Phrase.SceneButtonText]: 'Szene', [Phrase.Version]: 'Version', [Phrase.VideoCategoryTwoVTwoLabel]: '2v2', [Phrase.VideoCategoryThreeVThreeLabel]: '3v3', [Phrase.VideoCategoryFiveVFiveLabel]: '5v5', [Phrase.VideoCategorySkirmishLabel]: 'Geplänkel', [Phrase.VideoCategorySoloShuffleLabel]: 'Gemischtes Einzel', [Phrase.VideoCategoryMythicPlusLabel]: 'Mythisch+', [Phrase.VideoCategoryRaidsLabel]: 'Schlachtzug', [Phrase.VideoCategoryBattlegroundsLabel]: 'Schlachtfelder', [Phrase.VideoCategoryClipsLabel]: 'Clips', [Phrase.LogsButtonLabel]: 'Logs', [Phrase.DiscordButtonLabel]: 'Discord', [Phrase.TestButtonUnable]: 'Es ist nicht möglich einen Test-Durchlauf gerade durchzuführen. Um einen Testdurchlauf durchzuführen, musst dein World of Warcraft gestartet sein, deine Einstellungen müssen gültig sein und du darfst aktuell noch in keiner Aktivität sein.', [Phrase.GeneralSettingsLabel]: 'Allgemeine Einstellungen', [Phrase.DiskStorageFolderLabel]: 'Speicherordner', [Phrase.SeparateBufferFolderLabel]: 'Separater Buffer Ordner', [Phrase.BufferFolderLabel]: 'Buffer Ordner', [Phrase.MaxDiskStorageLabel]: 'Maximale Speichergröße(GB)', [Phrase.WindowsSettingsLabel]: 'Anwendung Einstellungen', [Phrase.RunOnStartupLabel]: 'Autostart', [Phrase.StartMinimizedLabel]: 'Minimiert starten', [Phrase.MinimizeOnQuitLabel]: 'Minimieren beim schließen', [Phrase.MinimizeToTrayLabel]: 'Minimieren zur System Tray', [Phrase.LocaleSettingsLabel]: 'Spracheinstellung', [Phrase.LanguageLabel]: 'Sprache', [Phrase.GameSettingsLabel]: 'Spieleinstellungen', [Phrase.RecordRetailLabel]: 'Retail aufnehmen', [Phrase.RetailLogPathLabel]: 'Retail Log Pfad', [Phrase.RecordClassicLabel]: 'Classic aufnehmen', [Phrase.ClassicLogPathLabel]: 'Classic Log Pfad', [Phrase.RecordClassicEraLabel]: 'Classic Era aufnehmen', [Phrase.ClassicEraLogPathLabel]: 'Classic Era Log Pfad', [Phrase.PVESettingsLabel]: 'PvE Einstellungen', [Phrase.RecordRaidsLabel]: 'Schlachtzüge aufnehmen', [Phrase.MinimumEncounterDurationLabel]: 'Mindeste Bosskampf Dauer (sek)', [Phrase.RaidOverrunLabel]: 'Schlachtzug Überlauf (sek)', [Phrase.MinimumRaidDifficultyLabel]: 'Mindeste Schlachtzug Schwierigkeitsstufe', [Phrase.RecordMythicPlusLabel]: 'Mythisch+ aufnehmen', [Phrase.MinimumKeystoneLevelLabel]: 'Mindeste Schlüsselsteinstufe', [Phrase.ChallengeModeLabel]: 'Herausforderungsmodi aufnehmen', [Phrase.MythicPlusOverrunLabel]: 'Mythisch+ Überlauf (sek)', [Phrase.PVPSettingsLabel]: 'PvP Einstellungen', [Phrase.Record2v2Label]: '2v2 aufnehmen', [Phrase.Record3v3Label]: '3v3 aufnehmen', [Phrase.Record5v5Label]: '5v5 aufnehmen', [Phrase.RecordSkirmishLabel]: 'Geplänkel aufnehmen', [Phrase.RecordSoloShuffleLabel]: 'Gemischtes Einzel aufnehmen', [Phrase.RecordBattlegroundsLabel]: 'Schlachtfelder aufnehmen', [Phrase.CloudSettingsLabel]: 'Cloud Einstellungen', [Phrase.CloudPlaybackLabel]: 'Cloud Wiedergabe', [Phrase.UserEmailLabel]: 'Benutzer oder E-Mail', [Phrase.PasswordLabel]: 'Passwort', [Phrase.GuildNameLabel]: 'Gildenname', [Phrase.CloudUploadLabel]: 'Cloud Upload', [Phrase.CloudUploadRetailLabel]: 'Upload Retail', [Phrase.CloudUploadClassicLabel]: 'Upload Classic', [Phrase.UploadRateLimitToggleLabel]: 'Upload Limitierung', [Phrase.UploadRateLimitValueLabel]: 'Upload Limitierung (MB/s)', [Phrase.UploadRaidsLabel]: 'Schlachtzüge hochladen', [Phrase.UploadDifficultyThresholdLabel]: 'Schwierigkeitsstufen Schwelle zum hochladen', [Phrase.UploadMythicPlusLabel]: 'Mythisch+ hochladen', [Phrase.UploadLevelThresholdLabel]: 'Schlüsselsteinstufen Schwelle zum hochladen', [Phrase.Upload2v2Label]: '2v2 hochladen', [Phrase.Upload3v3Label]: '3v3 hochladen', [Phrase.Upload5v5Label]: '5v5 hochladen', [Phrase.UploadSkirmishLabel]: 'Geplänkel hochladen', [Phrase.UploadSoloShuffleLabel]: 'Gemischtes Einzel hochladen', [Phrase.UploadBattlgroundsLabel]: 'Schlachtfelder hochladen', [Phrase.SettingsDisabledText]: 'Diese Einstellungen können nicht bearbeitet werden während eine Aufnahme läuft.', [Phrase.SomeSettingsDisabledText]: 'Einige Einstellung in der Kategorie sind aktuell versteckt, da diese nicht bearbeitet werden können während eine Aufnahme läuft.', [Phrase.CloudSettingsDisabledText]: 'Cloud-Einstellungen können nicht geändert werden, während Uploads oder Downloads im Gange sind.', [Phrase.InvalidRetailLogPathText]: 'Ungültiger Retail Log Pfad', [Phrase.InvalidClassicLogPathText]: 'Ungültiger Classic Log Pfad.', [Phrase.InvalidClassicEraLogPathText]: 'Ungültiger Classic Era Log Pfad.', [Phrase.CannotBeEmpty]: 'Kann nicht leer sein', [Phrase.OneOrGreater]: 'Muss 1 oder größer sein', [Phrase.SourceHeading]: 'Quelle', [Phrase.VideoHeading]: 'Video', [Phrase.AudioHeading]: 'Audio', [Phrase.OverlayHeading]: 'Overlay', [Phrase.CaptureModeLabel]: 'Aufnahmemodus', [Phrase.WindowCaptureValue]: 'Fenster', [Phrase.GameCaptureValue]: 'Spiel', [Phrase.MonitorCaptureValue]: 'Monitor', [Phrase.MonitorLabel]: 'Monitor', [Phrase.CaptureCursorLabel]: 'Mauszeiger aufnehmen', [Phrase.FPSLabel]: 'FPS', [Phrase.CanvasResolutionLabel]: 'Bildflächen Auflösung', [Phrase.QualityLabel]: 'Qualität', [Phrase.VideoEncoderLabel]: 'Video Encoder', [Phrase.SpeakersLabel]: 'Lautsprecher', [Phrase.MicrophonesLabel]: 'Mikrofon', [Phrase.AudioSuppressionLabel]: 'Audio Unterdrückung', [Phrase.MonoInputLabel]: 'Mono Eingabe', [Phrase.PushToTalkLabel]: 'Drücken zum Reden', [Phrase.PushToTalkKeyLabel]: 'Drücken zum Reden Taste', [Phrase.PressAnyKeyCombination]: 'Drück irgendeine Tastenkombination...', [Phrase.ClickToBind]: 'Drücken zum setzen', [Phrase.ClickToRebind]: 'Drücken zum neu setzen', [Phrase.Mouse]: 'Maus', [Phrase.ChatOverlayLabel]: 'Chat Overlay', [Phrase.OwnImageLabel]: 'Benutzerdefiniertes Bild', [Phrase.ImagePathLabel]: 'Bildpfad', [Phrase.WidthLabel]: 'Breite', [Phrase.HeightLabel]: 'Höhe', [Phrase.HorizontalLabel]: 'Horizontal', [Phrase.VerticalLabel]: 'Vertikal', [Phrase.ScaleLabel]: 'Skalierung', [Phrase.TableHeaderEncounter]: 'Bosskampf', [Phrase.TableHeaderResult]: 'Ergebnis', [Phrase.TableHeaderPull]: 'Pull', [Phrase.TableHeaderDifficulty]: 'Schwierigkeitsstufe', [Phrase.TableHeaderDuration]: 'Dauer', [Phrase.TableHeaderDate]: 'Datum', [Phrase.TableHeaderViewpoints]: 'Sichtweisen', [Phrase.TableHeaderMap]: 'Karte', [Phrase.TableHeaderType]: 'Typ', [Phrase.TableHeaderTag]: 'Tag', [Phrase.SearchLabel]: 'Suche', [Phrase.SearchSuggestionMythicPlus]: 'Probier: timed Tempel gestern +18 Priester Lesezeichen verstärkt', [Phrase.SearchSuggestionRaid]: 'Probier: Sieg heute retail mythisch zerstörung Lesezeichen', [Phrase.SearchSuggestionBattlegrounds]: 'Probier: Kriegshymnenschlucht Lesezeichen', [Phrase.SearchSuggestionSoloShuffle]: 'Probier: Dalaran 6-0 Lesezeichen', [Phrase.SearchSuggestionDefault]: 'Probier: Sieg Enigmatiegel Arkan Lesezeichen', [Phrase.ShowRoundsLabel]: 'Runden anzeigen', [Phrase.ShowDeathsLabel]: 'Tode anzeigen', [Phrase.ShowEncountersLabel]: 'Bosskämpfe anzeigen', [Phrase.FullScreenTooltip]: 'Fullscreen', [Phrase.PlaybackSpeedTooltip]: 'Wiedergabegeschwindigkeit', [Phrase.ClipTooltip]: 'Clip', [Phrase.ClipUnavailableTooltip]: 'Du kannst nur lokal gespeicherte Videos clipen', [Phrase.ConfirmTooltip]: 'Bestätigen', [Phrase.CancelTooltip]: 'Abbrechen', [Phrase.TagButtonTooltip]: 'Einen Tag hinzufügen', [Phrase.StarButtonTooltip]: 'Niemals altern lassen', [Phrase.UnstarButtonTooltip]: 'altert lassen', [Phrase.OpenFolderButtonTooltip]: 'Speicherort öffnen', [Phrase.DeleteButtonTooltip]: 'Löschen', [Phrase.BulkDeleteButtonTooltip]: 'Ausgewählte löschen', [Phrase.ShareLinkButtonTooltip]: 'Teilbaren Link generieren', [Phrase.CloudButtonTooltip]: 'Cloud Version nutzen', [Phrase.DiskButtonTooltip]: 'Lokale version nutzen', [Phrase.UploadButtonTooltip]: 'In die Cloud hochladen', [Phrase.DownloadButtonTooltip]: 'in Speicherort herunerladen', [Phrase.StatusTitleRecording]: 'Aufnahme', [Phrase.StatusTitleWaiting]: 'Warten', [Phrase.StatusTitleInvalid]: 'Ungültig', [Phrase.StatusTitleReady]: 'Bereit', [Phrase.StatusTitleFatalError]: 'Fehler', [Phrase.StatusTitleOverrunning]: 'Überlaufen', [Phrase.StatusTitleReconfiguring]: 'Neukonfigurieren', [Phrase.StatusDescriptionRecording]: 'Warcraft Recorder nimmt aktuell auf', [Phrase.StatusDescriptionForceEnd]: 'Du kannst eine Aufnahme eigenständig beenden. Normalerweise sollte dies nicht nötig sein. Es kann aber helfen einen abgebrochenen Mythisch+ Lauf abzubrechen, der sonst erst nach ein paar Minuten abbrechen würde.', [Phrase.StatusDescriptionWaiting]: 'Warte auf den Start von World of Warcraft', [Phrase.StatusDescriptionConfiguredToRecord]: 'Warcraft Recorder ist darauf eingestellt aufzunehmen', [Phrase.StatusDescriptionMisconfigured]: 'Warcraft Recorder ist ungültig eingestellt', [Phrase.StatusDescriptionResolveError]: 'Bitte lies den untenstehenden Fehler', [Phrase.StatusDescriptionDetectedRunning]: 'Es wurde erkannt, dass World of Warcraft läuft', [Phrase.StatusDescriptionWatchingLogs]: 'Warcraft Recorder wartet auf ein Event, dass im Combat Log steht, zum auslösen einer Aufnahme. Liest Log Pfäde', [Phrase.StatusDescriptionTip]: 'Hinweis', [Phrase.StatusDescriptionIfNoRecording]: 'Wenn die Aufnahme nicht gestartet ist, überprüfe deine Log-Einstellungen im Spiel und gehe sicher, dass dein Log Pfad richtig ist.', [Phrase.StatusDescriptionFatalError]: 'Warcraft Recorder hatte einen kritischen Fehler', [Phrase.StatusDescriptionPleaseResolve]: 'Bitte versuch den untenstehenden Fehler zu beheben und starte Warcraft Recorder neu.', [Phrase.StatusDescriptionIfRecurring]: 'Wenn das Problem mehrfach auftrifft, bitte frag im Discord nach Hilfe. Siehe die angehefteten Nachrichten im #help Kanal, um schnell möglichst Hilfe zu bekommen.', [Phrase.StatusDescriptionOverrunning]: 'Warcraft Recorder hat eine abgeschlossene Aktivität erkannt und nimmt noch für ein paar Sekunden auf, um die Reaktionen einzufangen.', [Phrase.StatusDescriptionNothing]: 'Nichts. Du solltest einen Spiel-Modus in den Allgemeinen Einstellungen -> Spiel auswählen.', [Phrase.StatusHeading]: 'Status', [Phrase.StatusButtonForceEndLabel]: 'Stopp erzwingen', [Phrase.Retail]: 'Retail', [Phrase.Classic]: 'Classic', [Phrase.Era]: 'Era', [Phrase.MicListeningTooltip]: 'Hört zu', [Phrase.MicMutedTooltip]: 'Stummgeschaltet', [Phrase.CrashHappenedText]: 'Ein OBS Absturz ist aufgetreten und wurde sich von erholt. Dies sollte im Normalfall nicht passieren. Vielleicht möchtest du Hilfe suchen, in dem du deine Warcraft Recorder und OBS Logs im Discord teilst.', [Phrase.SettingsPageApplicationHeader]: 'Anwendung', [Phrase.SettingsPageGameHeader]: 'Spiel', [Phrase.SettingsPageProHeader]: 'Pro', [Phrase.TestButtonHeading]: 'Wähle eine Kategorie zum testen.', [Phrase.SystemTrayOpen]: 'Öffnen', [Phrase.SystemTrayQuit]: 'Schließen', [Phrase.Kill]: 'Sieg', [Phrase.Wipe]: 'Niederlage', [Phrase.Win]: 'Sieg', [Phrase.Loss]: 'Niederlage', [Phrase.Abandoned]: 'Abgebrochen', [Phrase.Depleted]: 'Depleted', [Phrase.AreYouSure]: 'Bist du dir sicher?', [Phrase.ThisWillPermanentlyDelete]: 'Die Löschung ist permanent.', [Phrase.Recordings]: 'Aufnahme(n)', [Phrase.From]: 'aus', [Phrase.Rows]: 'Reihe(n)', [Phrase.Death]: 'Tod', [Phrase.AddADescription]: 'Füge eine Beschreibung hinzu', [Phrase.TagDialogText]: 'Diese Beschreibung ist in der Suchleiste durchsuchbar.', [Phrase.Clear]: 'Löschen', [Phrase.Save]: 'Speichern', [Phrase.ShareableLinkTitle]: 'Teilbarer Link wurde generiert und befindet sich in deiner Ablage.', [Phrase.ShareableLinkText]: 'Dieser Link ist gültig, solange das Video in der Cloud gespeichert bleibt.', [Phrase.ShareableLinkFailedTitle]: 'Link Erzeugung ist fehlgeschlagen', [Phrase.ShareableLinkFailedText]: 'Bitte siehe Logs für mehr Details', [Phrase.CloudUsageDescription]: 'Cloud Nutzung', [Phrase.DiskUsageDescription]: 'Speicher Nutzung', [Phrase.Hardware]: 'Hardware', [Phrase.Software]: 'Software', [Phrase.All]: 'Alle', [Phrase.Own]: 'Eigene', [Phrase.None]: 'Keine', [Phrase.On]: 'An', [Phrase.Off]: 'Aus', [Phrase.Ultra]: 'Ultra', [Phrase.High]: 'Hoch', [Phrase.Moderate]: 'Durchschnittlich', [Phrase.Low]: 'Niedrig', [Phrase.LFR]: 'LFR', [Phrase.Normal]: 'Normal', [Phrase.Heroic]: 'Heroisch', [Phrase.Mythic]: 'Mythisch', [Phrase.Pvp]: 'PVP', [Phrase.ErrorAccountEmpty]: 'Account Name darf nicht leer sein.', [Phrase.ErrorPasswordEmpty]: 'Account Passwort darf nicht leer sein.', [Phrase.ErrorGuildEmpty]: 'Gildenname darf nicht leer sein.', [Phrase.ErrorUserNotAuthorizedPlayback]: 'Benutzer ist nicht berechtigt auf diese Gilde zuzugreifen.', [Phrase.ErrorUserNotAuthorizedUpload]: 'Benutzer ist nicht berechtigt auf diese Gilde hochzuladen.', [Phrase.ErrorStoragePathInvalid]: 'Speicherpfad ist ungültig.', [Phrase.ErrorBufferPathInvalid]: 'Buffer Speicherpfad ist ungültig.', [Phrase.ErrorStoragePathSameAsBufferPath]: 'Speicher Pfad ist der gleiche wie der Buffer Pfad.', [Phrase.ErrorCustomOverlayNotAllowed]: 'Um ein benutzerdefiniertes Overlay zu nutzen, aktiviere den Cloud Speicher.', [Phrase.ErrorNoCustomImage]: 'Overlay Bild wurde nicht übergeben von benutzerdefiniertes Overlay.', [Phrase.ErrorCustomImageFileType]: 'Overlay Bild muss eine .png oder .gif Datei sein.', [Phrase.ErrorCustomImageNotExist]: 'Spezifizierte Datei existiert nicht.', [Phrase.InvalidRetailLogPath]: 'Ungültiger Retail Log Pfad.', [Phrase.InvalidClassicLogPath]: 'Ungültiger Classic Log Pfad.', [Phrase.InvalidEraLogPath]: 'Ungültiger Classic Era Log Pfad.', [Phrase.SelectAnOutputDevice]: 'Wähle ein Audioausgabegerät', [Phrase.SelectAnInputDevice]: 'Wähle ein Audioeingabegerät.', [Phrase.ClickToSelectAll]: 'Klicken Sie, um alle auszuwählen', [Phrase.ClickToSortAsc]: 'Klicken Sie, um aufsteigend zu sortieren', [Phrase.ClickToSortDec]: 'Klicken Sie, um absteigend zu sortieren', [Phrase.ClickToClearSort]: 'Klicken Sie, um die Sortierung zu löschen', [Phrase.Start]: 'Start', [Phrase.End]: 'Ende', [Phrase.Cloud]: "Cloud", [Phrase.Disk]: "Festplatte", [Phrase.Starred]: "Markiert", [Phrase.NotStarred]: "Nicht markiert", [Phrase.Tagged]: "Markiert", [Phrase.Today]: "Heute", [Phrase.Yesterday]: "Gestern", [Phrase.Chests]: "Truhen", [Phrase.Timed]: "Zeitgesteuert", [Phrase.Activity]: "Aktivität", [Phrase.Unknown]: "Unbekannt", [Phrase.NoneTagged]: "Diese Zeile enthält keine getaggten Aufnahmen.", [Phrase.MultipleTagged]: "Diese Zeile enthält mehrere getaggte Aufnahmen.", [Phrase.NoneStarred]: "Diese Zeile enthält keine markierten Aufnahmen.", [Phrase.SomeStarred]: 'Diese Zeile enthält mindestens eine markierte Aufnahme.', [Phrase.UploadClipsLabel]: 'Clips hochladen', [Phrase.CloudUploadClipsDescription]: 'Wenn abgeschnittene Aufnahmen in die Cloud hochgeladen werden sollen.', [Phrase.RetailPtrLogPathDescription]: 'Speicherort deines Logs Ordners von deiner Retail PTR World of Warcraft Installation, e.g. "D:\\World of Warcraft\\_xptr_\\Logs".', [Phrase.RecordRetailPtrDescription]: 'Ob die Anwendung Retail aufnehmen soll.', [Phrase.RetailPtr]: 'Retail PTR', [Phrase.RecordRetailPtrLabel]: 'Retail PTR aufnehmen', [Phrase.RetailPtrLogPathLabel]: 'Retail PTR Log Pfad', [Phrase.InvalidRetailPtrLogPathText]: 'Ungültiger Retail PTR Log Pfad', [Phrase.Details]: 'Details', [Phrase.PlayerModeLabel]: 'Spielermodus', [Phrase.MultiPlayerModeHeading]: 'Mehrspielermodus', [Phrase.MultiPlayerModeAdvice1]: 'Mithilfe des Rasters auf der linken Seite können Sie bis zu 4 Spieler gleichzeitig auswählen bzw. die Auswahl aufheben.', [Phrase.MultiPlayerModeAdvice2]: 'Um die hier normalerweise angezeigten Schaltflächen wiederherzustellen, kehren Sie zum Einzelspielermodus zurück.', [Phrase.UpdateAvailableTooltip]: 'Ein Update ist verfügbar. Klicken Sie hier, um es zu installieren.', [Phrase.UpdateAvailableTitle]: 'Update verfügbar', [Phrase.UpdateAvailableText]: 'Es ist ein Update verfügbar und kann installiert werden.', [Phrase.UpdateAvailableInstallButtonText]: 'Jetzt installieren', [Phrase.UpdateAvailableRemindButtonText]: 'Erinnere mich später', [Phrase.Saving]: 'Sparen...', [Phrase.StartTyping]: 'Beginnen Sie mit der Eingabe...', [Phrase.ToggleDrawingMode]: 'Zeichenmodus umschalten', [Phrase.StarSelected]: 'Ausgewählte Zeilen sperren.', [Phrase.UnstarSelected]: 'Ausgewählte Zeilen entsperren.', [Phrase.Selection]: 'Auswahl', [Phrase.NoCombatants]: 'Keine Daten zu den Kämpfern verfügbar.', [Phrase.DateFilter]: "Datumsfilter", [Phrase.DateFilterSeparator]: "bis", [Phrase.Last7Days]: "Letzte 7 Tage", [Phrase.Last30Days]: "Letzte 30 Tage", [Phrase.ThisMonth]: "Diesen Monat", [Phrase.LastMonth]: "Letzten Monat", [Phrase.Cancel]: "Abbrechen", [Phrase.Apply]: "Anwenden", [Phrase.January]: "Januar", [Phrase.February]: "Februar", [Phrase.March]: "März", [Phrase.April]: "April", [Phrase.May]: "Mai", [Phrase.June]: "Juni", [Phrase.July]: "Juli", [Phrase.August]: "August", [Phrase.September]: "September", [Phrase.October]: "Oktober", [Phrase.November]: "November", [Phrase.December]: "Dezember", [Phrase.Sunday]: "Sonntag", [Phrase.Monday]: "Montag", [Phrase.Tuesday]: "Dienstag", [Phrase.Wednesday]: "Mittwoch", [Phrase.Thursday]: "Donnerstag", [Phrase.Friday]: "Freitag", [Phrase.Saturday]: "Samstag", [Phrase.PermissionLabel]: "Berechtigungen", [Phrase.PermissionDescription]: "Die Zugriffsebene, die Ihnen innerhalb der aktuell ausgewählten Gilde vom Gildenadministrator gewährt wurde.", [Phrase.PermissionReadLabel]: "Lesen", [Phrase.PermissionReadDescription]: "Ermöglicht die Wiedergabe von Videos.", [Phrase.PermissionWriteLabel]: "Schreiben", [Phrase.PermissionWriteDescription]: "Ermöglicht das Hochladen, Markieren und Sperren von Videos.", [Phrase.PermissionDeleteLabel]: "Löschen", [Phrase.PermissionDeleteDescription]: "Ermöglicht das Löschen und Entsperren von Videos.", [Phrase.ButtonDiskOnlyDescription]: "Aktivieren Sie diese Option, um Schaltflächen nur auf lokale Videos anzuwenden und alle Cloud-Videos und relevanten Gildenzugriffsberechtigungen zu ignorieren.", [Phrase.StorageFilterLabel]: "Speicherfilter", [Phrase.ShowDiskOnlyTooltip]: "Nur Disk-Videos anzeigen", [Phrase.ShowCloudOnlyTooltip]: "Nur Cloud-Videos anzeigen", [Phrase.ShowBothTooltip]: "Gruppieren Sie Disk- und Cloud-Videos und zeigen Sie alles", [Phrase.GuildNoPermission]: "Nicht genügend Gildenberechtigungen, um diese Aktion auszuführen.", [Phrase.RemoveTagFromList]: "%value% aus der Liste entfernen", [Phrase.DownloadUploadDisabledDueToFilter]: "Aufgrund des aktuell ausgewählten Speicherfilters deaktiviert.", [Phrase.ProcessesLabel]: "Anwendungen", [Phrase.SelectProcess]: "Wählen Sie eine Anwendung aus", [Phrase.AudioProcessDevicesDescription]: "Anwendungen zum Aufnehmen von Audiodaten, zusätzlich zu allen ausgewählten Lautsprechern und Mikrofonen.", [Phrase.ProcessVolumeDescription]: "Die Lautstärke der Anwendung in der Aufnahme, von 0 bis 1.", [Phrase.HideEmptyCategoriesLabel]: "Leere Kategorien ausblenden", [Phrase.HideEmptyCategoriesDescription]: "Blendet Kategorien im Seitenmenü aus, die keine Videos enthalten.", [Phrase.HardwareAccelerationLabel]: "Hardwarebeschleunigung", [Phrase.HardwareAccelerationDescription]: "Aktivieren Sie die hardwarebeschleunigte Darstellung der Anwendung. Dies wird den meisten Benutzern empfohlen, insbesondere denen mit AV1-Kodierung, kann aber auf manchen Systemen zu Problemen führen.", [Phrase.RecordCurrentRaidsOnlyLabel]: "Nur aktuelle Raids aufzeichnen", [Phrase.RecordCurrentRaidsOnlyDescription]: "Zeichne nur Raid-Begegnungen der aktuellen Stufe auf, dies gilt nur für Retail-Raid-Begegnungen.", [Phrase.UploadCurrentRaidsOnlyLabel]: "Nur aktuelle Raids hochladen", [Phrase.UploadCurrentRaidsOnlyDescription]: "Laden Sie nur Raid-Begegnungen der aktuellen Stufe hoch. Dies gilt nur für Einzelhandels-Raid-Begegnungen.", [Phrase.MustNotBeEmpty]: "Darf nicht leer sein", [Phrase.PushToTalkReleaseDelayLabel]: "Verzögerung bei der Veröffentlichung", [Phrase.ForceSdrLabel]: "SDR erzwingen", [Phrase.ForceSdrDescription]: "Erzwingt, dass das Video in SDR anstatt HDR gerendert wird.", [Phrase.VideoSourceScaleDescription]: "Die Skalierung der Videoquelle.", [Phrase.VideoSourceXPositionDescription]: "Die X-Position der Videoquelle.", [Phrase.VideoSourceYPositionDescription]: "Die Y-Position der Videoquelle.", [Phrase.VideoCategoryManualLabel]: 'Manuell', [Phrase.ManualRecordSettingsLabel]: 'Manuelle Einstellungen', [Phrase.ManualRecordSwitchLabel]: 'Manuelle Aufnahme', [Phrase.ManualRecordHotKeyLabel]: 'Start/Stopp Hotkey', [Phrase.ManualRecordUploadLabel]: 'Manuelle Aufnahmen hochladen', [Phrase.ManualRecordDescription]: 'Aktiviert manuelle Aufnahme, die ein Benutzer nach Bedarf starten und stoppen kann, um Dinge aufzunehmen, die nicht vom Kampflog erfasst werden.', [Phrase.ManualRecordHotKeyDescription]: 'Setze einen Hotkey, um die Aufnahme manuell zu starten und zu stoppen.', [Phrase.ManualRecordUploadDescription]: 'Automatisches Hochladen von manuell gestarteten Aufnahmen in die Cloud.', [Phrase.ManualRecordSoundAlertLabel]: 'Ton-Alarm', [Phrase.ManualRecordSoundAlertDescription]: 'Spiele einen Ton-Alarm ab, wenn die manuelle Aufnahme startet oder stoppt.', [Phrase.StatusTitleRec]: 'Recorder', [Phrase.StatusTitlePro]: 'Pro', [Phrase.StatusTitleConnected]: 'Verbunden', [Phrase.StatusDescrConnected]: 'Gildenverbindung hergestellt. Pro-Features sind aktiviert.', [Phrase.StatusTitleDisconnected]: 'Getrennt', [Phrase.StatusDescrDisconnected]: 'Um Pro-Features zu nutzen, kaufe ein Gilden-Abonnement oder tritt einer bestehenden Gilde bei und konfiguriere deine Anmeldedaten auf der Pro-Einstellungsseite.', [Phrase.StatusTitleNotAuthenticated]: 'Nicht authentifiziert', [Phrase.StatusDescrNotAuthenticated]: 'Anmeldung fehlgeschlagen, bitte überprüfe, ob deine Zugangsdaten gültig sind.', [Phrase.StatusTitleNoGuild]: 'Keine Gilde', [Phrase.StatusDescrNoGuild]: 'Ihre Anmeldung war erfolgreich, Sie haben jedoch keine Gilde ausgewählt.', [Phrase.StatusTitleNotAuthorized]: 'Kein Zugriff', [Phrase.StatusDescrNotAuthorized]: 'Deine Anmeldung war erfolgreich, aber du bist nicht berechtigt, auf die ausgewählte Gilde zuzugreifen.', [Phrase.FirstTimeSetupDescription]: 'Dies ist das erste Mal, dass der Benutzer die Anwendung gestartet hat.', [Phrase.AutoSelectEncoderTooltip]: 'Wählen Sie automatisch einen sinnvollen Encoder aus den verfügbaren aus. Eine gute Option für die meisten Benutzer.', [Phrase.CloudRefreshGuildTooltip]: 'Aktualisieren Sie die Liste der verfügbaren Gilden.', [Phrase.SourceSnappingSwitchText]: 'Schnappen', [Phrase.SourceSnappingSwitchTooltip]: 'Quellen-Snapping umschalten', [Phrase.ResetOverlayButtonText]: 'Overlay zurücksetzen', [Phrase.ResetGameButtonText]: 'Spiel zurücksetzen', [Phrase.GameWindowLabel]: 'Spielfenster', [Phrase.OverlayLabel]: 'Chat-Fenster', [Phrase.AddSpeakerButtonText]: 'Lautsprecher', [Phrase.AddMicrophoneButtonText]: 'Mikrofon', [Phrase.AddApplicationButtonText]: 'Anwendung', [Phrase.NoAudioSourcesText]: 'Fügen Sie eine Quelle hinzu, um Audio aufzunehmen.', [Phrase.SelectADevice]: 'Wählen Sie ein Gerät aus...', [Phrase.SelectAnApplication]: 'Wählen Sie eine Anwendung aus...', [Phrase.BulkUploadButtonTooltip]: 'Laden Sie alle ausgewählten Clips von Ihrer lokalen Festplatte in die Cloud hoch.', [Phrase.BulkDownloadButtonTooltip]: 'Laden Sie alle ausgewählten Clips aus der Cloud auf Ihre lokale Festplatte herunter.', [Phrase.BulkUploadDialogText]: 'Dies wird die Warteschlange für den Upload aller ausgewählten Clips in die Cloud erstellen. Dies kann eine Weile dauern. Der Fortschritt kann über die Statuskarte in der oberen linken Ecke der Anwendung überwacht werden.', [Phrase.BulkDownloadDialogText]: 'Dies wird die Warteschlange für den Download aller ausgewählten Clips aus der Cloud erstellen. Der Fortschritt kann über die Statuskarte in der oberen linken Ecke der Anwendung überwacht werden.', [Phrase.BulkTransferWarningText]: 'Dies kann eine Weile dauern. Es können Datengebühren anfallen, wenn Sie sich in einem gemessenen Netzwerk befinden. Das Ausschalten Ihres Computers unterbricht den Vorgang.', [Phrase.UploadButtonText]: 'Uploads in die Warteschlange stellen', [Phrase.DownloadButtonText]: 'Downloads in die Warteschlange stellen', [Phrase.ChatTypeMessageText]: 'Kommentar hinzufügen... Verwende MM:SS für einen Zeitstempel.', [Phrase.ChatUploadToCloudText]: 'In die Cloud hochladen, um Videochat zu aktivieren.', [Phrase.ChatNoMessagesText]: 'Keine Nachrichten zum Anzeigen.', [Phrase.ChatErrorLoadingText]: 'Fehler beim Laden der Chatnachrichten.', [Phrase.ChatUserText1]: 'Um den Chat zu verwenden, bestätigen Sie bitte, dass Ihr Benutzername für Mitglieder Ihrer Gilde sichtbar sein darf, die diesen Chat lesen können.', [Phrase.ChatUserText2]: 'Um Ihre E-Mail-Adresse zu schützen, können Sie auf folgender Seite einen eigenen Benutzernamen festlegen: ', [Phrase.ChatUserText3]: ', und anschließend Ihren Benutzernamen in den App-Einstellungen aktualisieren.', [Phrase.ChatUserText4]: 'Chatten als', [Phrase.ChatForClipsComingSoon]: 'Chat für Clips kommt bald.', [Phrase.ClassicPtrLogPathDescription]: 'Speicherort des World-of-Warcraft-Protokollordners für die Classic-PTR-Installation, z. B. "D:\\World of Warcraft\\_classic_ptr_\\Logs".', [Phrase.RecordClassicPtrDescription]: 'Gibt an, ob die Anwendung Classic PTR aufzeichnen soll. Die Funktion ist nur eingeschränkt zuverlässig und hängt von der fortgesetzten PTR-Kampfprotokoll-Kompatibilität ab.', [Phrase.RecordClassicPtrLabel]: 'Classic PTR aufzeichnen', [Phrase.ClassicPtrLogPathLabel]: 'Classic PTR-Protokollpfad', [Phrase.InvalidClassicPtrLogPathText]: 'Ungültiger Classic-PTR-Protokollpfad.', [Phrase.ClassicPtr]: 'Classic PTR', [Phrase.InvalidClassicPtrLogPath]: 'Ungültiger Classic-PTR-Protokollpfad.', [Phrase.PatreonButtonLabel]: 'Auf Patreon abonnieren', [Phrase.SelectDifficulty]: 'Wählen Sie einen Schwierigkeitsgrad', [Phrase.SelectResolution]: 'Wählen Sie eine Auflösung', [Phrase.SelectQuality]: 'Qualität wählen', [Phrase.SelectEncoder]: 'Encoder wählen', [Phrase.SelectGuild]: 'Wählen Sie eine Gilde', [Phrase.SelectOptions]: 'Optionen wählen', [Phrase.SelectLanguage]: 'Sprache wählen', [Phrase.ChatDeleteMessageTooltip]: 'Diese Chat-Nachricht löschen', [Phrase.ValidateLogPathLabel]: 'Logpfade überprüfen', [Phrase.ValidateLogPathsDescription]: 'Warcraft Recorder überprüft, ob die von Ihnen festgelegten Logpfade auf eine unterstützte WoW-Installation verweisen. Sie können diese Überprüfung deaktivieren, um nicht unterstützte Spielmodi festzulegen. Deaktivieren Sie dies auf eigenes Risiko.', [Phrase.StartManualRecordingTooltip]: 'Starte eine manuelle Aufnahme', [Phrase.StopManualRecordingTooltip]: 'Beende die manuelle Aufnahme', [Phrase.RecordedAt]: 'Aufgenommen am', [Phrase.AdvancedCombatLoggingDisabledWarning]: 'Erweitertes Kampflog ist nicht aktiviert. Aktiviere es in WoW: System > Netzwerk > Erweitertes Kampflog. Hinweis: Schließe WoW nach dem Aktivieren vollständig - die Einstellung wird erst beim Beenden in die Config.wtf geschrieben.', [Phrase.KillVideoCreatorTooltip]: 'Ein Multiview-Video erstellen.', [Phrase.KillVideoCreatorTooltipNotEnoughLocal]: 'Diese Funktion kann nur mit lokalen Aufnahmen verwendet werden. Verwenden Sie zuerst die Download-Schaltfläche.', [Phrase.KillVideoCreatorTooltipNotEnoughPov]: 'Mindestens 2 Perspektiven müssen verfügbar sein, um diese Funktion zu nutzen.', [Phrase.KillVideoCreatorTitle]: 'Video-Editor', [Phrase.KillVideoSingleAudioTrackLabel]: 'Einzelne Audiospur', [Phrase.KillVideoSingleAudioTrackTooltip]: 'Eine einzelne Audiospur verwenden. Deaktiviert lassen, um die Audiospur mit dem Video zu wechseln.', [Phrase.KillVideoAudioTrackLabel]: 'Audiospur', [Phrase.KillVideoAudioTrackTooltip]: 'Die zu verwendende Audiospur auswählen.', [Phrase.KillVideoCreating]: 'Multiview-Video wird kodiert...', [Phrase.KillVideoDescription]: 'Kombiniert mehrere Perspektiven derselben Begegnung zu einem einzigen Video und fügt die Clips automatisch mit fließenden Übergängen zusammen. Erfordert eine Neu-Codierung, die CPU-intensiv ist und einige Minuten dauern kann. Das fertige Video erscheint in der Kategorie „Clips“.', [Phrase.KillVideoRemove]: 'Hier ablegen, um zu entfernen', [Phrase.Reset]: 'Zurücksetzen', [Phrase.Render]: 'Rendern', [Phrase.Preparing]: 'Vorbereitung', }; export default GERMAN; ================================================ FILE: src/localisation/korean.ts ================================================ import { Translations, Phrase } from './phrases'; /* eslint-disable prettier/prettier */ const KOREAN: Translations = { [Phrase.NoVideosSaved]: '이 카테고리에 저장된 동영상이 없습니다.', [Phrase.FirstTimeHere]: '처음 사용하는 경우 아래 링크에서 설정 방법을 확인할 수 있습니다.', [Phrase.SetupInstructions]: 'Setup', [Phrase.ClipsDisplayedHere]: '클립한 동영상은 여기에 표시됩니다.', [Phrase.NoClipsSaved]: '저장된 클립이 없습니다.', [Phrase.StoragePathDescription]: '기록을 저장할 위치. 프로그램이 폴더의 소유권을 가지며, 초기 설정 시 비어 있어야 하며 그 자리에서 내용을 수정해서는 안 됩니다.(영문 폴더명 권장)', [Phrase.SeparateBufferPathDescription]: '임시 기록을 별도의 위치에 저장할 수 있도록 설정합니다.', [Phrase.BufferStoragePathDescription]: '임시 기록을 저장할 위치. 설정하지 않은 채로 두면 기본 저장 경로에서 관리됩니다.', [Phrase.RetailLogPathDescription]: '본섭 로그 폴더 위치 (ex: "World of Warcraft\\_retail_\\Logs")', [Phrase.ClassicLogPathDescription]: '클래식 로그 폴더 위치 (ex: "World of Warcraft\\_classic_\\Logs")', [Phrase.EraLogPathDescription]: '클래식 시대 로그 폴더 위치 (ex: "World of Warcraft\\_classic_era_\\Logs")', [Phrase.MaxStorageDescription]: '저장소 최대 허용 용량. 무제한은 "0"으로 설정. 범위 내로 유지되도록 가장 오래된 기록물은 하나씩 삭제됩니다.', [Phrase.MonitorIndexDescription]: '모니터가 여러 대일 경우 개별 항목이 생성되어 기록을 원하는 모니터를 선택할 수 있습니다.', [Phrase.SelectedCategoryDescription]: '마지막으로 선택한 동영상 카테고리', [Phrase.AudioInputDevicesDescription]: '기록에 포함할 오디오 입력 장치', [Phrase.AudioOutputDevicesDescription]: '기록에 포함할 오디오 출력 장치', [Phrase.MinEncounterDurationDescription]: '이 시간보다 짧은 전투는 기록되지 않습니다. 이 설정은 보스 리셋이 저장되지 않도록 하는데 목적이 있습니다.', [Phrase.StartUpDescription]: '윈도우가 시작될 때 자동으로 프로그램 시작', [Phrase.StartMinimizedDescription]: '윈도우 시스템 트레이로 실행', [Phrase.ObsOutputResolutionDescription]: '기록물의 해상도. 게임 해상도 크기로 설정 또는 그 이하', [Phrase.ObsFPSDescription]: '기록물의 초당 프레임 수. FPS가 낮을수록 동영상 크기는 작아지지만 재생이 원할하지 않습니다.', [Phrase.ObsForceMonoDescription]: '입력 장치를 모노로 강제할지 여부. 오디오 마이크가 하나의 스테레오 채널에서만 재생되는 경우 활성화', [Phrase.ObsQualityDescription]: '기록물 품질. 높을수록 더 많이 작동하고 더 많은 디스크 공간을 사용합니다.', [Phrase.ObsCaptureModeDescription]: '녹화할 때 사용할 모드', [Phrase.ObsRecEncoderDescription]: '사용할 비디오 인코더. 일반적으로 하드웨어(Hardware) 인코더 권장', [Phrase.RecordRetailDescription]: '본섭을 기록해야 하는지 여부', [Phrase.RecordClassicDescription]: '클래식을 기록해야 하는지 여부', [Phrase.RecordEraDescription]: '클래식 시대를 기록해야 하는지 여부', [Phrase.RecordRaidsDescription]: '레이드를 기록해야 하는지 여부', [Phrase.RecordDungeonsDescription]: '쐐기+를 기록해야 하는지 여부', [Phrase.RecordTwoVTwoDescription]: '2v2를 기록해야 하는지 여부', [Phrase.RecordThreeVThreeDescription]: '3v3을 기록해야 하는지 여부', [Phrase.RecordFiveVFiveDescription]: '5v5를 기록해야 하는지 여부', [Phrase.RecordSkirmishDescription]: '연습전투를 기록해야 하는지 여부', [Phrase.RecordSoloShuffleDescription]: '1인전을 기록해야 하는지 여부', [Phrase.RecordBattlegroundsDescription]: '전장을 기록해야 하는지 여부', [Phrase.CaptureCursorDescription]: '기록에 커서를 포함할지 여부', [Phrase.MinKeystoneLevelDescription]: '기록할 최소 쐐기돌 단수', [Phrase.ChallengeModeDescription]: '도전 모드를 기록할지 여부', [Phrase.MinRaidDifficultyDescription]: '기록할 최소 레이드 난이도, 본섭만 적용', [Phrase.MinimizeOnQuitDescription]: '닫기 버튼이 종료가 아닌 최소화해야 하는지 여부', [Phrase.MinimizeToTrayDescription]: '최소화 버튼을 시스템 트레이로 최소화할지 작업 표시줄로 최소화할지 여부', [Phrase.ChatOverlayEnabledDescription]: '일부 영역을 가리고 기록하길 원할 때 활성화', [Phrase.ChatOverlayOwnImageDescription]: '사용자 지정 이미지를 채팅 오버레이로 사용해야 하는 경우(Pro)', [Phrase.ChatOverlayOwnImagePathDescription]: '채팅 오버레이로 사용할 PNG 파일(Pro)', [Phrase.ChatOverlayWidthDescription]: '채팅 오버레이 너비', [Phrase.ChatOverlayHeightDescription]: '채팅 오버레이 높이', [Phrase.ChatOverlayScaleDescription]: '채팅 오버레이 비율', [Phrase.ChatOverlayXPositionDescription]: '채팅 오버레이 x-위치', [Phrase.ChatOverlayYPositionDescription]: '채팅 오버레이 y-위치', [Phrase.SpeakerVolumeDescription]: '녹음 스피커의 볼륨', [Phrase.MicVolumeDescription]: '녹음 마이크의 볼륨', [Phrase.DeathMarkersDescription]: '타임라인에 표시할 죽음 마크', [Phrase.EncounterMarkersDescription]: '타임라인에 표시할 죽음 마크', [Phrase.RoundMarkersDescription]: '타임라인에 표시할 죽음 마크', [Phrase.PushToTalkDescription]: '단축키를 누르고 있을 때만 녹음해야 하는 경우', [Phrase.PushToTalkKeyDescription]: 'PTT 단축키 설정', [Phrase.PushToTalkMouseButtonDescription]: 'PTT 마우스 버튼', [Phrase.PushToTalkModifiersDescription]: 'PTT 단축키와 함께 필요한 수식어의 쉼표로 구분된 목록', [Phrase.PushToTalkReleaseDelayDescription]: 'PTT 키에서 손을 뗀 후 마이크가 활성 상태로 유지되는 시간입니다.', [Phrase.ObsAudioSuppressionDescription]: '마이크에 잡히는 배경 소음을 억제하는데 도움이 될 수 있습니다.', [Phrase.RaidOverrunDescription]: '보스가 죽은 후 기록할 초과 시간', [Phrase.DungeonOverrunDescription]: '던전 완료 후 기록할 초과 시간', [Phrase.CloudStorageDescription]: '클라우드 모드 활성화 & 설정', [Phrase.CloudUploadDescription]: '클라우드 업로드 모드 활성화 & 설정. 기록이 완료되면 자동으로 클라우드에 업로드되며, 기존 동영상도 수동으로 업로드 할 수 있습니다.', [Phrase.CloudUploadRetailDescription]: '본섭 동영상을 클라우드에 업로드해야 하는 경우.', [Phrase.CloudUploadClassicDescription]: '클래식 동영상을 클라우드에 업로드해야 하는 경우.', [Phrase.CloudUploadRateLimitDescription]: '클라우드 업로드 속도를 제한해야 하는 경우 (업로드가 지연되는 경우 유용)', [Phrase.CloudUploadRateLimitMbpsDescription]: '업로드 속도 제한 (MB/s)', [Phrase.CloudAccountNameDescription]: '계정 이름', [Phrase.CloudAccountPasswordDescription]: '계정 비밀번호', [Phrase.CloudGuildNameDescription]: '서버 이름', [Phrase.CloudUpload2v2Description]: '2v2를 업로드해야 하는 경우', [Phrase.CloudUpload3v3Description]: '3v3을 업로드해야 하는 경우', [Phrase.CloudUpload5v5Description]: '5v5를 업로드해야 하는 경우', [Phrase.CloudUploadSkirmishDescription]: '연습전투를 업로드해야 하는 경우', [Phrase.CloudUploadSoloShuffleDescription]: '1인전을 업로드해야 하는 경우', [Phrase.CloudUploadDungeonsDescription]: '쐐기+를 업로드해야 하는 경우', [Phrase.CloudUploadRaidsDescription]: '레이드를 업로드해야 하는 경우', [Phrase.CloudUploadBattlegroundsDescription]: '전장을 업로드해야 하는 경우', [Phrase.CloudUploadRaidMinDifficultyDescription]: '업로드할 최소 레이드 난이도', [Phrase.CloudUploadDungeonMinLevelDescription]: '업로드할 최소 쐐기돌 단수', [Phrase.LanguageDescription]: '프로그램에서 사용할 언어', [Phrase.RecordingsHeading]: 'Recordings', [Phrase.SettingsHeading]: 'Settings', [Phrase.GeneralButtonText]: '설정', [Phrase.SceneButtonText]: '화면 설정', [Phrase.Version]: 'Version', [Phrase.VideoCategoryTwoVTwoLabel]: '2v2', [Phrase.VideoCategoryThreeVThreeLabel]: '3v3', [Phrase.VideoCategoryFiveVFiveLabel]: '5v5', [Phrase.VideoCategorySkirmishLabel]: '연습전투', [Phrase.VideoCategorySoloShuffleLabel]: '1인전', [Phrase.VideoCategoryMythicPlusLabel]: '쐐기+', [Phrase.VideoCategoryRaidsLabel]: '레이드', [Phrase.VideoCategoryBattlegroundsLabel]: '전장', [Phrase.VideoCategoryClipsLabel]: '클립', [Phrase.LogsButtonLabel]: 'Folder', [Phrase.DiscordButtonLabel]: 'Setup', [Phrase.TestButtonUnable]: '지금은 테스트를 실행할 수 없습니다. 테스트를 실행하려면 게임이 실행 중이어야 하고, 설정이 유효해야 하며 현재 활동 중이 아니어야 합니다.', [Phrase.GeneralSettingsLabel]: '일반 설정', [Phrase.DiskStorageFolderLabel]: '디스크 저장 폴더', [Phrase.SeparateBufferFolderLabel]: '별도의 임시 폴더', [Phrase.BufferFolderLabel]: '임시 폴더', [Phrase.MaxDiskStorageLabel]: '디스크 저장 용량 제한 (GB)', [Phrase.WindowsSettingsLabel]: '윈도우 설정', [Phrase.RunOnStartupLabel]: '시작시 실행', [Phrase.StartMinimizedLabel]: '트레이 시작', [Phrase.MinimizeOnQuitLabel]: '종료시 트레이', [Phrase.MinimizeToTrayLabel]: '최소화 트레이', [Phrase.LocaleSettingsLabel]: '언어 설정', [Phrase.LanguageLabel]: '언어', [Phrase.GameSettingsLabel]: '게임 설정', [Phrase.RecordRetailLabel]: '본섭 기록', [Phrase.RetailLogPathLabel]: '본섭 로그 경로', [Phrase.RecordClassicLabel]: '클래식 기록', [Phrase.ClassicLogPathLabel]: '클래식 로그 경로', [Phrase.RecordClassicEraLabel]: '클래식 시대 기록', [Phrase.ClassicEraLogPathLabel]: '클래식 시대 로그 경로', [Phrase.PVESettingsLabel]: 'PvE 설정', [Phrase.RecordRaidsLabel]: '레이드 기록', [Phrase.MinimumEncounterDurationLabel]: '최소 전투 시간 (초)', [Phrase.RaidOverrunLabel]: '레이드 오버런 (초)', [Phrase.MinimumRaidDifficultyLabel]: '최소 레이드 난이도', [Phrase.RecordMythicPlusLabel]: '쐐기+ 기록', [Phrase.MinimumKeystoneLevelLabel]: '최소 쐐기돌 단수', [Phrase.ChallengeModeLabel]: '도전 모드 기록', [Phrase.MythicPlusOverrunLabel]: '쐐기+ 오버런 (초)', [Phrase.PVPSettingsLabel]: 'PvP 설정', [Phrase.Record2v2Label]: '2v2 기록', [Phrase.Record3v3Label]: '3v3 기록', [Phrase.Record5v5Label]: '5v5 기록', [Phrase.RecordSkirmishLabel]: '연습전투 기록', [Phrase.RecordSoloShuffleLabel]: '1인전 기록', [Phrase.RecordBattlegroundsLabel]: '전장 기록', [Phrase.CloudSettingsLabel]: '클라우드 설정', [Phrase.CloudPlaybackLabel]: '클라우드 모드', [Phrase.UserEmailLabel]: '아이디', [Phrase.PasswordLabel]: '비밀번호', [Phrase.GuildNameLabel]: '서버', [Phrase.CloudUploadLabel]: '클라우드 업로드', [Phrase.CloudUploadRetailLabel]: '본섭 업로드', [Phrase.CloudUploadClassicLabel]: '클래식 업로드', [Phrase.UploadRateLimitToggleLabel]: '업로드 속도 제한', [Phrase.UploadRateLimitValueLabel]: '업로드 속도 제한 (MB/s)', [Phrase.UploadRaidsLabel]: '레이드 업로드', [Phrase.UploadDifficultyThresholdLabel]: '업로드 최소 난이도', [Phrase.UploadMythicPlusLabel]: '쐐기+ 업로드', [Phrase.UploadLevelThresholdLabel]: '업로드 최소 단수', [Phrase.Upload2v2Label]: '2v2 업로드', [Phrase.Upload3v3Label]: '3v3 업로드', [Phrase.Upload5v5Label]: '5v5 업로드', [Phrase.UploadSkirmishLabel]: '연습전투 업로드', [Phrase.UploadSoloShuffleLabel]: '1인전 업로드', [Phrase.UploadBattlgroundsLabel]: '전장 업로드', [Phrase.SettingsDisabledText]: '기록이 활성화된 동안에는 이러한 설정을 수정할 수 없습니다.', [Phrase.SomeSettingsDisabledText]: '기록이 활성화되어 있는 동안에는 일부 설정을 수정할 수 없습니다.', [Phrase.CloudSettingsDisabledText]: '클라우드 설정은 업로드 또는 다운로드가 진행 중일 때 수정할 수 없습니다.', [Phrase.InvalidRetailLogPathText]: '잘못된 본섭 로그 경로', [Phrase.InvalidClassicLogPathText]: '잘못된 클래식 로그 경로', [Phrase.InvalidClassicEraLogPathText]: '잘못된 클래식 시대 로그 경로', [Phrase.CannotBeEmpty]: '비워둘 수 없습니다.', [Phrase.OneOrGreater]: '1 이상이어야 합니다.', [Phrase.SourceHeading]: '소스', [Phrase.VideoHeading]: '비디오', [Phrase.AudioHeading]: '오디오', [Phrase.OverlayHeading]: '오버레이', [Phrase.CaptureModeLabel]: '녹화 모드', [Phrase.WindowCaptureValue]: '윈도우', [Phrase.GameCaptureValue]: '게임', [Phrase.MonitorCaptureValue]: '모니터', [Phrase.MonitorLabel]: '모니터', [Phrase.CaptureCursorLabel]: '커서 포함', [Phrase.FPSLabel]: 'FPS', [Phrase.CanvasResolutionLabel]: '화면 해상도', [Phrase.QualityLabel]: '품질', [Phrase.VideoEncoderLabel]: '비디오 인코더', [Phrase.SpeakersLabel]: '스피커', [Phrase.MicrophonesLabel]: '마이크', [Phrase.AudioSuppressionLabel]: '오디오 억제', [Phrase.MonoInputLabel]: '모노 입력', [Phrase.PushToTalkLabel]: 'PTT', [Phrase.PushToTalkKeyLabel]: 'PTT 단축키', [Phrase.PressAnyKeyCombination]: '키 조합을 누르세요..', [Phrase.ClickToBind]: '조합하려면 클릭', [Phrase.ClickToRebind]: '다시 조합하려면 클릭', [Phrase.Mouse]: 'Mouse', [Phrase.ChatOverlayLabel]: '채팅 오버레이', [Phrase.OwnImageLabel]: '지정 이미지', [Phrase.ImagePathLabel]: '이미지 경로', [Phrase.WidthLabel]: '너비', [Phrase.HeightLabel]: '높이', [Phrase.HorizontalLabel]: '가로 위치', [Phrase.VerticalLabel]: '세로 위치', [Phrase.ScaleLabel]: '스케일', [Phrase.TableHeaderEncounter]: 'Encounter', [Phrase.TableHeaderResult]: 'Result', [Phrase.TableHeaderPull]: 'Pull', [Phrase.TableHeaderDifficulty]: 'Difficulty', [Phrase.TableHeaderDuration]: 'Duration', [Phrase.TableHeaderDate]: 'Date', [Phrase.TableHeaderViewpoints]: 'Viewpoints', [Phrase.TableHeaderMap]: 'Map', [Phrase.TableHeaderType]: 'Type', [Phrase.TableHeaderTag]: 'Tag', [Phrase.SearchLabel]: '검색', [Phrase.SearchSuggestionMythicPlus]: 'Search', [Phrase.SearchSuggestionRaid]: 'Search', [Phrase.SearchSuggestionBattlegrounds]: 'Search', [Phrase.SearchSuggestionSoloShuffle]: 'Search', [Phrase.SearchSuggestionDefault]: 'Search', [Phrase.ShowRoundsLabel]: '라운드 표시', [Phrase.ShowDeathsLabel]: '죽음 표시', [Phrase.ShowEncountersLabel]: '보스 표시', [Phrase.FullScreenTooltip]: '전체 화면', [Phrase.PlaybackSpeedTooltip]: '재생 속도', [Phrase.ClipTooltip]: '클립', [Phrase.ClipUnavailableTooltip]: '디스크에 저장된 동영상만 클립할 수 있습니다.', [Phrase.ConfirmTooltip]: '확인', [Phrase.CancelTooltip]: '취소', [Phrase.TagButtonTooltip]: '태그', [Phrase.StarButtonTooltip]: '북마크 켜기', [Phrase.UnstarButtonTooltip]: '북마크 끄기', [Phrase.OpenFolderButtonTooltip]: '저장 폴더 열기', [Phrase.DeleteButtonTooltip]: '삭제', [Phrase.BulkDeleteButtonTooltip]: '선택한 행 삭제', [Phrase.ShareLinkButtonTooltip]: '공유 가능한 링크 받기', [Phrase.CloudButtonTooltip]: '클라우드에 저장됨', [Phrase.DiskButtonTooltip]: '디스크에 저장됨', [Phrase.UploadButtonTooltip]: '클라우드에 업로드', [Phrase.DownloadButtonTooltip]: '디스크에 다운로드', [Phrase.StatusTitleRecording]: '녹화 중', [Phrase.StatusTitleWaiting]: '대기 중', [Phrase.StatusTitleInvalid]: '잘못된 구성', [Phrase.StatusTitleReady]: '녹화 준비 완료', [Phrase.StatusTitleFatalError]: '오류 발생', [Phrase.StatusTitleOverrunning]: '오버런 중', [Phrase.StatusTitleReconfiguring]: '대기 중', [Phrase.StatusDescriptionRecording]: '현재 기록 중입니다.', [Phrase.StatusDescriptionForceEnd]: '기록을 강제로 종료할 수 있습니다. 일반적으로 이 선택은 필요하지 않습니다. 실패한 쐐기+를 강제 종료하는 데 도움이 될 수 있습니다.', [Phrase.StatusDescriptionWaiting]: '게임이 시작되기를 기다리는 중.', [Phrase.StatusDescriptionConfiguredToRecord]: '프로그램이 녹화하도록 구성되었습니다.', [Phrase.StatusDescriptionMisconfigured]: '프로그램이 잘못 구성되었습니다.', [Phrase.StatusDescriptionResolveError]: '아래 오류를 해결하세요.', [Phrase.StatusDescriptionDetectedRunning]: '게임이 실행 중임을 감지했습니다.', [Phrase.StatusDescriptionWatchingLogs]: '기록 가능한 이벤트가 전투 로그에 나타나기를 기다리고 있습니다.', [Phrase.StatusDescriptionTip]: 'Tip', [Phrase.StatusDescriptionIfNoRecording]: '기록이 시작되지 않으면 게임 내 로그 애드온 설정을 확인하고 로그 경로 구성이 올바른지 확인하세요.', [Phrase.StatusDescriptionFatalError]: '치명적인 오류가 발생했습니다.', [Phrase.StatusDescriptionPleaseResolve]: '아래 오류를 해결한 후 프로그램을 다시 시작하세요.', [Phrase.StatusDescriptionIfRecurring]: '이 문제가 반복되면 재설치 또는 도움을 요청하세요.', [Phrase.StatusDescriptionOverrunning]: '기록 활동이 성공적으로 완료되었음을 감지했습니다. 기록을 파악하기 위해 몇 초 더 필요합니다.', [Phrase.StatusDescriptionNothing]: '아무것도 없습니다. 설정에서 게임 모드를 활성화할 수 있습니다.', [Phrase.StatusHeading]: 'Status', [Phrase.StatusButtonForceEndLabel]: '강제 중지', [Phrase.Retail]: '본섭', [Phrase.Classic]: '클래식', [Phrase.Era]: '시대', [Phrase.MicListeningTooltip]: 'Listening', [Phrase.MicMutedTooltip]: 'Muted', [Phrase.CrashHappenedText]: 'OBS 충돌이 발생하여 복구되었습니다. 정상적인 작동에서는 이런 일이 일어나지 않아야 합니다.', [Phrase.SettingsPageApplicationHeader]: 'Application', [Phrase.SettingsPageGameHeader]: 'Game', [Phrase.SettingsPageProHeader]: 'Pro', [Phrase.TestButtonHeading]: '테스트할 카테고리를 선택하세요', [Phrase.SystemTrayOpen]: '열기', [Phrase.SystemTrayQuit]: '종료', [Phrase.Kill]: '킬', [Phrase.Wipe]: '전멸', [Phrase.Win]: '승리', [Phrase.Loss]: '패배', [Phrase.Abandoned]: '탈주', [Phrase.Depleted]: '소진', [Phrase.AreYouSure]: '확실하신가요?', [Phrase.ThisWillPermanentlyDelete]: '이렇게 하면 영구적으로 삭제됩니다.', [Phrase.Recordings]: '기록물', [Phrase.From]: '/', [Phrase.Rows]: '행', [Phrase.Death]: '죽음', [Phrase.AddADescription]: '태그 추가', [Phrase.TagDialogText]: '이 기록은 검색창에서 조회할 수 있습니다.', [Phrase.Clear]: '삭제', [Phrase.Save]: '저장', [Phrase.ShareableLinkTitle]: '공유할 수 있는 링크가 클립보드에 저장됐습니다.', [Phrase.ShareableLinkText]: '이 링크는 동영상이 클라우드에 저장되어 있는 동안 유효합니다.', [Phrase.ShareableLinkFailedTitle]: '링크 생성에 실패했습니다.', [Phrase.ShareableLinkFailedText]: '다시 시도해 보세요.', [Phrase.CloudUsageDescription]: '클라우드 사용량', [Phrase.DiskUsageDescription]: '디스크 사용량', [Phrase.Hardware]: 'Hardware', [Phrase.Software]: 'Software', [Phrase.All]: '전체', [Phrase.Own]: '자신', [Phrase.None]: '없음', [Phrase.On]: 'On', [Phrase.Off]: 'Off', [Phrase.Ultra]: '매우 높음', [Phrase.High]: '높음', [Phrase.Moderate]: '보통', [Phrase.Low]: '낮음', [Phrase.LFR]: '공격대 찾기', [Phrase.Normal]: '일반', [Phrase.Heroic]: '영웅', [Phrase.Mythic]: '신화', [Phrase.Pvp]: 'PVP', [Phrase.ErrorAccountEmpty]: '계정 아이디는 비워둘 수 없습니다.', [Phrase.ErrorPasswordEmpty]: '계정 비밀번호는 비워둘 수 없습니다.', [Phrase.ErrorGuildEmpty]: '서버 이름은 비워둘 수 없습니다.', [Phrase.ErrorUserNotAuthorizedPlayback]: '클라우드에 접근할 수 있는 권한이 없습니다.', [Phrase.ErrorUserNotAuthorizedUpload]: '클라우드에 업로드할 수 있는 권한이 없습니다.', [Phrase.ErrorStoragePathInvalid]: '저장 경로가 잘못되었습니다.', [Phrase.ErrorBufferPathInvalid]: '임시 저장 경로가 잘못되었습니다.', [Phrase.ErrorStoragePathSameAsBufferPath]: '저장 경로가 임시 경로와 동일합니다.', [Phrase.ErrorCustomOverlayNotAllowed]: '사용자 지정 오버레이를 사용하려면 클라우드 모드를 사용 설정하세요.', [Phrase.ErrorNoCustomImage]: '사용자 지정 오버레이 이미지 경로가 지정되지 않았습니다.', [Phrase.ErrorCustomImageFileType]: '오버레이 이미지는 .png 또는 .gif 파일이어야 합니다.', [Phrase.ErrorCustomImageNotExist]: '지정한 파일이 존재하지 않습니다.', [Phrase.InvalidRetailLogPath]: '잘못된 본섭 로그 경로', [Phrase.InvalidClassicLogPath]: '잘못된 클래식 로그 경로', [Phrase.InvalidEraLogPath]: '잘못된 시대 로그 경로', [Phrase.SelectAnOutputDevice]: '출력 장치 선택', [Phrase.SelectAnInputDevice]: '입력 장치 선택', [Phrase.ClickToSelectAll]: '모두 선택하려면 클릭', [Phrase.ClickToSortAsc]: '오름차순으로 정렬하려면 클릭', [Phrase.ClickToSortDec]: '내림차순으로 정렬하려면 클릭', [Phrase.ClickToClearSort]: '정렬을 지우려면 클릭', [Phrase.Start]: '시작', [Phrase.End]: '끝', [Phrase.Cloud]: "클라우드", [Phrase.Disk]: "디스크", [Phrase.Starred]: "북마크", [Phrase.NotStarred]: "잠금 해제됨", [Phrase.Tagged]: "태그", [Phrase.Today]: "오늘", [Phrase.Yesterday]: "어제", [Phrase.Chests]: "상자", [Phrase.Timed]: "소진", [Phrase.Activity]: "활동", [Phrase.Unknown]: "알 수 없음", [Phrase.NoneTagged]: "이 행에는 태그가 지정된 기록이 없습니다.", [Phrase.MultipleTagged]: "이 행에는 태그가 지정된 기록이 여러 개 포함되어 있습니다.", [Phrase.NoneStarred]: "이 행은 잠겨 있지 않습니다. 기록이 오래되면 자동 삭제될 수 있습니다.", [Phrase.SomeStarred]: '이 행은 잠겨 있습니다. 자동으로 삭제되지 않습니다.', [Phrase.UploadClipsLabel]: '클립 업로드', [Phrase.CloudUploadClipsDescription]: '클립을 업로드해야 하는 경우', [Phrase.RetailPtrLogPathDescription]: '본섭 PTR 로그 폴더 위치 (ex: "World of Warcraft\\_xptr_\\Logs")', [Phrase.RecordRetailPtrDescription]: '본섭 PTR을 기록해야 하는지 여부', [Phrase.RetailPtr]: '본섭 PTR', [Phrase.RecordRetailPtrLabel]: '본섭 PTR 기록', [Phrase.RetailPtrLogPathLabel]: '본섭 PTR 로그 경로', [Phrase.InvalidRetailPtrLogPathText]: '잘못된 본섭 PTR 로그 경로', [Phrase.Details]: 'Details', [Phrase.PlayerModeLabel]: '플레이어 모드', [Phrase.MultiPlayerModeHeading]: '멀티플레이어 모드', [Phrase.MultiPlayerModeAdvice1]: '왼쪽의 그리드를 사용하여 최대 4명의 플레이어를 한 번에 선택/선택 해제할 수 있습니다.', [Phrase.MultiPlayerModeAdvice2]: '여기에 일반적으로 표시되는 버튼을 복원하려면 싱글 플레이어 모드로 돌아가세요.', [Phrase.UpdateAvailableTooltip]: '업데이트가 있습니다. 클릭하여 설치하세요.', [Phrase.UpdateAvailableTitle]: '업데이트 가능', [Phrase.UpdateAvailableText]: '사용 가능한 업데이트가 있으며 설치할 준비가 되었습니다.', [Phrase.UpdateAvailableInstallButtonText]: '지금 설치', [Phrase.UpdateAvailableRemindButtonText]: '나중에 알림', [Phrase.Saving]: '저장...', [Phrase.StartTyping]: '검색...', [Phrase.ToggleDrawingMode]: '그리기 모드', [Phrase.StarSelected]: '선택한 행 잠금', [Phrase.UnstarSelected]: '선택한 행 잠금 해제', [Phrase.Selection]: '선택 옵션', [Phrase.NoCombatants]: '전투원 데이터가 없습니다.', [Phrase.DateFilter]: "날짜 필터", [Phrase.DateFilterSeparator]: "~", [Phrase.Last7Days]: "최근 7일", [Phrase.Last30Days]: "최근 30일", [Phrase.ThisMonth]: "이번 달", [Phrase.LastMonth]: "지난 달", [Phrase.Cancel]: "취소", [Phrase.Apply]: "적용", [Phrase.January]: "1월", [Phrase.February]: "2월", [Phrase.March]: "3월", [Phrase.April]: "4월", [Phrase.May]: "5월", [Phrase.June]: "6월", [Phrase.July]: "7월", [Phrase.August]: "8월", [Phrase.September]: "9월", [Phrase.October]: "10월", [Phrase.November]: "11월", [Phrase.December]: "12월", [Phrase.Sunday]: "일", [Phrase.Monday]: "월", [Phrase.Tuesday]: "화", [Phrase.Wednesday]: "수", [Phrase.Thursday]: "목", [Phrase.Friday]: "금", [Phrase.Saturday]: "토", [Phrase.PermissionLabel]: "권한", [Phrase.PermissionDescription]: "관리자가 부여한 현재 선택된 서버 내 접근 권한 수준입니다.", [Phrase.PermissionReadLabel]: "읽기", [Phrase.PermissionReadDescription]: "영상 재생을 허용합니다.", [Phrase.PermissionWriteLabel]: "쓰기", [Phrase.PermissionWriteDescription]: "영상 업로드, 태그 지정, 잠금을 허용합니다.", [Phrase.PermissionDeleteLabel]: "삭제", [Phrase.PermissionDeleteDescription]: "영상 삭제 및 잠금 해제를 허용합니다.", [Phrase.ButtonDiskOnlyDescription]: "사용자는 구성된 서버에 대한 접근 권한이 제한되어 있습니다. 디스크 동영상에만 선택 옵션을 적용하고, 클라우드 동영상과 관련 서버 접근 권한은 무시합니다.", [Phrase.StorageFilterLabel]: "저장소 필터", [Phrase.ShowDiskOnlyTooltip]: "디스크 동영상만 표시", [Phrase.ShowCloudOnlyTooltip]: "클라우드 동영상만 표시", [Phrase.ShowBothTooltip]: "디스크 및 클라우드 동영상을 그룹화하고 모든 동영상을 표시합니다.", [Phrase.GuildNoPermission]: "이 작업을 수행하기 위한 권한이 없습니다.", [Phrase.RemoveTagFromList]: "목록에서 %value%를 제거합니다.", [Phrase.DownloadUploadDisabledDueToFilter]: "현재 선택한 저장소 필터로 인해 비활성화되었습니다.", [Phrase.ProcessesLabel]: "응용 프로그램", [Phrase.SelectProcess]: "응용 프로그램 선택", [Phrase.AudioProcessDevicesDescription]: "선택한 스피커와 마이크 외에 오디오를 포함할 응용 프로그램", [Phrase.ProcessVolumeDescription]: "기록에서 응용 프로그램의 볼륨은 0에서 1까지입니다.", [Phrase.HideEmptyCategoriesLabel]: "빈 카테고리 숨기기", [Phrase.HideEmptyCategoriesDescription]: "동영상이 없는 카테고리를 숨깁니다.", [Phrase.HardwareAccelerationLabel]: "하드웨어 가속", [Phrase.HardwareAccelerationDescription]: "프로그램의 하드웨어 가속 렌더링을 활성화합니다. 이 기능은 대부분의 사용자에게 권장되며, 특히 AV1 인코딩을 사용하는 경우에 유용하지만 일부 시스템에서는 문제가 발생할 수 있습니다. 이 변경 사항은 프로그램을 재시작해야 적용됩니다.", [Phrase.RecordCurrentRaidsOnlyLabel]: "현재 시즌 레이드만", [Phrase.RecordCurrentRaidsOnlyDescription]: "현재 시즌의 레이드 전투만 기록합니다. 이는 본섭 레이드 전투에만 적용됩니다.", [Phrase.UploadCurrentRaidsOnlyLabel]: "현재 시즌 레이드만", [Phrase.UploadCurrentRaidsOnlyDescription]: "현재 시즌의 레이드 전투만 업로드합니다. 이는 본섭 레이드 전투에만 적용됩니다.", [Phrase.MustNotBeEmpty]: "비워둘 수 없습니다", [Phrase.PushToTalkReleaseDelayLabel]: "종료 지연", [Phrase.ForceSdrLabel]: "SDR 강제", [Phrase.ForceSdrDescription]: "HDR 대신 SDR로 영상을 강제로 렌더링합니다.", [Phrase.VideoSourceScaleDescription]: "비디오 소스의 비율.", [Phrase.VideoSourceXPositionDescription]: "비디오 소스의 X 좌표.", [Phrase.VideoSourceYPositionDescription]: "비디오 소스의 Y 좌표.", [Phrase.VideoCategoryManualLabel]: '수동', [Phrase.ManualRecordSettingsLabel]: '수동 녹화 설정', [Phrase.ManualRecordSwitchLabel]: '수동 녹화', [Phrase.ManualRecordHotKeyLabel]: '시작/중지 단축키', [Phrase.ManualRecordUploadLabel]: '수동 녹화 업로드', [Phrase.ManualRecordDescription]: '전투 로그에 기록되지 않는 상황을 사용자가 직접 시작하고 중지하여 녹화할 수 있습니다.', [Phrase.ManualRecordHotKeyDescription]: '수동 녹화를 시작하거나 중지할 단축키를 설정합니다.', [Phrase.ManualRecordUploadDescription]: '수동으로 시작한 녹화를 자동으로 클라우드에 업로드합니다.', [Phrase.ManualRecordSoundAlertLabel]: '소리 알림', [Phrase.ManualRecordSoundAlertDescription]: '수동 녹화가 시작되거나 중지될 때 소리 알림을 재생합니다.', [Phrase.StatusTitleRec]: 'Rec', [Phrase.StatusTitlePro]: 'Pro', [Phrase.StatusTitleConnected]: '연결됨', [Phrase.StatusDescrConnected]: '연결이 완료되었습니다. 기능이 활성화되었습니다.', [Phrase.StatusTitleDisconnected]: '연결 해제', [Phrase.StatusDescrDisconnected]: '기능을 사용하려면 구독 또는 서버에 가입한 뒤 설정 페이지에서 로그인 정보를 입력해야 합니다.', [Phrase.StatusTitleNotAuthenticated]: '로그인 실패', [Phrase.StatusDescrNotAuthenticated]: '로그인에 실패했습니다. 계정 정보가 올바른지 확인하세요.', [Phrase.StatusTitleNoGuild]: '서버 없음', [Phrase.StatusDescrNoGuild]: '로그인은 성공했지만 선택한 서버가 없습니다.', [Phrase.StatusTitleNotAuthorized]: '접근 권한 없음', [Phrase.StatusDescrNotAuthorized]: '로그인은 성공했지만 선택한 서버에 접근할 권한이 없습니다.', [Phrase.FirstTimeSetupDescription]: '사용자가 처음으로 앱을 실행한 경우입니다.', [Phrase.AutoSelectEncoderTooltip]: '사용 가능한 옵션 중 합리적인 인코더를 자동으로 선택합니다. 대부분의 사용자에게 적합합니다.', [Phrase.CloudRefreshGuildTooltip]: '사용 가능한 서버 목록을 새로 고칩니다.', [Phrase.SourceSnappingSwitchText]: '맞춤 정렬', [Phrase.SourceSnappingSwitchTooltip]: '맞춤 정렬 기능을 켜거나 끕니다.', [Phrase.ResetOverlayButtonText]: '오버레이 초기화', [Phrase.ResetGameButtonText]: '게임 창 자동 맞춤', [Phrase.GameWindowLabel]: '게임 창', [Phrase.OverlayLabel]: '오버레이 창', [Phrase.AddSpeakerButtonText]: '스피커', [Phrase.AddMicrophoneButtonText]: '마이크', [Phrase.AddApplicationButtonText]: '애플리케이션', [Phrase.NoAudioSourcesText]: '오디오를 녹음할 소스를 추가하세요.', [Phrase.SelectADevice]: '장치를 선택하세요...', [Phrase.SelectAnApplication]: '응용 프로그램을 선택하세요...', [Phrase.BulkUploadButtonTooltip]: '선택한 모든 기록을 디스크에서 클라우드로 업로드합니다.', [Phrase.BulkDownloadButtonTooltip]: '선택한 모든 기록을 클라우드에서 디스크로 다운로드합니다.', [Phrase.BulkUploadDialogText]: '선택한 모든 기록이 클라우드 업로드 대기열에 추가됩니다. 진행 상황은 좌측 상단의 상태 배너를 통해 확인할 수 있습니다', [Phrase.BulkDownloadDialogText]: '선택한 모든 기록이 클라우드에서 다운로드 대기열에 추가됩니다. 진행 상황은 좌측 상단의 상태 배너를 통해 확인할 수 있습니다.', [Phrase.BulkTransferWarningText]: '완료하는 데 시간이 오래 걸릴 수 있습니다. 컴퓨터를 끄면 프로세스가 중단됩니다.', [Phrase.UploadButtonText]: '업로드 대기열 추가', [Phrase.DownloadButtonText]: '다운로드 대기열 추가', [Phrase.ChatTypeMessageText]: '댓글 추가… (타임스탬프는 MM:SS 형식을 사용하세요.)', [Phrase.ChatUploadToCloudText]: '채팅에 참여하려면 이 영상을 클라우드에 업로드하세요.', [Phrase.ChatNoMessagesText]: '표시할 메시지가 없습니다.', [Phrase.ChatErrorLoadingText]: '채팅 메시지를 불러오는 중 오류가 발생했습니다.', [Phrase.ChatUserText1]: '기록물 영상 채팅에 참여합니다.', [Phrase.ChatUserText2]: '요청에 따라 이메일 주소가 아닌 닉네임으로 설정할 수 있습니다: ', [Phrase.ChatUserText3]: ', 닉네임 설정 후 앱 설정에서 아이디를 업데이트하세요.', [Phrase.ChatUserText4]: '채팅 참여.', [Phrase.ChatForClipsComingSoon]: '추후 추가.', [Phrase.ClassicPtrLogPathDescription]: '클래식 PTR 로그 폴더 위치, (ex: "World of Warcraft\\_classic_ptr_\\Logs")', [Phrase.RecordClassicPtrDescription]: '클래식 PTR을 기록할지 여부. 이 기능은 블리자드의 PTR 전투 로그 호환성 유지 여부에 따라 달라질 수 있어 동작이 불안정할 수 있습니다.', [Phrase.RecordClassicPtrLabel]: '클래식 PTR 기록', [Phrase.ClassicPtrLogPathLabel]: '클래식 PTR 로그 경로', [Phrase.InvalidClassicPtrLogPathText]: '잘못된 클래식 PTR 로그 경로', [Phrase.ClassicPtr]: '클래식 PTR', [Phrase.InvalidClassicPtrLogPath]: '잘못된 클래식 PTR 로그 경로', [Phrase.PatreonButtonLabel]: 'Patreon 구독하기', [Phrase.SelectDifficulty]: '난이도 선택', [Phrase.SelectResolution]: '해상도 선택', [Phrase.SelectQuality]: '품질 선택', [Phrase.SelectEncoder]: '인코더 선택', [Phrase.SelectGuild]: '서버 선택', [Phrase.SelectOptions]: '옵션 선택', [Phrase.SelectLanguage]: '언어 선택', [Phrase.ChatDeleteMessageTooltip]: '이 채팅 메시지 삭제', [Phrase.ValidateLogPathLabel]: '로그 경로 검증', [Phrase.ValidateLogPathsDescription]: 'Warcraft Recorder는 설정한 로그 경로가 지원되는 WoW 설치를 가리키는지 확인합니다. 이 검사를 비활성화하면 지원되지 않는 게임 모드를 설정할 수 있습니다. 이 기능을 비활성화하는 것은 사용자 책임입니다.', [Phrase.StartManualRecordingTooltip]: '수동 녹화 시작', [Phrase.StopManualRecordingTooltip]: '수동 녹화 중지', [Phrase.RecordedAt]: '녹화 시간', [Phrase.AdvancedCombatLoggingDisabledWarning]: '고급 전투 기록이 활성화되어 있지 않습니다. WoW에서 활성화하세요: 시스템 > 네트워크 > 고급 전투 기록. 참고: 활성화 후 WoW를 완전히 종료해야 합니다 - 설정은 종료 시에만 Config.wtf에 기록됩니다.', [Phrase.KillVideoCreatorTooltip]: '멀티뷰 비디오를 생성합니다.', [Phrase.KillVideoCreatorTooltipNotEnoughLocal]: '이 기능은 로컬 녹화에서만 사용할 수 있습니다. 먼저 다운로드 버튼을 사용하세요.', [Phrase.KillVideoCreatorTooltipNotEnoughPov]: '이 기능을 사용하려면 최소 2개의 시점이 필요합니다.', [Phrase.KillVideoCreatorTitle]: '비디오 편집기', [Phrase.KillVideoSingleAudioTrackLabel]: '단일 오디오 트랙', [Phrase.KillVideoSingleAudioTrackTooltip]: '하나의 오디오 트랙만 사용합니다. 비활성화하면 비디오에 맞게 오디오 트랙이 전환됩니다.', [Phrase.KillVideoAudioTrackLabel]: '오디오 트랙', [Phrase.KillVideoAudioTrackTooltip]: '사용할 오디오 트랙을 선택합니다.', [Phrase.KillVideoCreating]: '멀티뷰 비디오 인코딩 중...', [Phrase.KillVideoDescription]: '같은 전투의 여러 시점을 하나의 영상으로 결합하고, 클립을 부드러운 전환과 함께 자동으로 이어 붙입니다. 재인코딩이 필요하며 CPU 사용량이 높아 보통 몇 분 정도 소요됩니다. 완료된 영상은 Clips 카테고리에 표시됩니다.', [Phrase.KillVideoRemove]: '拖到此处以移除', [Phrase.Reset]: '초기화', [Phrase.Render]: '렌더링', [Phrase.Preparing]: '준비 중', }; export default KOREAN; ================================================ FILE: src/localisation/phrases.ts ================================================ enum Phrase { NoVideosSaved, FirstTimeHere, SetupInstructions, ClipsDisplayedHere, NoClipsSaved, StoragePathDescription, SeparateBufferPathDescription, BufferStoragePathDescription, RetailLogPathDescription, ClassicLogPathDescription, ClassicPtrLogPathDescription, EraLogPathDescription, MaxStorageDescription, MonitorIndexDescription, SelectedCategoryDescription, AudioInputDevicesDescription, AudioOutputDevicesDescription, MinEncounterDurationDescription, StartUpDescription, StartMinimizedDescription, ObsOutputResolutionDescription, ObsFPSDescription, ObsForceMonoDescription, ObsQualityDescription, ObsCaptureModeDescription, ObsRecEncoderDescription, RecordRetailDescription, RecordClassicDescription, RecordClassicPtrDescription, RecordEraDescription, RecordRaidsDescription, RecordDungeonsDescription, RecordTwoVTwoDescription, RecordThreeVThreeDescription, RecordFiveVFiveDescription, RecordSkirmishDescription, RecordSoloShuffleDescription, RecordBattlegroundsDescription, CaptureCursorDescription, MinKeystoneLevelDescription, ChallengeModeDescription, MinRaidDifficultyDescription, MinimizeOnQuitDescription, MinimizeToTrayDescription, ChatOverlayEnabledDescription, ChatOverlayOwnImageDescription, ChatOverlayOwnImagePathDescription, ChatOverlayWidthDescription, ChatOverlayHeightDescription, ChatOverlayScaleDescription, ChatOverlayXPositionDescription, ChatOverlayYPositionDescription, SpeakerVolumeDescription, MicVolumeDescription, DeathMarkersDescription, EncounterMarkersDescription, RoundMarkersDescription, PushToTalkDescription, PushToTalkKeyDescription, PushToTalkMouseButtonDescription, PushToTalkModifiersDescription, PushToTalkReleaseDelayDescription, ObsAudioSuppressionDescription, RaidOverrunDescription, DungeonOverrunDescription, CloudStorageDescription, CloudUploadDescription, CloudUploadRetailDescription, CloudUploadClassicDescription, CloudUploadRateLimitDescription, CloudUploadRateLimitMbpsDescription, CloudAccountNameDescription, CloudAccountPasswordDescription, CloudGuildNameDescription, CloudUpload2v2Description, CloudUpload3v3Description, CloudUpload5v5Description, CloudUploadSkirmishDescription, CloudUploadSoloShuffleDescription, CloudUploadDungeonsDescription, CloudUploadRaidsDescription, CloudUploadBattlegroundsDescription, CloudUploadRaidMinDifficultyDescription, CloudUploadDungeonMinLevelDescription, CloudUploadClipsDescription, LanguageDescription, RecordingsHeading, SettingsHeading, GeneralButtonText, SceneButtonText, Version, VideoCategoryTwoVTwoLabel, VideoCategoryThreeVThreeLabel, VideoCategoryFiveVFiveLabel, VideoCategorySkirmishLabel, VideoCategorySoloShuffleLabel, VideoCategoryMythicPlusLabel, VideoCategoryRaidsLabel, VideoCategoryBattlegroundsLabel, VideoCategoryManualLabel, VideoCategoryClipsLabel, LogsButtonLabel, DiscordButtonLabel, TestButtonUnable, GeneralSettingsLabel, DiskStorageFolderLabel, SeparateBufferFolderLabel, BufferFolderLabel, MaxDiskStorageLabel, WindowsSettingsLabel, RunOnStartupLabel, StartMinimizedLabel, MinimizeOnQuitLabel, MinimizeToTrayLabel, LocaleSettingsLabel, LanguageLabel, GameSettingsLabel, RecordRetailLabel, RetailLogPathLabel, RecordClassicLabel, ClassicLogPathLabel, RecordClassicPtrLabel, ClassicPtrLogPathLabel, RecordClassicEraLabel, ClassicEraLogPathLabel, PVESettingsLabel, RecordRaidsLabel, MinimumEncounterDurationLabel, RaidOverrunLabel, MinimumRaidDifficultyLabel, RecordMythicPlusLabel, MinimumKeystoneLevelLabel, ChallengeModeLabel, MythicPlusOverrunLabel, PVPSettingsLabel, Record2v2Label, Record3v3Label, Record5v5Label, RecordSkirmishLabel, RecordSoloShuffleLabel, RecordBattlegroundsLabel, CloudSettingsLabel, CloudPlaybackLabel, UserEmailLabel, PasswordLabel, GuildNameLabel, CloudUploadLabel, CloudUploadRetailLabel, CloudUploadClassicLabel, UploadRateLimitToggleLabel, UploadRateLimitValueLabel, UploadRaidsLabel, UploadDifficultyThresholdLabel, UploadMythicPlusLabel, UploadLevelThresholdLabel, Upload2v2Label, Upload3v3Label, Upload5v5Label, UploadSkirmishLabel, UploadSoloShuffleLabel, UploadBattlgroundsLabel, UploadClipsLabel, SettingsDisabledText, SomeSettingsDisabledText, CloudSettingsDisabledText, InvalidRetailLogPathText, InvalidClassicLogPathText, InvalidClassicPtrLogPathText, InvalidClassicEraLogPathText, CannotBeEmpty, OneOrGreater, SourceHeading, VideoHeading, AudioHeading, OverlayHeading, CaptureModeLabel, WindowCaptureValue, GameCaptureValue, MonitorCaptureValue, MonitorLabel, CaptureCursorLabel, FPSLabel, CanvasResolutionLabel, QualityLabel, VideoEncoderLabel, SpeakersLabel, MicrophonesLabel, AudioSuppressionLabel, MonoInputLabel, PushToTalkLabel, PushToTalkKeyLabel, PressAnyKeyCombination, ClickToBind, ClickToRebind, Mouse, ChatOverlayLabel, OwnImageLabel, ImagePathLabel, WidthLabel, HeightLabel, HorizontalLabel, VerticalLabel, ScaleLabel, TableHeaderEncounter, TableHeaderResult, TableHeaderPull, TableHeaderDifficulty, TableHeaderDuration, TableHeaderDate, TableHeaderViewpoints, TableHeaderMap, TableHeaderType, TableHeaderTag, SearchLabel, SearchSuggestionMythicPlus, SearchSuggestionRaid, SearchSuggestionBattlegrounds, SearchSuggestionSoloShuffle, SearchSuggestionDefault, ShowDeathsLabel, ShowEncountersLabel, ShowRoundsLabel, FullScreenTooltip, PlaybackSpeedTooltip, ClipTooltip, ClipUnavailableTooltip, ConfirmTooltip, CancelTooltip, TagButtonTooltip, StarButtonTooltip, UnstarButtonTooltip, OpenFolderButtonTooltip, DeleteButtonTooltip, BulkDeleteButtonTooltip, ShareLinkButtonTooltip, CloudButtonTooltip, DiskButtonTooltip, UploadButtonTooltip, DownloadButtonTooltip, StatusTitleRecording, StatusTitleWaiting, StatusTitleInvalid, StatusTitleReady, StatusTitleFatalError, StatusTitleOverrunning, StatusTitleReconfiguring, StatusDescriptionRecording, StatusDescriptionForceEnd, StatusDescriptionWaiting, StatusDescriptionConfiguredToRecord, StatusDescriptionMisconfigured, StatusDescriptionResolveError, StatusDescriptionDetectedRunning, StatusDescriptionWatchingLogs, StatusDescriptionTip, StatusDescriptionIfNoRecording, StatusDescriptionFatalError, StatusDescriptionPleaseResolve, StatusDescriptionIfRecurring, StatusDescriptionOverrunning, StatusDescriptionNothing, StatusHeading, StatusButtonForceEndLabel, Retail, Classic, ClassicPtr, Era, MicListeningTooltip, MicMutedTooltip, CrashHappenedText, SettingsPageApplicationHeader, SettingsPageGameHeader, SettingsPageProHeader, TestButtonHeading, SystemTrayQuit, SystemTrayOpen, Kill, Wipe, Win, Loss, Abandoned, Depleted, AreYouSure, ThisWillPermanentlyDelete, Recordings, From, Rows, Death, AddADescription, TagDialogText, Clear, Reset, Render, Save, ShareableLinkTitle, ShareableLinkText, ShareableLinkFailedTitle, ShareableLinkFailedText, CloudUsageDescription, DiskUsageDescription, Hardware, Software, All, Own, None, On, Off, Ultra, High, Moderate, Low, LFR, Normal, Heroic, Mythic, Pvp, ErrorAccountEmpty, ErrorPasswordEmpty, ErrorGuildEmpty, ErrorUserNotAuthorizedPlayback, ErrorUserNotAuthorizedUpload, ErrorStoragePathInvalid, ErrorBufferPathInvalid, ErrorStoragePathSameAsBufferPath, ErrorCustomOverlayNotAllowed, ErrorNoCustomImage, ErrorCustomImageFileType, ErrorCustomImageNotExist, InvalidRetailLogPath, InvalidClassicLogPath, InvalidClassicPtrLogPath, InvalidEraLogPath, SelectAnOutputDevice, SelectAnInputDevice, ClickToSelectAll, ClickToSortAsc, ClickToSortDec, ClickToClearSort, Start, End, Cloud, Disk, Starred, NotStarred, Tagged, Today, Yesterday, Chests, Timed, Activity, Unknown, NoneTagged, MultipleTagged, NoneStarred, SomeStarred, RetailPtrLogPathDescription, RecordRetailPtrDescription, RetailPtr, RecordRetailPtrLabel, RetailPtrLogPathLabel, InvalidRetailPtrLogPathText, Details, PlayerModeLabel, MultiPlayerModeHeading, MultiPlayerModeAdvice1, MultiPlayerModeAdvice2, UpdateAvailableTooltip, UpdateAvailableTitle, UpdateAvailableText, UpdateAvailableInstallButtonText, UpdateAvailableRemindButtonText, Saving, StartTyping, ToggleDrawingMode, UnstarSelected, StarSelected, Selection, NoCombatants, DateFilter, DateFilterSeparator, Last7Days, Last30Days, ThisMonth, LastMonth, Cancel, Apply, January, February, March, April, May, June, July, August, September, October, November, December, Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, PermissionLabel, PermissionDescription, PermissionReadLabel, PermissionReadDescription, PermissionWriteLabel, PermissionWriteDescription, PermissionDeleteLabel, PermissionDeleteDescription, ButtonDiskOnlyDescription, StorageFilterLabel, ShowDiskOnlyTooltip, ShowCloudOnlyTooltip, ShowBothTooltip, GuildNoPermission, RemoveTagFromList, DownloadUploadDisabledDueToFilter, ProcessesLabel, SelectProcess, AudioProcessDevicesDescription, ProcessVolumeDescription, HideEmptyCategoriesLabel, HideEmptyCategoriesDescription, HardwareAccelerationLabel, HardwareAccelerationDescription, RecordCurrentRaidsOnlyLabel, RecordCurrentRaidsOnlyDescription, UploadCurrentRaidsOnlyLabel, UploadCurrentRaidsOnlyDescription, MustNotBeEmpty, PushToTalkReleaseDelayLabel, ForceSdrLabel, ForceSdrDescription, VideoSourceScaleDescription, VideoSourceXPositionDescription, VideoSourceYPositionDescription, ManualRecordSettingsLabel, ManualRecordSwitchLabel, ManualRecordHotKeyLabel, ManualRecordUploadLabel, ManualRecordSoundAlertLabel, ManualRecordDescription, ManualRecordHotKeyDescription, ManualRecordUploadDescription, ManualRecordSoundAlertDescription, StatusTitleRec, StatusTitlePro, StatusTitleConnected, StatusDescrConnected, StatusTitleDisconnected, StatusDescrDisconnected, StatusTitleNotAuthenticated, StatusDescrNotAuthenticated, StatusTitleNoGuild, StatusDescrNoGuild, StatusTitleNotAuthorized, StatusDescrNotAuthorized, FirstTimeSetupDescription, AutoSelectEncoderTooltip, CloudRefreshGuildTooltip, SourceSnappingSwitchText, SourceSnappingSwitchTooltip, ResetOverlayButtonText, ResetGameButtonText, GameWindowLabel, OverlayLabel, AddSpeakerButtonText, AddMicrophoneButtonText, AddApplicationButtonText, NoAudioSourcesText, SelectADevice, SelectAnApplication, BulkUploadButtonTooltip, BulkDownloadButtonTooltip, BulkUploadDialogText, BulkDownloadDialogText, UploadButtonText, DownloadButtonText, BulkTransferWarningText, ChatTypeMessageText, ChatUploadToCloudText, ChatNoMessagesText, ChatErrorLoadingText, ChatUserText1, ChatUserText2, ChatUserText3, ChatUserText4, ChatForClipsComingSoon, PatreonButtonLabel, SelectDifficulty, SelectResolution, SelectQuality, SelectEncoder, SelectGuild, SelectOptions, SelectLanguage, ChatDeleteMessageTooltip, ValidateLogPathLabel, ValidateLogPathsDescription, StartManualRecordingTooltip, StopManualRecordingTooltip, RecordedAt, AdvancedCombatLoggingDisabledWarning, KillVideoCreatorTooltip, KillVideoCreatorTooltipNotEnoughLocal, KillVideoCreatorTooltipNotEnoughPov, KillVideoCreatorTitle, KillVideoSingleAudioTrackLabel, KillVideoSingleAudioTrackTooltip, KillVideoAudioTrackLabel, KillVideoAudioTrackTooltip, KillVideoCreating, KillVideoDescription, KillVideoRemove, Preparing, } enum Language { ENGLISH = 'English', KOREAN = '한국어', GERMAN = 'German', CHINESE_SIMPLIFIED = '简体中文', } type Translations = { [key in Phrase]: string; }; type LocalizationDataType = { [key in Language]: Translations; }; export { Phrase, Language, Translations, LocalizationDataType }; ================================================ FILE: src/localisation/translations.ts ================================================ import { VideoCategory } from '../types/VideoCategory'; import { Language, LocalizationDataType, Phrase } from './phrases'; import EnglishTranslations from './english'; import KoreanTranslations from './korean'; import GermanTranslations from './german'; import ChineseSimplifiedTranslations from './chineseSimplified'; const data: LocalizationDataType = { [Language.ENGLISH]: EnglishTranslations, [Language.KOREAN]: KoreanTranslations, [Language.GERMAN]: GermanTranslations, [Language.CHINESE_SIMPLIFIED]: ChineseSimplifiedTranslations, }; const getLocalePhrase = (lang: Language, phrase: Phrase) => data[lang][phrase]; const getLocaleCategoryLabel = ( lang: Language, videoCategory: VideoCategory, ) => { switch (videoCategory) { case VideoCategory.TwoVTwo: return getLocalePhrase(lang, Phrase.VideoCategoryTwoVTwoLabel); case VideoCategory.ThreeVThree: return getLocalePhrase(lang, Phrase.VideoCategoryThreeVThreeLabel); case VideoCategory.FiveVFive: return getLocalePhrase(lang, Phrase.VideoCategoryFiveVFiveLabel); case VideoCategory.Skirmish: return getLocalePhrase(lang, Phrase.VideoCategorySkirmishLabel); case VideoCategory.SoloShuffle: return getLocalePhrase(lang, Phrase.VideoCategorySoloShuffleLabel); case VideoCategory.Raids: return getLocalePhrase(lang, Phrase.VideoCategoryRaidsLabel); case VideoCategory.MythicPlus: return getLocalePhrase(lang, Phrase.VideoCategoryMythicPlusLabel); case VideoCategory.Battlegrounds: return getLocalePhrase(lang, Phrase.VideoCategoryBattlegroundsLabel); case VideoCategory.Manual: return getLocalePhrase(lang, Phrase.VideoCategoryManualLabel); case VideoCategory.Clips: return getLocalePhrase(lang, Phrase.VideoCategoryClipsLabel); default: throw new Error('Unrecognized category'); } }; export { getLocalePhrase, getLocaleCategoryLabel, Language }; ================================================ FILE: src/main/AppUpdater.ts ================================================ import { BrowserWindow, ipcMain } from 'electron'; import { autoUpdater } from 'electron-updater'; const customConsoleLogger = { log: (...args: string[]) => console.log('[AutoUpdater]', ...args), // Debug logging is too verbose for production. // debug: (...args: string[]) => console.debug('[AutoUpdater]', ...args), info: (...args: string[]) => console.info('[AutoUpdater]', ...args), warn: (...args: string[]) => console.warn('[AutoUpdater]', ...args), error: (...args: string[]) => console.error('[AutoUpdater]', ...args), }; export default class AppUpdater { constructor(window: BrowserWindow) { autoUpdater.logger = customConsoleLogger; // Don't auto-install on quit. This would force users to update which // isn't friendly. autoUpdater.autoInstallOnAppQuit = false; // If we find a new version on GitHub, inform the frontend. autoUpdater.on('update-downloaded', () => { window.webContents.send('updateAvailable'); }); // If the user accepted the update on the frontend, actually // do it. ipcMain.on('doAppUpdate', () => { console.log('[AutoUpdater] User triggered auto-update'); autoUpdater.quitAndInstall(); }); this.periodicallyCheckUpdate(); } private periodicallyCheckUpdate() { // Check GitHub to see if any new versions are available. autoUpdater.checkForUpdates(); // Schedule the next check. setTimeout( () => { this.periodicallyCheckUpdate(); }, 1000 * 60 * 60 * 24, // Check every 24 hours. ); } } ================================================ FILE: src/main/Combatant.ts ================================================ import { RawCombatant } from './types'; /** * Represents an arena combatant. */ export default class Combatant { private _GUID: string; private _teamID?: number; private _specID?: number; private _name?: string; private _realm?: string; private _region?: string; /** * Constructs a new Combatant. * * @param GUID the GUID of the combatant. * @param teamID the team the combatant belongs to. * @param specID the specID of the combatant */ constructor(GUID: string, teamID?: number, specID?: number) { this._GUID = GUID; if (teamID !== undefined) { this._teamID = teamID; } if (specID !== undefined) { this._specID = specID; } } /** * Gets the GUID. */ get GUID() { return this._GUID; } /** * Sets the specID. */ set GUID(value) { this._GUID = value; } /** * Gets the team ID. */ get teamID() { return this._teamID; } /** * Sets the teamID. */ set teamID(value) { this._teamID = value; } /** * Gets the team ID. */ get specID() { return this._specID; } /** * Sets the specID. */ set specID(value) { this._specID = value; } /** * Gets the name. * @apinote Name is in Name-Realm format */ get name() { return this._name; } /** * Sets the name. * @apinote Name is in Name-Realm format */ set name(value) { this._name = value; } /** * Gets the name. * @apinote Name is in Name-Realm format */ get realm() { return this._realm; } /** * Sets the name. * @apinote Name is in Name-Realm format */ set realm(value) { this._realm = value; } /** * Gets the region. */ get region() { return this._region; } /** * Sets the region. */ set region(value) { this._region = value; } isFullyDefined() { const hasGUID = this.teamID !== undefined; const hasName = this.name !== undefined; const hasRealm = this.realm !== undefined; const hasSpecID = this.specID !== undefined; const hasTeamID = this.teamID !== undefined; // We do not check region here, because it may not exists in Classic / Era clients. return hasGUID && hasName && hasRealm && hasSpecID && hasTeamID; } getRaw(): RawCombatant { const rawCombatant: RawCombatant = { _GUID: this.GUID }; if (this.teamID !== undefined) rawCombatant._teamID = this.teamID; if (this.specID !== undefined) rawCombatant._specID = this.specID; if (this.name !== undefined) rawCombatant._name = this.name; if (this.realm !== undefined) rawCombatant._realm = this.realm; if (this.region !== undefined) rawCombatant._region = this.region; return rawCombatant; } } ================================================ FILE: src/main/Manager.ts ================================================ import fs, { FSWatcher } from 'fs'; import { app, ipcMain, powerMonitor } from 'electron'; import { uIOhook, UiohookKeyboardEvent } from 'uiohook-napi'; import EraLogHandler from '../parsing/EraLogHandler'; import { buildClipMetadata, checkAdvancedCombatLogging, getConfigWtfPath, getMetadataForVideo, getOBSFormattedDate, isManualRecordHotKey, nextKeyPressPromise, nextMousePressPromise, rendererVideoToMetadata, } from './util'; import { VideoCategory } from '../types/VideoCategory'; import Poller from '../utils/Poller'; import ClassicLogHandler from '../parsing/ClassicLogHandler'; import RetailLogHandler from '../parsing/RetailLogHandler'; import Recorder from './Recorder'; import ConfigService from '../config/ConfigService'; import { RecStatus, VideoQueueItem, MicStatus, WowProcessEvent, BaseConfig, ActivityStatus, AdvancedLoggingStatus, KillVideoQueueItem, RendererVideo, KillVideoSegment, } from './types'; import { getObsVideoConfig, getObsAudioConfig, getOverlayConfig, getBaseConfig, validateBaseConfig, } from '../utils/configUtils'; import { ERecordingState, QualityPresets } from './obsEnums'; import { runClassicRecordingTest, runRetailRecordingTest, } from '../utils/testButtonUtils'; import VideoProcessQueue from './VideoProcessQueue'; import LogHandler from 'parsing/LogHandler'; import { PTTKeyPressEvent } from 'types/KeyTypesUIOHook'; import { send } from './main'; import DiskClient from 'storage/DiskClient'; /** * Manager class. */ export default class Manager { /** * Quick references to a bunch of singletons. */ private poller = Poller.getInstance(); private recorder = Recorder.getInstance(); private cfg = ConfigService.getInstance(); /** * Log handlers. */ private retailLogHandler: RetailLogHandler | undefined; private retailPtrLogHandler: RetailLogHandler | undefined; private classicLogHandler: ClassicLogHandler | undefined; private classicPtrLogHandler: ClassicLogHandler | undefined; private eraLogHandler: EraLogHandler | undefined; /** * If the config is valid or not. */ private configValid = false; /** * The config message, typically used to show the user why their config is * invalid. */ private configMessage = ''; /** * If we are in the middle of a reconfigure or not. */ private reconfiguring = false; /** * If the audio settings are open or not. We want the audio devices to * be attached to power the volmeters in the case they are on display, * even if WoW is closed. But we want the audio devices disconnected if * both the settings and WoW are closed to allow Windows to naturally sleep. */ private audioSettingsOpen = false; /** * It's confusing if you try to change the hotkey to something similar and * it starts a recording mid changing it, so set this to true while doing so. */ private manualHotKeyDisabled = false; /** * File watchers for Config.wtf files, used to detect changes to * the advanced combat logging setting. */ private configWtfWatchers: FSWatcher[] = []; /** * Cached advanced logging status per flavour, pushed to the frontend. */ private advancedLoggingStatus: AdvancedLoggingStatus = { retail: true, classic: true, era: true, retailPtr: true, classicPtr: true, }; /** * Constructor. */ constructor() { console.info('[Manager] Creating manager'); this.setupListeners(); this.recorder.on('state-change', () => { setTimeout(() => this.refreshStatus(), 0); }); this.poller .on(WowProcessEvent.STARTED, () => this.onWowStarted()) .on(WowProcessEvent.STOPPED, () => this.onWowStopped()); } /** * Run the startup configuration. Run once, on startup. */ public async startup() { console.info('[Manager] Starting up'); this.reconfiguring = true; let success = false; try { // This can fail. await this.configureBase(true); success = true; } catch (error) { console.error('[Manager] Failed to configure base on startup', error); this.setConfigInvalid(String(error)); } // This stuff should never fail, and doesn't rely on the previous step // succeeding. We still want to apply as much as we can. await this.configureObsVideo(); await this.configureObsAudio(); await this.configureObsOverlay(); if (success) { this.setConfigValid(); this.poller.start(); await this.watchConfigWtfFiles(); await this.checkAdvancedLogging(); } this.reconfiguring = false; } /** * Reconfigure the base settings. This exists because we need the recorder * to be stopped to do this, and because the user can input invalid settings * which we want to catch. * * Be careful how you call this. While JavaScript is single-threaded, async * calls can overlap and cause race conditions. Do not run this concurrently. */ public async reconfigureBase() { console.info('[Manager] Reconfiguring base'); // The recording must be stopped to do this. await this.recorder.forceStop(true); this.reconfiguring = true; this.refreshStatus(); let success = false; try { await this.configureBase(false); success = true; } catch (error) { console.error('[Manager] Failed to configure base on startup', error); this.setConfigInvalid(String(error)); } if (success) { this.setConfigValid(); this.poller.start(); } // We're done, now make sure we refresh the frontend. this.reconfiguring = false; this.refreshStatus(); // ...and for the disk client too. await DiskClient.getInstance().refreshStatus(); await DiskClient.getInstance().refreshVideos(); await this.watchConfigWtfFiles(); await this.checkAdvancedLogging(); } /** * Configure the base config. */ private async configureBase(startup: boolean) { const config = getBaseConfig(this.cfg); await validateBaseConfig(config); await this.applyBaseConfig(config, startup); } /** * Force a recording to stop regardless of the scenario. */ public async forceStop() { if (!LogHandler.activity) { console.info('[Manager] No activity to force end'); return; } console.info('[Manager] Force ending activity'); LogHandler.forceEndActivity(); } /** * Run a test. We prefer retail here, if the user doesn't have a retail path * configured, then fall back to classic. We only pass through the category * for retail, any classic tests will default to 2v2. Probably should fix * that. */ public test(category: VideoCategory, endTest: boolean) { const retail = this.retailLogHandler || this.retailPtrLogHandler; if (retail) { console.info('[Manager] Running retail test'); const parser = retail.combatLogWatcher; runRetailRecordingTest(category, parser, endTest); return; } if (this.classicLogHandler) { console.info('[Manager] Running classic test'); const parser = this.classicLogHandler.combatLogWatcher; runClassicRecordingTest(parser, endTest); } } /** * Set member variables to reflect the config being valid. */ private setConfigValid() { this.configValid = true; this.configMessage = ''; this.refreshStatus(); } /** * Set member variables to reflect the config being invalid. */ private setConfigInvalid(reason: string) { this.configValid = false; this.configMessage = reason; this.refreshStatus(); } /** * Refresh the recorder and mic status icons in the UI. This is the only * place that this should be done from to avoid any status icon confusion. */ public refreshStatus() { if (this.reconfiguring) { this.refreshRecStatus(RecStatus.Reconfiguring); return; } if (!this.configValid) { this.refreshRecStatus( RecStatus.InvalidConfig, String(this.configMessage), ); return; } const inOverrun = LogHandler.overrunning; if (inOverrun) { this.refreshRecStatus(RecStatus.Overrunning); } else if (LogHandler.activity) { const activityStatus: ActivityStatus = { category: LogHandler.activity.category, start: LogHandler.activity.startDate.getTime(), }; this.refreshRecStatus(RecStatus.Recording, '', activityStatus); } else if (this.recorder.obsState === ERecordingState.Recording) { this.refreshRecStatus(RecStatus.ReadyToRecord); } else if (this.recorder.obsState === ERecordingState.None) { this.refreshRecStatus(RecStatus.WaitingForWoW); } this.refreshMicStatus(this.recorder.obsMicState); this.redrawPreview(); } /** * Check Config.wtf for each configured WoW flavour and warn the user * if advanced combat logging is not enabled. */ public async checkAdvancedLogging() { this.advancedLoggingStatus = { retail: !this.cfg.get('recordRetail') || (await checkAdvancedCombatLogging( this.cfg.get('retailLogPath'), )), classic: !this.cfg.get('recordClassic') || (await checkAdvancedCombatLogging( this.cfg.get('classicLogPath'), )), era: !this.cfg.get('recordEra') || (await checkAdvancedCombatLogging(this.cfg.get('eraLogPath'))), retailPtr: !this.cfg.get('recordRetailPtr') || (await checkAdvancedCombatLogging( this.cfg.get('retailPtrLogPath'), )), classicPtr: !this.cfg.get('recordClassicPtr') || (await checkAdvancedCombatLogging( this.cfg.get('classicPtrLogPath'), )), }; this.pushAdvancedLoggingStatus(); } /** * Push the cached advanced logging status to the frontend. */ public pushAdvancedLoggingStatus() { send('updateAdvancedLoggingStatus', this.advancedLoggingStatus); } /** * Watch Config.wtf files for changes so the advanced logging status * updates reactively when the user toggles the setting in WoW. */ private watchConfigWtfFiles() { console.info('Close any existing Config.wtf file watchers'); this.configWtfWatchers.forEach((w) => w.close()); this.configWtfWatchers = []; const logPaths = new Set(); if (this.cfg.get('recordRetail')) logPaths.add(this.cfg.get('retailLogPath')); if (this.cfg.get('recordClassic')) logPaths.add(this.cfg.get('classicLogPath')); if (this.cfg.get('recordEra')) logPaths.add(this.cfg.get('eraLogPath')); if (this.cfg.get('recordRetailPtr')) logPaths.add(this.cfg.get('retailPtrLogPath')); if (this.cfg.get('recordClassicPtr')) logPaths.add(this.cfg.get('classicPtrLogPath')); console.info('Start watching Config.wtf files for', logPaths); for (const logPath of logPaths) { const configPath = getConfigWtfPath(logPath); try { const watcher = fs.watch(configPath, () => { console.info('[Manager] Config.wtf changed:', configPath); this.checkAdvancedLogging(); }); this.configWtfWatchers.push(watcher); } catch (err) { console.warn('[Manager] Failed to watch Config.wtf:', configPath, err); } } } /** * Send a message to the frontend to update the recorder status icon. */ private refreshRecStatus( status: RecStatus, msg = '', activityStatus: ActivityStatus | null = null, ) { send('updateRecStatus', status, msg); send('updateActivityStatus', activityStatus); } /** * Send a message to the frontend to update the mic status icon. */ private refreshMicStatus(status: MicStatus) { send('updateMicStatus', status); } /** * Trigger the frontend to redraw the preview if it's open. */ private redrawPreview() { // Really don't understand the need for the timeout here but it sometimes // gets stale data otherwise. A caching thing in libobs maybe? Noobs will // send a source signal to the recorder if the size of sources change, but // for other changes we need to manually trigger a redraw. setTimeout(() => send('redrawPreview'), 100); } /** * Called when the WoW process is detected, which may be either on launch * of the App if WoW is open, or the user has genuinely opened WoW. Attaches * the audio sources and starts the buffer recording. */ private async onWowStarted() { console.info('[Manager] Detected WoW is running'); this.recorder.attachCaptureSource(); const audioConfig = getObsAudioConfig(this.cfg); this.recorder.configureAudioSources(audioConfig); try { await this.recorder.startBuffer(); } catch (error) { console.error('[Manager] OBS failed to record when WoW started', error); } } /** * Called when the WoW process is detected to have exited. Ends any * recording that is still ongoing. We detach audio sources here to * allow Windows to go to sleep with WCR running. */ private async onWowStopped() { console.info('[Manager] Detected WoW not running'); const inActivity = Boolean(LogHandler.activity); if (inActivity) { console.info('[Manager] Force ending activity'); LogHandler.forceEndActivity(); } else { await this.recorder.forceStop(true); } this.recorder.clearFindWindowInterval(); if (!this.audioSettingsOpen) { // Only remove the audio sources if the audio settings window is not open. // We want to keep them attached to show the volmeter bars if it is. this.recorder.removeAudioSources(); } } /** * Configure the base OBS config. We need to stop the recording to do this. */ private async applyBaseConfig(config: BaseConfig, startup: boolean) { await this.recorder.configureBase(config, startup); LogHandler.activity = undefined; LogHandler.overrunning = false; LogHandler.setStateChangeCallback(() => this.refreshStatus()); if (!startup) { console.info('[Manager] Not startup, so reset log handlers'); const logHandlers = [ this.retailLogHandler, this.retailPtrLogHandler, this.classicLogHandler, this.classicPtrLogHandler, this.eraLogHandler, ]; logHandlers .filter((lh) => lh !== undefined) .forEach((lh) => lh.destroy()); this.retailLogHandler = undefined; this.retailPtrLogHandler = undefined; this.classicLogHandler = undefined; this.classicPtrLogHandler = undefined; this.eraLogHandler = undefined; } if (config.recordRetail) { this.retailLogHandler = new RetailLogHandler(config.retailLogPath); } if (config.recordClassic) { this.classicLogHandler = new ClassicLogHandler(config.classicLogPath); } if (config.recordClassicPtr) { this.classicLogHandler = new ClassicLogHandler(config.classicPtrLogPath); } if (config.recordEra) { this.eraLogHandler = new EraLogHandler(config.eraLogPath); } if (config.recordRetailPtr) { this.retailPtrLogHandler = new RetailLogHandler(config.retailPtrLogPath); this.retailPtrLogHandler.setIsPtr(); } } /** * Configure video settings in OBS. This can all be changed live. */ private configureObsVideo() { const config = getObsVideoConfig(this.cfg); this.recorder.configureVideoSources(config); } /** * Configure audio settings in OBS. This can all be changed live. */ private configureObsAudio() { const isWowRunning = this.poller.isWowRunning(); const shouldConfigure = isWowRunning || this.audioSettingsOpen; if (!shouldConfigure) { console.info("[Manager] Won't configure audio sources, WoW not running"); return; } const config = getObsAudioConfig(this.cfg); this.recorder.configureAudioSources(config); } /** * Configure chat overlay in OBS. This can all be changed live. */ private configureObsOverlay() { const config = getOverlayConfig(this.cfg); this.recorder.configureOverlayImageSource(config); } /** * Setup event listeneres the app relies on. */ private setupListeners() { // Config change listener we use to tweak the app settings in Windows if // the user enables/disables run on start-up. this.cfg.on('change', (key: string, value: unknown) => { if (key === 'startUp') { const isStartUp = value === true; console.info('[Main] OS level set start-up behaviour:', isStartUp); app.setLoginItemSettings({ openAtLogin: isStartUp, }); } }); // Test listener, to enable the test button to start a test. ipcMain.on('test', (_event, args) => { const testCategory = args[0] as VideoCategory; const endTest = Boolean(args[1]); this.test(testCategory, endTest); }); // Clipping listener. ipcMain.on( 'clip', async ( _event, video: RendererVideo, offset: number, duration: number, ) => { console.info( '[Manager] Clip request received with args', video.videoSource, offset, duration, ); const sourceMetadata = rendererVideoToMetadata({ ...video }); const now = new Date(); const clipMetadata = buildClipMetadata(sourceMetadata, duration, now); const clipQueueItem: VideoQueueItem = { name: video.videoName, source: video.videoSource, suffix: `Clipped at ${getOBSFormattedDate(now)}`, offset, duration, clip: true, metadata: clipMetadata, }; VideoProcessQueue.getInstance().queueVideo(clipQueueItem); }, ); ipcMain.on( 'createKillVideo', async ( _event, width: number, height: number, fps: number, segments: KillVideoSegment[], audioTrackIndex: number, ) => { console.info( '[Manager] Creating kill video with settings:', `${width}x${height} at ${fps} fps`, ); if (segments.length < 2) { console.warn('[Manager] Too few videos for kill video'); return; } console.info( '[Manager] Have segments for kill video', segments.map((seg) => ({ videoName: seg.video.videoName, videoSource: seg.video.videoSource, cloud: seg.video.cloud, start: seg.start, stop: seg.stop, })), ); const item: KillVideoQueueItem = { uuid: crypto.randomUUID(), width, height, fps, segments, audioTrackIndex, }; VideoProcessQueue.getInstance().queueCreateKillVideo(item); }, ); // Listens for a manual recording being started via the button. The // hotkey listener is handled separately. ipcMain.on('toggleManualRecording', async () => { if (!this.cfg.get('manualRecord')) { // Manual recording is not enabled. return; } if (!this.poller.isWowRunning()) { console.warn('[Manager] WoW not running when manual hotkey pressed'); return; } if (this.recorder.obsState !== ERecordingState.Recording) { console.warn('[Manager] Recorder not ready when manual hotkey pressed'); return; } LogHandler.handleManualRecordingHotKey(); }); // Handles a click of the force stop button. ipcMain.on('forceStopRecording', async () => { LogHandler.forceEndActivity(); }); // Test listener, to enable the test button to start a test. ipcMain.on('test', (_event, args) => { const testCategory = args[0] as VideoCategory; const endTest = Boolean(args[1]); this.test(testCategory, endTest); }); /** * Get the next key pressed by the user. This can be modifier keys, so if * you want to catch the next non-modifier key you may need to call this * a few times back to back. The event returned includes modifier details. * * Probably should rename the PTTKeyPressEvent, it's generic and not * specific to Push to Talk, it's just like that for historical reasons. */ ipcMain.handle('getNextKeyPress', async (): Promise => { this.manualHotKeyDisabled = true; const event = await Promise.race([ nextKeyPressPromise(), nextMousePressPromise(), ]); this.manualHotKeyDisabled = false; return event; }); /** * Manually start/stop recording. Being careful with the logs here as * some of this is very spammy as it fires on every key press. */ uIOhook.on('keydown', (event: UiohookKeyboardEvent) => { if (this.manualHotKeyDisabled) { // This user is updating their settings. Don't do anything. return; } if (!this.cfg.get('manualRecord')) { // Manual recording is not enabled. return; } if (!isManualRecordHotKey(event)) { // It's not the manual record hotkey. return; } if (!this.poller.isWowRunning()) { console.warn('[Manager] WoW not running when manual hotkey pressed'); return; } if (this.recorder.obsState !== ERecordingState.Recording) { console.warn('[Manager] Recorder not ready when manual hotkey pressed'); return; } LogHandler.handleManualRecordingHotKey(); }); // If Windows is going to sleep, we don't want to confuse OBS. It would be // unusual for someone to sleep windows while WoW is open AND while in an // activity, all we can do is drop the activity and stop the recorder. powerMonitor.on('suspend', async () => { console.info('[Manager] Detected Windows is going to sleep.'); LogHandler.dropActivity(); this.poller.stop(); await this.recorder.forceStop(false); // No timeout on this. }); powerMonitor.on('resume', async () => { console.info('[Manager] Detected Windows waking up from a sleep.'); await this.recorder.forceStop(true); this.poller.start(); }); } } ================================================ FILE: src/main/Recorder.ts ================================================ import path from 'path'; import fs from 'fs'; import WaitQueue from 'wait-queue'; import { UiohookKeyboardEvent, UiohookMouseEvent, uIOhook, EventType, } from 'uiohook-napi'; import { EventEmitter } from 'stream'; import { CaptureMode, EOBSOutputSignal, ERecordingState, ESupportedEncoders, QualityPresets, } from './obsEnums'; import { deferredPromiseHelper, fixPathWhenPackaged, getAssetPath, isPushToTalkHotkey, convertUioHookEvent, tryUnlink, getPromiseBomb, takeOwnershipBufferDir, exists, emitErrorReport, getSortedFiles, } from './util'; import { AudioSource, AudioSourceType, BaseConfig, MicStatus, ObsAudioConfig, ObsOverlayConfig, ObsVideoConfig, VideoSourceName, SceneItem, FileSortDirection, } from './types'; import ConfigService from '../config/ConfigService'; import { obsResolutions } from './constants'; import { getObsAudioConfig, getObsVideoConfig, getOverlayConfig, } from '../utils/configUtils'; import noobs, { ObsData, SceneItemPosition, Signal, SourceDimensions, } from 'noobs'; import { getNativeWindowHandle, send } from './main'; import { ipcMain } from 'electron'; import Poller from 'utils/Poller'; import AsyncQueue from 'utils/AsyncQueue'; import assert from 'assert'; import { isHighRes } from 'renderer/rendererutils'; const devMode = process.env.NODE_ENV === 'development'; /** * Class for handing the interface between Warcraft Recorder and OBS. * * This works by constantly recording a "buffer" whenever WoW is open. If an * interesting event is spotted in the combatlog (e.g. an ENCOUNTER_START * event), the buffer becomes a real recording. * * This ensures we catch the start of activities, the fundamental problem * here being that the combatlog doesn't write in real time, and we might * actually see the ENCOUNTER_START event 20 seconds after it occured in * game. */ export default class Recorder extends EventEmitter { /** * Singleton instance. */ private static instance: Recorder; /** * Singleton instance accessor. */ public static getInstance() { if (!this.instance) this.instance = new this(); return this.instance; } /** * ConfigService instance. */ private cfg = ConfigService.getInstance(); /** * Timer for latching onto a window for either game capture or * window capture. Often this does not appear immediately on * the WoW process starting. */ private findWindowTimer?: NodeJS.Timeout; /** * We wait 5s between each attempt to latch on to game or window * capture sources. */ private findWindowIntervalDuration = 5000; /** * The current number of attempts to find a window to capture. */ private findWindowAttempts = 0; /** * The maximum number of attempts to find a window to capture. */ private findWindowAttemptLimit = 10; /** * Resolution selected by the user in settings. */ private resolution: keyof typeof obsResolutions = this.cfg.get( 'obsOutputResolution', ) as keyof typeof obsResolutions; /** * Active audio sources. */ private audioSources: AudioSource[] = []; /** * Gets toggled if push to talk is enabled and when the hotkey for push to * talk is held down. */ private inputDevicesMuted = false; /** * WaitQueue object for storing signalling from OBS. We only care about * start signals here which indicate the recording has started. */ private startQueue = new WaitQueue(); /** * WaitQueue object for storing signalling from OBS. We only care about * deactivate signals here which indicate the OBS output has deactivated. */ private stopQueue = new WaitQueue(); /** * The state of the recorder, typically used to tell if OBS is recording * or not. */ public obsState = ERecordingState.None; /** * The state of the recorder in regards to input devices, i.e. what are we * doing with the mic currently. */ public obsMicState: MicStatus = MicStatus.NONE; /** * For easy checking if OBS has been initialized. */ public obsInitialized = false; /** * Action queue, used to ensure we do not make concurrent stop/start * requests to OBS. That's complication we can do without. */ private queue = new AsyncQueue(Number.MAX_SAFE_INTEGER); /** * The last file output by OBS. */ public lastFile: string | null = null; /** * Timer that keeps the mic on briefly after you release the Push To Talk key. */ private pttReleaseDelayTimer?: NodeJS.Timeout; /** * Timer to debounce the saving of the game capture position or scale changing. */ private gamePosDebounceTimer?: NodeJS.Timeout; /** * Timer to debounce the saving of the overlay position or scale changing. */ private overlayPosDebounceTimer?: NodeJS.Timeout; private captureMode = CaptureMode.NONE; private captureSource?: string; private overlaySource?: string; private chatOverlayDefaultImage = getAssetPath('poster', 'chat-overlay.png'); // eslint-disable-next-line @typescript-eslint/no-unused-vars private pushToTalkKeyListener = (e: UiohookKeyboardEvent) => {}; // eslint-disable-next-line @typescript-eslint/no-unused-vars private pushToTalkMouseListener = (e: UiohookMouseEvent) => {}; /** * Constructor. */ private constructor() { console.info('[Recorder] Constructing recorder'); super(); this.setupListeners(); } private setupListeners() { ipcMain.on('reconfigureVideo', () => { console.info('[Recorder] Video source reconfigure'); const cfg = getObsVideoConfig(this.cfg); this.configureVideoSources(cfg); }); ipcMain.on('reconfigureAudio', () => { console.info('[Recorder] Audio source reconfigure'); const cfg = getObsAudioConfig(this.cfg); this.configureAudioSources(cfg); }); ipcMain.on('reconfigureOverlay', () => { console.info('[Recorder] Overlay source reconfigure'); const overlayCfg = getOverlayConfig(this.cfg); this.configureOverlayImageSource(overlayCfg); }); /** * Callback to attach the audio devices. This is called when the user * opens the audio settings so that the volmeter bars can be populated. */ ipcMain.handle('audioSettingsOpen', () => { console.info('[Manager] Audio settings were opened'); noobs.SetVolmeterEnabled(true); if (Poller.getInstance().isWowRunning()) { console.info('[Manager] Wont touch audio sources as WoW is running'); return; } const config = getObsAudioConfig(this.cfg); this.configureAudioSources(config); }); ipcMain.handle('audioSettingsClosed', () => { console.info('[Manager] Audio settings were closed'); noobs.SetVolmeterEnabled(false); if (Poller.getInstance().isWowRunning()) { console.info('[Manager] Wont touch audio sources as WoW is running'); return; } this.removeAudioSources(); }); ipcMain.handle('getDisplayInfo', () => { return this.getDisplayInfo(); }); ipcMain.handle('getSourcePosition', (_event, item: SceneItem) => { return this.getSourcePosition(item); }); ipcMain.on( 'setSourcePosition', ( event, item: SceneItem, target: { x: number; y: number; width: number; height: number; cropLeft: number; cropRight: number; cropTop: number; cropBottom: number; }, ) => { const src = item === SceneItem.OVERLAY ? this.overlaySource : this.captureSource; if (!src) return; this.setSourcePosition(src, target); // Don't need to redraw here, frontend handles this for us. }, ); ipcMain.on('resetSourcePosition', (_event, item: SceneItem) => { const src = item === SceneItem.OVERLAY ? this.overlaySource : this.captureSource; if (!src) return; this.resetSourcePosition(src); setTimeout(() => send('redrawPreview'), 100); }); ipcMain.handle( 'createAudioSource', (event, id: string, type: AudioSourceType) => { console.info('[Manager] Creating audio source', id, 'of type', type); const name = noobs.CreateSource(id, type); console.info('[Manager] Created audio source', name); noobs.AddSourceToScene(name); return name; }, ); ipcMain.handle('getAudioSourceProperties', (_event, id: string) => { console.info('[Manager] Getting audio source properties for', id); return noobs.GetSourceProperties(id); }); ipcMain.on('deleteAudioSource', (_event, id: string) => { console.info('[Manager] Deleting audio source', id); noobs.DeleteSource(id); }); ipcMain.on('setAudioSourceDevice', (_event, id: string, value: string) => { console.info( '[Manager] Setting audio device for source', id, 'to', value, ); const settings = noobs.GetSourceSettings(id); settings['device_id'] = value; noobs.SetSourceSettings(id, settings); }); ipcMain.on('setAudioSourceWindow', (_event, id: string, value: string) => { console.info( '[Manager] Setting audio window for source', id, 'to', value, ); const settings = noobs.GetSourceSettings(id); settings['window'] = value; settings['priority'] = 2; // Executable matching noobs.SetSourceSettings(id, settings); }); ipcMain.on('setAudioSourceVolume', (_event, id: string, value: number) => { console.info( '[Manager] Setting audio volume for source', id, 'to', value, ); noobs.SetSourceVolume(id, value); }); ipcMain.on('setForceMono', (_event, enabled: boolean) => { console.info('[Manager] Setting force mono to', enabled); noobs.SetForceMono(enabled); }); ipcMain.on('setAudioSuppression', (_event, enabled: boolean) => { console.info('[Manager] Setting audio suppression to', enabled); noobs.SetAudioSuppression(enabled); }); ipcMain.on( 'configurePreview', (_event, x: number, y: number, width: number, height: number) => { this.configurePreview(x, y, width, height); setTimeout(() => send('redrawPreview'), 100); }, ); ipcMain.on('showPreview', () => { this.showPreview(); }); ipcMain.on('hidePreview', () => { this.hidePreview(); }); ipcMain.on('disablePreview', () => { this.disablePreview(); }); // Encoder listener, to populate settings on the frontend. ipcMain.handle('getEncoders', (): string[] => { const obsEncoders = this.getAvailableEncoders().filter( (encoder) => encoder !== 'none', ); return obsEncoders; }); ipcMain.handle('getSensibleEncoderDefault', (): string => { return this.getSensibleEncoderDefault(); }); } /** * Publicly accessible method to start recording, handles all the gory internals * to make sure we only attempt one recorder action at once, and to handle if OBS * misbehaves. */ public async startBuffer() { console.info('[Recorder] Queued start buffer'); const { resolveHelper, rejectHelper, promise } = deferredPromiseHelper(); const task = async () => { try { await this.startObsBuffer(); resolveHelper(null); } catch (error) { console.error('[Recorder] Error on starting buffer', String(error)); emitErrorReport(error); rejectHelper(error); } }; this.queue.add(task); await promise; } /** * Publicly accessible method to start recording, handles all the gory internals * to make sure we only attempt one recorder action at once, and to handle if OBS * misbehaves. */ public async startRecording(offset: number) { console.info('[Recorder] Queued start recording'); const { resolveHelper, rejectHelper, promise } = deferredPromiseHelper(); const task = async () => { try { await this.convertObsBuffer(offset); resolveHelper(null); } catch (error) { console.error('[Recorder] Error on starting recording', String(error)); emitErrorReport(error); rejectHelper(error); } }; this.queue.add(task); await promise; } /** * Publicly accessible method to stop recording, handles all the gory internals * to make sure we only attempt one recorder action at once, and to handle if OBS * misbehaves. */ public async stop() { console.info('[Recorder] Queued stop'); const { resolveHelper, rejectHelper, promise } = deferredPromiseHelper(); const task = async () => { try { await this.stopObsRecording(); resolveHelper(null); } catch (error) { console.error('[Recorder] Error on stop', String(error)); emitErrorReport(error); rejectHelper(error); } }; this.queue.add(task); await promise; } /** * Publicly accessible method to stop recording, handles all the gory internals * to make sure we only attempt one recorder action at once, and to handle if OBS * misbehaves. */ public async forceStop(timeout: boolean) { console.info('[Recorder] Queued force stop'); const { resolveHelper, rejectHelper, promise } = deferredPromiseHelper(); const task = async () => { try { await this.forceStopOBS(timeout); resolveHelper(null); } catch (error) { console.error('[Recorder] Error on force stop', String(error)); emitErrorReport(error); rejectHelper(error); } }; this.queue.add(task); await promise; } /** * Configures OBS. This does a bunch of things that we need the * user to have setup their config for, which is why it's split out. */ public async configureBase(config: BaseConfig, startup: boolean) { const { obsFPS, obsRecEncoder, obsQuality, obsOutputResolution, obsPath } = config; if (this.obsState !== ERecordingState.None) { console.error('[Recorder] OBS must be offline to reconfigure base'); throw new Error('[Recorder] OBS must be offline to reconfigure base'); } this.resolution = obsOutputResolution as keyof typeof obsResolutions; const { height, width } = obsResolutions[this.resolution]; console.info('[Recorder] Configure OBS video context'); const canvas = noobs.GetPreviewInfo(); noobs.ResetVideoContext(obsFPS, width, height); const { canvasHeight, canvasWidth } = canvas; const changedResolution = canvasHeight !== height || canvasWidth !== width; if (changedResolution && !startup) { // Noobs defaults to 1920x1080, so if at a different resolution, this // will be hit on startup. Changing canvas size causes libobs to auto-scale // the existing sources. So reconfigure the video sources if the resolution // has changed to undo that. We avoid this branch on startup as we will // reconfigure the video sources anyway. console.info('[Recorder] Resolution changed, reconfig video sources'); const cfg = getObsVideoConfig(this.cfg); this.configureVideoSources(cfg); } const outputPath = path.normalize(obsPath); await Recorder.createRecordingDirs(outputPath); await this.cleanup(outputPath); // Record in MKV to avoid file corruption on crashes. MP4 cannot be // recovered in that event but MKV can. We will remux to MP4 for browser // player compatibility in the VideoProcessQueue. console.info('[Recorder] Set recording directory', outputPath); noobs.SetRecordingCfg(outputPath, 'mkv'); // Configure the encoder. It's possible that a user has replaced their // GPU since we last ran, so double check the encoder is still valid. let encoder = obsRecEncoder; if (!this.getAvailableEncoders().includes(obsRecEncoder)) { // If the encoder is not valid, then default to something sensible and // save that in the config as if it were first time setup. console.warn('[Recorder] Encoder not available', obsRecEncoder); encoder = this.getSensibleEncoderDefault(); this.cfg.set('obsRecEncoder', encoder); } const settings = Recorder.getEncoderSettings(encoder, obsQuality); noobs.SetVideoEncoder(encoder, settings); } private static getEncoderSettings(encoder: string, quality: string) { // Specify a 1 sec interval for the I-frames. This allows us to round to // the nearest second later when cutting and always land on a keyframe. // - This is part of the strategy to avoid re-encoding the videos while // enabling a reasonable cutting accuracy. // - We won't ever be off by more than 0.5 sec with this approach, which // I think is an acceptable error. // - Obviously this is a trade off in file size, where the default keyframe // interval appears to be around 4s. const settings: ObsData = { keyint_sec: 1 }; switch (encoder) { case ESupportedEncoders.OBS_X264: // CRF and CPQ are so similar in configuration that we can just treat // the CRF configuration the same as CQP configuration. settings.rate_control = 'CRF'; settings.crf = Recorder.getCqpFromQuality(encoder, quality); break; case ESupportedEncoders.AMD_H264: case ESupportedEncoders.AMD_AV1: case ESupportedEncoders.NVENC_H264: case ESupportedEncoders.NVENC_AV1: case ESupportedEncoders.QSV_H264: case ESupportedEncoders.QSV_AV1: settings.rate_control = 'CQP'; settings.cqp = Recorder.getCqpFromQuality(encoder, quality); break; default: console.error('[Recorder] Unrecognised encoder type', encoder); throw new Error('Unrecognised encoder type'); } return settings; } /** * Configures the video source in OBS. */ public configureVideoSources(config: ObsVideoConfig) { const { obsCaptureMode } = config; this.clearFindWindowInterval(); if (this.captureSource) { console.info( '[Recorder] Removing existing capture source', this.captureSource, ); noobs.RemoveSourceFromScene(this.captureSource); noobs.DeleteSource(this.captureSource); this.captureSource = undefined; this.captureMode = CaptureMode.NONE; } if (obsCaptureMode === 'monitor_capture') { this.configureMonitorCaptureSource(config); } else if (obsCaptureMode === 'game_capture') { this.configureGameCaptureSource(config); } else if (obsCaptureMode === 'window_capture') { this.configureWindowCaptureSource(config); } else { console.error('[Recorder] Unrecognised capture mode', obsCaptureMode); throw new Error('Unrecognised capture mode'); } const wowRunning = Poller.getInstance().isWowRunning(); if (wowRunning && obsCaptureMode !== 'monitor_capture') { this.attachCaptureSource(); } const overlayCfg = getOverlayConfig(this.cfg); this.configureOverlayImageSource(overlayCfg); } /** * Configure and add the chat overlay to the scene. */ private async configureOwnOverlay(config: ObsOverlayConfig) { console.info('[Recorder] Configure own image as chat overlay'); if (!this.overlaySource) { console.error('[Recorder] No existing overlay source'); throw new Error('No existing overlay source'); } const { chatOverlayScale, chatOverlayXPosition, chatOverlayYPosition, chatOverlayOwnImagePath, } = config; const settings = noobs.GetSourceSettings(this.overlaySource); noobs.SetSourceSettings(this.overlaySource, { ...settings, file: chatOverlayOwnImagePath, }); noobs.AddSourceToScene(this.overlaySource); noobs.SetSourcePos(this.overlaySource, { x: chatOverlayXPosition, y: chatOverlayYPosition, scaleX: chatOverlayScale, scaleY: chatOverlayScale, cropLeft: config.chatOverlayCropX, cropRight: config.chatOverlayCropX, cropTop: config.chatOverlayCropY, cropBottom: config.chatOverlayCropY, }); } /** * Configure and add the default chat overlay to the scene. */ private configureDefaultOverlay(config: ObsOverlayConfig) { console.info('[Recorder] Configure default image as chat overlay'); if (!this.overlaySource) { console.error('[Recorder] No existing overlay source'); throw new Error('No existing overlay source'); } const { chatOverlayXPosition, chatOverlayYPosition, chatOverlayScale } = config; const settings = noobs.GetSourceSettings(this.overlaySource); noobs.SetSourceSettings(this.overlaySource, { ...settings, file: this.chatOverlayDefaultImage, }); noobs.AddSourceToScene(this.overlaySource); noobs.SetSourcePos(this.overlaySource, { x: chatOverlayXPosition, y: chatOverlayYPosition, scaleX: chatOverlayScale, scaleY: chatOverlayScale, cropLeft: config.chatOverlayCropX, cropRight: config.chatOverlayCropX, cropTop: config.chatOverlayCropY, cropBottom: config.chatOverlayCropY, }); } /** * Add the configured audio sources to the OBS scene. This is public * so it can be called externally when WoW is opened. */ public configureAudioSources(config: ObsAudioConfig) { this.removeAudioSources(); console.info('[Recorder] Configure audio sources'); // Can't release all the listeners here as we now use // uIOhook for triggering manual recording too. uIOhook.off('keydown', this.pushToTalkKeyListener); uIOhook.off('keyup', this.pushToTalkKeyListener); uIOhook.off('mousedown', this.pushToTalkMouseListener); uIOhook.off('mouseup', this.pushToTalkMouseListener); noobs.SetForceMono(config.obsForceMono); noobs.SetAudioSuppression(config.obsAudioSuppression); config.audioSources.forEach((src) => { console.info('[Recorder] Create audio source', src.id); // OBS may have renamed the source if there was a naming conflict, // log that for posterity. Use that name going forward even if it // doesn't match what we asked for. const name = noobs.CreateSource(src.id, src.type); console.info('[Recorder] Created audio source', name); const settings = noobs.GetSourceSettings(name); if (src.type === AudioSourceType.PROCESS && src.device) { settings['window'] = src.device; settings['priority'] = 2; // Executable matching noobs.SetSourceSettings(name, settings); } else if (src.type !== AudioSourceType.PROCESS) { const properties = noobs.GetSourceProperties(name); const available = properties.find((prop) => prop.name === 'device_id'); assert(available && available.type === 'list'); // To help the compiler out. // Try to match by device ID. let match = available.items.find((d) => d.value === src.device); if (!match) { // Fallback to matching by name if we didn't find an ID match. // Suspect this can happen on replugging devices. console.info('[Recorder] Fallback to matching audio device by name'); match = available.items.find((d) => d.name === src.friendly); if (!match) { // Still no match after looking at both ID and friendly name, // so give up trying to configure this source. console.warn( '[Recorder] Failed to configure audio device', src, available.items, ); return; } // Correct the device ID in the config. console.info( '[Recorder] Fix up audio device ID from', src.device, 'to', match.value, ); src.device = match.value; this.cfg.set('audioSources', config.audioSources); } // Finish configuring the source. settings['device_id'] = match.value; noobs.SetSourceSettings(name, settings); noobs.SetSourceVolume(name, src.volume); } else { // Can happen if a user adds an app source but never selects a window. console.warn('[Recorder] Unable to configure audio source', src); } noobs.AddSourceToScene(name); this.audioSources.push(src); }); const mics = this.audioSources.filter( (src) => src.type === AudioSourceType.INPUT, ); if (mics.length !== 0 && config.pushToTalk) { this.obsMicState = MicStatus.MUTED; this.emit('state-change'); } else if (mics.length !== 0) { this.obsMicState = MicStatus.LISTENING; this.emit('state-change'); } if (config.pushToTalk) { this.inputDevicesMuted = true; this.pushToTalkKeyListener = (e: UiohookKeyboardEvent) => this.pushToTalkHandler(e, config); this.pushToTalkMouseListener = (e: UiohookMouseEvent) => this.pushToTalkHandler(e, config); uIOhook.on('keydown', this.pushToTalkKeyListener); uIOhook.on('keyup', this.pushToTalkKeyListener); uIOhook.on('mousedown', this.pushToTalkMouseListener); uIOhook.on('mouseup', this.pushToTalkMouseListener); } } /** * Remove all audio sources from the OBS scene. This is public * so it can be called externally when WoW is closed. */ public removeAudioSources() { console.info('[Recorder] Remove all audio sources'); this.obsMicState = MicStatus.NONE; this.emit('state-change'); this.audioSources.forEach((src) => { console.info('[Recorder] Remove audio source', src.id); noobs.RemoveSourceFromScene(src.id); noobs.DeleteSource(src.id); }); this.audioSources = []; this.inputDevicesMuted = true; } /** * Cancel the find window interval timer. */ public clearFindWindowInterval() { this.findWindowAttempts = 0; if (this.findWindowTimer) { clearInterval(this.findWindowTimer); this.findWindowTimer = undefined; } } /** * Release all OBS resources and shut it down. */ public shutdownOBS() { console.info('[Recorder] OBS shutting down'); if (!this.obsInitialized) { console.info('[Recorder] OBS not initialized so not attempting shutdown'); return; } noobs.Shutdown(); this.obsInitialized = false; console.info('[Recorder] OBS shut down successfully'); } /** * Return an array of all the encoders available to OBS. */ public getAvailableEncoders(): string[] { console.info('[Recorder] Getting available encoders'); if (!this.obsInitialized) { throw new Error('[Recorder] OBS not initialized'); } const encoders = noobs.ListVideoEncoders(); console.info('[Recorder] Encoders:', encoders); return encoders; } /** * Set the size and position of the scene preview. */ public configurePreview(x: number, y: number, width: number, height: number) { console.info('[Recorder] Configure preview with', x, y, width, height); noobs.ConfigurePreview(x, y, width, height); } /** * Show the scene preview. */ public showPreview() { console.info('[Recorder] Show preview'); noobs.ShowPreview(); } /** * Hide the scene preview. */ public hidePreview() { console.info('[Recorder] Hide preview'); noobs.HidePreview(); } /** * Disable the scene preview. */ public disablePreview() { console.info('[Recorder] Disable preview'); noobs.DisablePreview(); } /** * Clean-up the recording directory. */ public async cleanup(obsPath: string) { console.info('[Recorder] Clean out buffer'); // We now record in MKV but convert to MP4 during processing. So we're really // cleaning out MKVs here but also may as well make sure we get any stray MP4s // that might be hanging around from legacy versions. const videos = await getSortedFiles( obsPath, '.*\\.(mp4|mkv)', FileSortDirection.NewestFirst, // This sorting is redundant in this context. ); const files = videos.map((f) => f.name); const promises = files.map(tryUnlink); await Promise.all(promises); } /** * Start OBS, no-op if already started. */ private async startObsBuffer() { console.info('[Recorder] Start'); if (!this.obsInitialized) { console.error('[Recorder] OBS not initialized'); throw new Error('OBS not initialized'); } if (this.obsState === ERecordingState.Recording) { console.info('[Recorder] Already started'); return; } this.startQueue.empty(); noobs.StartBuffer(); await Promise.race([ this.startQueue.shift(), getPromiseBomb(30, 'Failed to start'), ]); this.startQueue.empty(); } /** * Conver the buffer recording to a a real recording. */ private async convertObsBuffer(offset: number) { console.info('[Recorder] Convert buffer with offset:', offset); if (!this.obsInitialized) { console.error('[Recorder] OBS not initialized'); throw new Error('OBS not initialized'); } if (this.obsState !== ERecordingState.Recording) { console.error('[Recorder] Buffer not started'); throw new Error('Buffer not started'); } // The native code expects an integer. const rounded = Math.round(offset); noobs.StartRecording(rounded); } /** * Stop OBS, no-op if already stopped. If stopping fails, will attempt * recovery by force stopping. Will set lastFile if successful. */ private async stopObsRecording() { console.info('[Recorder] Stop recording'); if (!this.obsInitialized) { console.error('[Recorder] OBS not initialized'); throw new Error('OBS not initialized'); } if (this.obsState === ERecordingState.None) { console.info('[Recorder] Already stopped'); return; } this.stopQueue.empty(); noobs.StopRecording(); const wrote = this.stopQueue.shift(); try { await Promise.race([wrote, getPromiseBomb(60, 'Failed to stop')]); console.info('[Recorder] Stopped successfully'); } catch (error) { console.error('[Recorder]', error, 'will force stop.'); emitErrorReport( 'Failed to stop OBS cleanly. This may lead to miscut videos and is typically a symptom of encoder overload.', ); noobs.ForceStopRecording(); await Promise.race([ wrote, getPromiseBomb(3, 'Failed to recover by force stopping'), ]); console.info('[Recorder] Force stopped successfully'); } // Now that we record in MKV we can still attempt to save // a recording here even if we failed to stop cleanly. this.lastFile = noobs.GetLastRecording(); } /** * Force stop OBS, no-op if already stopped. Optionally pass in a wrote * promise to await instead of shifting from the queue ourselves. That's * useful in the case we've failed to stop and are now force stopping. */ private async forceStopOBS(timeout: boolean) { console.info('[Recorder] Force stop'); if (!this.obsInitialized) { console.error('[Recorder] OBS not initialized'); throw new Error('OBS not initialized'); } if (this.obsState === ERecordingState.None) { console.info('[Recorder] Already stopped'); return; } this.stopQueue.empty(); noobs.ForceStopRecording(); const wrote = this.stopQueue.shift(); if (timeout) { // In the normal case we expect to be done within a short timeout, // so enforce that here. const bomb = getPromiseBomb(3, 'Failed to force stop'); await Promise.race([wrote, bomb]); } else { // We allow this to be called without a timeout to enable waiting // indefinitely on Windows sleeping. Often the deactivate signal // is not received until Windows wakes, which could be an arbitrary // amount of time later. This isn't perfect as we could in theory // get stuck here forever, but it's hopefully good enough. await wrote; } this.lastFile = null; } /** * Create the obsPath directory if it doesn't already exist. Also * cleans it out for good measure. */ private static async createRecordingDirs(obsPath: string) { const dirExists = await exists(obsPath); if (!dirExists) { console.info('[Recorder] Creating dir:', obsPath); await fs.promises.mkdir(obsPath); await takeOwnershipBufferDir(obsPath); } } /** * Initialize OBS, should be called once only. */ public initializeObs() { console.info('[Recorder] Initializing OBS'); const cb = this.handleSignal.bind(this); let logPath = devMode ? path.resolve(__dirname, './logs') : path.resolve(__dirname, '../../dist/main/logs'); let noobsPath = devMode ? path.resolve(__dirname, '../../release/app/node_modules/noobs/dist') : path.resolve(__dirname, '../../node_modules/noobs/dist'); logPath = fixPathWhenPackaged(logPath); noobsPath = fixPathWhenPackaged(noobsPath); console.info('[Recorder] Noobs path:', noobsPath); console.info('[Recorder] Log path:', logPath); noobs.Init(noobsPath, logPath, cb); noobs.SetBuffering(true); const hwnd = getNativeWindowHandle(); noobs.InitPreview(hwnd); noobs.SetDrawSourceOutline(true); this.overlaySource = noobs.CreateSource( VideoSourceName.OVERLAY, 'image_source', ); this.obsInitialized = true; console.info('[Recorder] OBS initialized successfully'); } /** * Handle a signal from OBS. */ private handleSignal(signal: Signal) { if (signal.type === 'volmeter' && signal.value !== undefined) { // A volmeter callback was fired. This happens very often while there // are audio sources attached and the audio settings are open. send('volmeter', signal.id, signal.value); return; } // The rest of the signals here are not as frequent as volmeter signals // so just log them all. console.info('[Recorder] Got signal', signal); if (signal.type === 'source') { // A source has sporadically changed dimensions. This typically happens // when a game or window capture source is initialized or resized. To be // clear this is the dimensions NOT the scale. Users cannot trigger this. send('redrawPreview'); send('initCropSliders'); return; } switch (signal.id) { case EOBSOutputSignal.Start: this.startQueue.push(signal); this.obsState = ERecordingState.Recording; this.emit('state-change'); console.info('[Recorder] State is now:', this.obsState); break; case EOBSOutputSignal.Deactivate: this.stopQueue.push(signal); this.obsState = ERecordingState.None; this.emit('state-change'); console.info('[Recorder] State is now:', this.obsState); break; default: console.info('[Recorder] No action needed on this signal'); break; } } /** * Creates a window capture source. In TWW, the retail and classic Window names * diverged slightly, so while this was previously a hardcoded string, now we * search for it in the OSN sources API. */ private configureWindowCaptureSource(config: ObsVideoConfig) { console.info('[Recorder] Configuring OBS for Window Capture'); const { forceSdr, captureCursor, videoSourceXPosition, videoSourceYPosition, videoSourceScale, } = config; this.captureMode = CaptureMode.WINDOW; this.captureSource = noobs.CreateSource( VideoSourceName.WINDOW, 'window_capture', ); const settings = noobs.GetSourceSettings(this.captureSource); noobs.SetSourceSettings(this.captureSource, { ...settings, capture_mode: 'window', force_sdr: forceSdr, cursor: captureCursor, // For some reason is named differently here. method: 2, compatibility: true, }); noobs.AddSourceToScene(this.captureSource); noobs.SetSourcePos(this.captureSource, { x: videoSourceXPosition, y: videoSourceYPosition, scaleX: videoSourceScale, scaleY: videoSourceScale, cropLeft: 0, cropRight: 0, cropTop: 0, cropBottom: 0, }); } /** * Configures the game capture source. */ private configureGameCaptureSource(config: ObsVideoConfig) { console.info('[Recorder] Configuring OBS for Game Capture'); const { forceSdr, captureCursor, videoSourceXPosition, videoSourceYPosition, videoSourceScale, } = config; this.captureMode = CaptureMode.GAME; this.captureSource = noobs.CreateSource( VideoSourceName.GAME, 'game_capture', ); const defaults = noobs.GetSourceSettings(this.captureSource); const settings = { ...defaults, capture_mode: 'window', force_sdr: forceSdr, capture_cursor: captureCursor, priority: 2, }; const position = { x: videoSourceXPosition, y: videoSourceYPosition, scaleX: videoSourceScale, scaleY: videoSourceScale, cropLeft: 0, cropRight: 0, cropTop: 0, cropBottom: 0, }; noobs.SetSourceSettings(this.captureSource, settings); noobs.AddSourceToScene(this.captureSource); noobs.SetSourcePos(this.captureSource, position); } /** * Creates a monitor capture source. */ private configureMonitorCaptureSource(config: ObsVideoConfig) { console.info('[Recorder] Configuring OBS for Monitor Capture'); const { forceSdr, monitorIndex, videoSourceXPosition, videoSourceYPosition, videoSourceScale, captureCursor, } = config; this.captureMode = CaptureMode.MONITOR; this.captureSource = noobs.CreateSource( VideoSourceName.MONITOR, 'monitor_capture', ); const defaults = noobs.GetSourceSettings(this.captureSource); const properties = noobs.GetSourceProperties(this.captureSource); const monitors = properties.find((p) => p.name === 'monitor_id'); if (!monitors) { console.error('[Recorder] No monitors found'); throw new Error('[Recorder] No monitors found'); } if (monitors.type !== 'list') { console.error('[Recorder] Window setting is not a list'); throw new Error('Window setting is not a list'); } const opts = monitors.items.filter((item) => item.value !== 'DUMMY'); let monitorId = opts[monitorIndex]; if (!monitorId) { console.warn( '[Recorder] Monitor with index was not found for index', monitorIndex, opts, 'will default to first monitor', ); monitorId = opts[0]; if (!monitorId) { // Somehow still no monitor so all we can do is error. console.error('[Recorder] No valid monitors found', opts); throw new Error('[Recorder] No valid monitors'); } } console.info('[Recorder] Selected monitor:', monitorId); const settings = { ...defaults, method: 0, monitor_id: monitorId.value, force_sdr: forceSdr, capture_cursor: captureCursor, }; const position: SceneItemPosition = { x: videoSourceXPosition, y: videoSourceYPosition, scaleX: videoSourceScale, scaleY: videoSourceScale, cropLeft: 0, cropRight: 0, cropTop: 0, cropBottom: 0, }; noobs.SetSourceSettings(this.captureSource, settings); noobs.AddSourceToScene(this.captureSource); noobs.SetSourcePos(this.captureSource, position); } /** * Configure the chat overlay image source. */ public async configureOverlayImageSource(config: ObsOverlayConfig) { const { chatOverlayEnabled } = config; console.info('[Recorder] Configure image source for chat overlay'); if (this.overlaySource) { // Might be a no-op, we never actually delete this source. noobs.RemoveSourceFromScene(this.overlaySource); } if (!chatOverlayEnabled) { console.info('[Recorder] Chat overlay is disabled, not configuring'); return; } const useDefaultOverlay = await this.useDefaultOverlayImage(config); if (useDefaultOverlay) { console.info('[Recorder] Using default overlay'); this.configureDefaultOverlay(config); } else { console.info('[Recorder] Using custom overlay'); this.configureOwnOverlay(config); } } /** * Mute the mic audio sources. */ private muteInputDevices() { if (this.inputDevicesMuted) { return; } noobs.SetMuteAudioInputs(true); this.inputDevicesMuted = true; this.obsMicState = MicStatus.MUTED; this.emit('state-change'); } /** * Unmute the mic audio sources. */ private unmuteInputDevices() { if (!this.inputDevicesMuted) { return; } noobs.SetMuteAudioInputs(false); this.inputDevicesMuted = false; this.obsMicState = MicStatus.LISTENING; this.emit('state-change'); } private pushToTalkHandler( event: UiohookKeyboardEvent | UiohookMouseEvent, audioConfig: ObsAudioConfig, ) { const converted = convertUioHookEvent(event); const isKeybindMatch = isPushToTalkHotkey(audioConfig, converted); if (!isKeybindMatch) { return; } const isPress = event.type === EventType.EVENT_KEY_PRESSED || event.type === EventType.EVENT_MOUSE_PRESSED; if (isPress) { if (this.pttReleaseDelayTimer) { clearTimeout(this.pttReleaseDelayTimer); this.pttReleaseDelayTimer = undefined; } this.unmuteInputDevices(); return; } const delay = this.cfg.get('pushToTalkReleaseDelay'); this.pttReleaseDelayTimer = setTimeout(() => { this.muteInputDevices(); this.pttReleaseDelayTimer = undefined; }, delay); } /** * Convert the quality setting to an appropriate CQP/CRF value based on encoder type. */ private static getCqpFromQuality(encoder: string, quality: string) { if ( encoder === ESupportedEncoders.NVENC_AV1 || encoder === ESupportedEncoders.AMD_AV1 ) { // AV1 typically needs lower CQP values for similar quality switch (quality) { case QualityPresets.ULTRA: return 20; case QualityPresets.HIGH: return 24; case QualityPresets.MODERATE: return 28; case QualityPresets.LOW: return 32; default: console.error('[Recorder] Unrecognised quality', quality); throw new Error('Unrecognised quality'); } } // Original values for x264 CRF and other encoders' CQP switch (quality) { case QualityPresets.ULTRA: return 22; case QualityPresets.HIGH: return 26; case QualityPresets.MODERATE: return 30; case QualityPresets.LOW: return 34; default: console.error('[Recorder] Unrecognised quality', quality); throw new Error('Unrecognised quality'); } } /** * Check if the name of the window matches one of the known WoW window names. */ private static windowMatch(item: { name: string; value: string | number }) { return ( item.name.startsWith('[Wow.exe]: ') || item.name.startsWith('[WowT.exe]: ') || item.name.startsWith('[WowB.exe]: ') || item.name.startsWith('[WowClassic.exe]: ') || item.name.startsWith('[WowClassicT.exe]: ') ); } /** * Attach the current game_capture or window_capture source to the WoW client. */ public attachCaptureSource() { console.info('[Recorder] Attaching capture source', this.captureSource); if ( this.captureMode !== CaptureMode.WINDOW && this.captureMode !== CaptureMode.GAME ) { console.info('[Recorder] Nothing to attach for', this.captureMode); return; } if (!this.captureSource) { // This should never happen. console.error('[Recorder] No capture source available'); return; } const properties = noobs.GetSourceProperties(this.captureSource); const windows = properties.find((item) => item.name === 'window'); if (!windows) { console.error('[Recorder] Failed to find window setting'); throw new Error('Failed to find window setting'); } if (windows.type !== 'list') { console.error('[Recorder] Window setting is not a list'); throw new Error('Window setting is not a list'); } const opts = windows.items; const match = opts.find(Recorder.windowMatch); if (match) { console.info('[Recorder] Found matching window for game capture:', match); const settings = noobs.GetSourceSettings(this.captureSource); const updated = { ...settings, window: match.value }; noobs.SetSourceSettings(this.captureSource, updated); return; } if (this.findWindowAttempts < this.findWindowAttemptLimit) { console.info('[Recorder] No matching window yet'); this.findWindowAttempts++; this.findWindowTimer = setTimeout( () => this.attachCaptureSource(), this.findWindowIntervalDuration, ); return; } console.warn( '[Recorder] Failed to find WoW window after', this.findWindowAttempts, 'attempts. Giving up.', ); } /** * Get the current dimensions of the display preview. This includes the * base canvas size (unscaled) and the current display size (scaled). */ public getDisplayInfo(): { canvasWidth: number; canvasHeight: number; previewWidth: number; previewHeight: number; } { return noobs.GetPreviewInfo(); } /** * Get the current position and dimensions of a source. Width and height are * before scaling. This is the real size on the canvas, not the size in the * preview. */ public getSourcePosition(item: SceneItem) { const previewInfo = this.getDisplayInfo(); // Could be cached const sfx = previewInfo.previewWidth / previewInfo.canvasWidth; const sfy = previewInfo.previewHeight / previewInfo.canvasHeight; const sf = Math.min(sfx, sfy); const src = item === SceneItem.OVERLAY ? this.overlaySource : this.captureSource; if (!src) { console.warn( '[Recorder] getSourcePosition: No source found for item:', item, ); return; } const current = noobs.GetSourcePos(src); const position: SceneItemPosition & SourceDimensions = { x: current.x * sf, y: current.y * sf, scaleX: current.scaleX, scaleY: current.scaleY, width: current.width * sf * current.scaleX, height: current.height * sf * current.scaleY, cropLeft: current.cropLeft * sf * current.scaleX, cropRight: current.cropRight * sf * current.scaleX, cropTop: current.cropTop * sf * current.scaleY, cropBottom: current.cropBottom * sf * current.scaleY, }; return position; } /** * Sets the position of a source in the OBS scene. */ public setSourcePosition( src: string, target: { x: number; y: number; width: number; height: number; cropLeft: number; cropRight: number; cropTop: number; cropBottom: number; }, ) { const previewInfo = noobs.GetPreviewInfo(); // Could be cached? const current = noobs.GetSourcePos(src); // This is confusing because there are two forms of scaling at play // that we need to account for. // 1. The source scaling. The current.width might be 1000px but // if it's scaled by 0.5 the real width is 500px. // 2. The preview scaling. The preview is reduced to fit the div // based on the aspect ratio. const sfx = previewInfo.previewWidth / previewInfo.canvasWidth; const sfy = previewInfo.previewHeight / previewInfo.canvasHeight; const sf = Math.min(sfx, sfy); // We only allow one scale factor to retail the aspect ratio of // the source so just use the X. const scaledWidth = current.width * current.scaleX * sf; const ratioX = target.width / scaledWidth; const scale = ratioX * current.scaleX; const updated: SceneItemPosition = { x: target.x / sf, y: target.y / sf, scaleX: scale, scaleY: scale, cropLeft: target.cropLeft / (sf * scale), cropRight: target.cropRight / (sf * scale), cropTop: target.cropTop / (sf * scale), cropBottom: target.cropBottom / (sf * scale), }; noobs.SetSourcePos(src, updated); const item = src.startsWith('WCR Chat Overlay') ? SceneItem.OVERLAY : SceneItem.GAME; const timer = item === SceneItem.OVERLAY ? this.overlayPosDebounceTimer : this.gamePosDebounceTimer; if (timer) { clearTimeout(timer); } if (item === SceneItem.OVERLAY) { this.overlayPosDebounceTimer = setTimeout(() => { this.saveSourcePosition( item, updated.x, updated.y, scale, updated.cropLeft, updated.cropTop, ); this.overlayPosDebounceTimer = undefined; }, 1000); } else if (item === SceneItem.GAME) { this.gamePosDebounceTimer = setTimeout(() => { this.saveSourcePosition(item, updated.x, updated.y, scale); this.gamePosDebounceTimer = undefined; }, 1000); } } /** * Reset the source position to 0, 0 and unscaled. */ public resetSourcePosition(src: string) { console.info('[Recorder] Reset source position', src); const updated: SceneItemPosition = { x: 0, y: 0, scaleX: 1, scaleY: 1, cropLeft: 0, cropTop: 0, cropRight: 0, cropBottom: 0, }; const item = src.startsWith('WCR Chat Overlay') ? SceneItem.OVERLAY : SceneItem.GAME; if (item === SceneItem.GAME) { console.info('[Recorder] Resetting game source so fit to window'); const { height, width } = noobs.GetSourcePos(src); const { canvasHeight, canvasWidth } = noobs.GetPreviewInfo(); const scaleX = canvasWidth / width; const scaleY = canvasHeight / height; if (scaleX < scaleY) { // X-limited, center vertically. updated.scaleX = scaleX; updated.scaleY = scaleX; updated.y = (canvasHeight - height * scaleX) / 2; } else { // Y-limited, center horizontally. updated.x = (canvasWidth - width * scaleY) / 2; updated.scaleX = scaleY; updated.scaleY = scaleY; } } // scaleX and scaleY are the same by this point as we maintain aspect ratio. noobs.SetSourcePos(src, updated); this.saveSourcePosition(item, updated.x, updated.y, updated.scaleX); } /** * Save a video source position in the config. */ private saveSourcePosition( item: SceneItem, x: number, y: number, scale: number, cropX: number = 0, cropY: number = 0, ) { console.info('[Recorder] Saving', item, 'position', { x, y, scale, cropX, cropY, }); if (item === SceneItem.OVERLAY) { this.cfg.set('chatOverlayXPosition', x); this.cfg.set('chatOverlayYPosition', y); this.cfg.set('chatOverlayScale', scale); this.cfg.set('chatOverlayCropX', cropX); this.cfg.set('chatOverlayCropY', cropY); } else { this.cfg.set('videoSourceXPosition', x); this.cfg.set('videoSourceYPosition', y); this.cfg.set('videoSourceScale', scale); } } /** * Choose a sensible default encoder from those available. Doesn't choose AV1 * variants, those are considered advanced and not a sensible default. They * need hardware rendering of the app to be enabled. */ public getSensibleEncoderDefault() { const encoders = this.getAvailableEncoders(); const highRes = isHighRes(this.resolution); if (highRes) { // Just go for the software encoder if high res. return ESupportedEncoders.OBS_X264; } if (encoders.includes(ESupportedEncoders.NVENC_H264)) { return ESupportedEncoders.NVENC_H264; } if (encoders.includes(ESupportedEncoders.QSV_H264)) { return ESupportedEncoders.QSV_H264; } if (encoders.includes(ESupportedEncoders.AMD_H264)) { // Deliberatly after other hardware encoders as sometimes the // AMD iGPU can provide this and it's not usable. return ESupportedEncoders.AMD_H264; } return ESupportedEncoders.OBS_X264; } /** * Decide if we can should use the default overlay image or the custom one. */ private async useDefaultOverlayImage(config: ObsOverlayConfig) { const { chatOverlayOwnImage, chatOverlayOwnImagePath } = config; if (!chatOverlayOwnImage) { console.info('[Recorder] Configured to use default overlay'); return true; } if (!chatOverlayOwnImagePath) { console.warn('[Recorder] No custom image path set'); return true; } const fileExists = await exists(chatOverlayOwnImagePath); if (!fileExists) { console.warn(`[Recorder] File does not exist`, chatOverlayOwnImagePath); return true; } return false; } /** * Get the last recorded file and clear it so it won't be returned again. */ public getAndClearLastFile() { console.info('[Recorder] Get and clear last file', this.lastFile); const last = this.lastFile; this.lastFile = null; return last; } } ================================================ FILE: src/main/VideoProcessQueue.ts ================================================ import path from 'path'; import { getBaseConfig, shouldUpload } from '../utils/configUtils'; import DiskSizeMonitor from '../storage/DiskSizeMonitor'; import ConfigService from '../config/ConfigService'; import { CloudMetadata, DiskStatus, KillVideoQueueItem, KillVideoStatus, RendererVideo, SaveStatus, UploadQueueItem, VideoQueueItem, } from './types'; import { writeMetadataFile, getMetadataForVideo, rendererVideoToMetadata, getFileInfo, fixPathWhenPackaged, logAxiosError, tryUnlink, buildKillVideoMetadata, getOBSFormattedDate, } from './util'; import CloudClient from '../storage/CloudClient'; import { send } from './main'; import ffmpeg from 'fluent-ffmpeg'; import axios from 'axios'; import DiskClient from 'storage/DiskClient'; import Recorder from './Recorder'; const atomicQueue = require('atomic-queue'); const devMode = process.env.NODE_ENV === 'development'; const isDebug = devMode || process.env.DEBUG_PROD === 'true'; // Use the dynamically linked ffmpeg.exe we package with OBS in noobs. This // allows us to avoid including a static ffmpeg.exe which is an extra 60MB. const ffmpegPathRel = 'node_modules/noobs/dist/bin/ffmpeg.exe'; let ffmpegPathAbs = devMode ? path.resolve(__dirname, '../../release/app/', ffmpegPathRel) : path.resolve(__dirname, '../../', ffmpegPathRel); ffmpegPathAbs = fixPathWhenPackaged(ffmpegPathAbs); ffmpeg.setFfmpegPath(ffmpegPathAbs); /** * A queue for cutting videos to size. */ export default class VideoProcessQueue { /** * Singleton instance. */ private static instance: VideoProcessQueue; /** * Singleton instance accessor. */ public static getInstance() { if (!this.instance) this.instance = new this(); return this.instance; } /** * Atomic queue object for queueing cutting of videos. */ private videoQueue: any; /** * Atomic queue object for queuing upload of videos, seperated from the * video queue as this can take a long time and we don't want to block further * video cuts behind uploads. */ private uploadQueue: any; /** * Atomic queue object for queuing download of videos. */ private downloadQueue: any; /** * The kill video queue re-encoder a video from multiple perspectives into a * single video file. This is naturally computationally expensive. */ private killVideoQueue: any; /** * Config service handle. */ private cfg = ConfigService.getInstance(); /** * List of video file paths currently in the upload queue, or in * progress. Used to block subsequent attempts to queue the same * operation. */ private inProgressUploads: string[] = []; /** * List of video names currently in the download queue, or in * progress. Used to block subsequent attempts to queeue the same * operation. */ private inProgressDownloads: string[] = []; /** * List of kill video job uuids currently in in the queue for * processing, or in progress currently. */ private inProgressKillVideos: string[] = []; /** * Constructor. */ private constructor() { this.videoQueue = this.createVideoQueue(); this.uploadQueue = this.createUploadQueue(); this.downloadQueue = this.createDownloadQueue(); this.killVideoQueue = this.createKillVideoQueue(); } private createVideoQueue() { const worker = this.processVideoQueueItem.bind(this); const settings = { concurrency: 1 }; const queue = atomicQueue(worker, settings); /* eslint-disable prettier/prettier */ queue .on('error', VideoProcessQueue.errorProcessingVideo) .on('idle', () => {this.videoQueueEmpty()}); queue.pool .on('start', (data: VideoQueueItem) => { this.startedProcessingVideo(data) }) .on('finish', (_: unknown, data: VideoQueueItem) => { this.finishProcessingVideo(data) }); /* eslint-enable prettier/prettier */ return queue; } private createUploadQueue() { const worker = this.processUploadQueueItem.bind(this); const settings = { concurrency: 1 }; const queue = atomicQueue(worker, settings); /* eslint-disable prettier/prettier */ queue .on('error', VideoProcessQueue.errorUploadingVideo) .on('idle', () => { this.uploadQueueEmpty() }); queue.pool .on('start', (item: UploadQueueItem) => { this.startedUploadingVideo(item) }) .on('finish', async (_: unknown, item: UploadQueueItem) => { await this.finishUploadingVideo(item) }); /* eslint-enable prettier/prettier */ return queue; } private createDownloadQueue() { const worker = this.processDownloadQueueItem.bind(this); const settings = { concurrency: 1 }; const queue = atomicQueue(worker, settings); /* eslint-disable prettier/prettier */ queue .on('error', VideoProcessQueue.errorDownloadingVideo) .on('idle', () => { this.downloadQueueEmpty() }); queue.pool .on('start', (video: RendererVideo) => { this.startedDownloadingVideo(video) }) .on('finish', async (_: unknown, video: RendererVideo) => { await this.finishDownloadingVideo(video) }); /* eslint-enable prettier/prettier */ return queue; } private createKillVideoQueue() { const worker = this.processKillVideoQueueItem.bind(this); const settings = { concurrency: 1 }; const queue = atomicQueue(worker, settings); /* eslint-disable prettier/prettier */ queue .on('error', VideoProcessQueue.errorKillVideo) .on('idle', () => { this.videoQueueEmpty() }); queue.pool .on('start', (item: KillVideoQueueItem) => { this.startedProcessingKillVideo(item) }) .on('finish', (_: unknown, item: KillVideoQueueItem) => { this.finishProcessingKillVideo(item) }); /* eslint-enable prettier/prettier */ return queue; } /** * Add a video to the queue for processing, the processing it undergoes is * dictated by the input. This is the only public method on this class. */ public queueVideo = async (item: VideoQueueItem) => { console.info('[VideoProcessQueue] Queuing video for processing', item); this.videoQueue.write(item); }; /** * Queue a video for upload. */ public queueUpload = async (item: UploadQueueItem) => { const alreadyQueued = this.inProgressUploads.includes(item.path); if (alreadyQueued) { console.warn('[VideoProcessQueue] Upload already queued', item.path); return; } console.log('[VideoProcessQueue] Queuing video for upload', item.path); this.inProgressUploads.push(item.path); this.uploadQueue.write(item); const queued = Math.max(0, this.inProgressUploads.length); send('updateUploadQueueLength', queued); }; /** * Queue a video for download. */ public queueDownload = async (video: RendererVideo) => { const { videoName } = video; const alreadyQueued = this.inProgressDownloads.includes(video.videoName); if (alreadyQueued) { console.warn('[VideoProcessQueue] Download already queued', videoName); return; } console.log('[VideoProcessQueue] Queuing video for download', videoName); this.inProgressDownloads.push(videoName); this.downloadQueue.write(video); const queued = Math.max(0, this.inProgressDownloads.length); send('updateDownloadQueueLength', queued); }; /** * Queue up a kill video for creation. */ public queueCreateKillVideo = async (item: KillVideoQueueItem) => { console.log('[VideoProcessQueue] Queue kill video for processing'); this.inProgressKillVideos.push(item.uuid); this.killVideoQueue.write(item); }; /** * Process a video by cutting it to size and saving it to disk, also * writes out the metadata JSON file. */ private async processVideoQueueItem( data: VideoQueueItem, done: () => void, ): Promise { try { const outputDir = this.cfg.get('storagePath'); // In a lot of cases this is basically just a copy. But this also // covers the cases where we're cutting a section off the end of // the video due to a timeout. const videoPath = await this.cutVideo(data, outputDir); await writeMetadataFile(videoPath, data.metadata); const readyToUpload = await CloudClient.getInstance().ready(); const upload = readyToUpload && shouldUpload(this.cfg, data.metadata); if (upload) { const item: UploadQueueItem = { path: videoPath }; this.queueUpload(item); } } catch (error) { console.error( '[VideoProcessQueue] Error processing video:', String(error), ); } done(); } /** * Upload a video to the cloud store. */ private async processUploadQueueItem( item: UploadQueueItem, done: () => void, ): Promise { let lastProgress = 0; // Decide if we need to use a rate limit or not. Setting to -1 is unlimited. const rateLimit = this.cfg.get('cloudUploadRateLimit') ? this.cfg.get('cloudUploadRateLimitMbps') : -1; const progressCallback = (progress: number) => { if (progress === lastProgress) { return; } send('updateUploadProgress', progress); lastProgress = progress; }; const client = CloudClient.getInstance(); try { // Upload the video first, this can take a bit of time, and don't want // to confuse the frontend by having metadata without video. await client.putFile(item.path, rateLimit, progressCallback); progressCallback(100); // Now add the metadata. const metadata = await getMetadataForVideo(item.path); const cloudMetadata: CloudMetadata = { ...metadata, start: metadata.start || 0, uniqueHash: metadata.uniqueHash || '', videoName: path.basename(item.path, '.mp4'), videoKey: path.basename(item.path), }; if (cloudMetadata.level) { // The string "level" isn't a valid SQL column name, in new videos we // use the keystoneLevel entry in the metadata, but if we're uploading // an old video correct it here at the point of upload. cloudMetadata.keystoneLevel = cloudMetadata.level; delete cloudMetadata.level; } if (cloudMetadata.start === 0) { // Another "old videos don't have..." bug, this time for the start // parameter, which causes dates to be wrong in the UI. Grab the date // from the video file on disk. const stats = await getFileInfo(item.path); cloudMetadata.start = stats.mtime; } await client.postVideo(cloudMetadata); } catch (error) { if (axios.isAxiosError(error)) { const msg = '[CloudClient] Axios error processing video'; logAxiosError(msg, error); } else { console.error('[CloudClient] Error processing video', error); } progressCallback(100); } done(); } /** * Download a video from the cloud store to disk. This won't remove it from * the cloud store. */ private async processDownloadQueueItem( video: RendererVideo, done: () => void, ): Promise { const storageDir = this.cfg.get('storagePath'); const { videoName, videoSource } = video; let lastProgress = 0; const progressCallback = (progress: number) => { if (progress === lastProgress) { return; } send('updateDownloadProgress', progress); lastProgress = progress; }; const client = CloudClient.getInstance(); try { await client.getAsFile( `${videoName}.mp4`, videoSource, storageDir, progressCallback, ); // Spread to force this to be cloned, avoiding modifying the original input, // which is used again later. This manifested as a bug that prevented us clearing // the entry from the inProgressDownloads when done, meaning that a repeated // attempt to download would fail. const metadata = rendererVideoToMetadata({ ...video }); const videoPath = path.join(storageDir, `${videoName}.mp4`); await writeMetadataFile(videoPath, metadata); } catch (error) { console.error( '[VideoProcessQueue] Error downloading video:', String(error), ); } done(); } /** * Create a kill video. This is CPU intensive and involves re-encoding. We * build a complex filter graph to stitch together the different perspectives * with video and audio transitions. */ private async processKillVideoQueueItem( item: KillVideoQueueItem, done: () => void, ): Promise { if (item.segments.length < 2) { // Programmer error. Can't make kill videos of less than 2 segments. console.error('[VideoProcessQueue] Less than 2 segments'); done(); return; } const first = item.segments[0].video; const videoPath = VideoProcessQueue.prepareKillVideoPath(first); const audioMap = VideoProcessQueue.prepareKillVideoAudioMap(item); const filter = VideoProcessQueue.prepareKillVideoComplexFilter(item); const fn = ffmpeg() .complexFilter(filter) .outputOption('-movflags +faststart') .outputOption('-map [v]') .outputOption(audioMap) .outputOption('-shortest') .outputOption('-c:v libx264') .outputOption('-crf 22') // Matches "Ultra" in the Recorder. .outputOption('-c:a aac') .outputOption('-preset fast') .outputOption('-pix_fmt yuv420p') .outputOption('-xerror') // Die on error. .output(videoPath); item.segments .map((seg) => seg.video.videoSource) .forEach((src) => { console.info('[VideoProcessQueue] Adding source to ffmpeg:', src); fn.input(src); }); try { console.time(`[VideoProcessQueue] Create ${item.uuid} kill video`); // The ffmpeg command is constructed so now do the actual work. A // reminder: this is a full re-encode and is computationally expensive. await VideoProcessQueue.ffmpegWrapper( fn, 'Make kill Video', (progress: number) => this.onKillVideoProgress(progress), ); console.timeEnd(`[VideoProcessQueue] Create ${item.uuid} kill video`); // Ffmpeg is done. Write out the metadata for the newly generated clip. const baseMetadata = rendererVideoToMetadata({ ...first }); // Close as will mutate. const metadata = buildKillVideoMetadata(baseMetadata, item.segments); await writeMetadataFile(videoPath, metadata); const readyToUpload = await CloudClient.getInstance().ready(); const upload = readyToUpload && shouldUpload(this.cfg, metadata); if (upload) { const item: UploadQueueItem = { path: videoPath }; this.queueUpload(item); } } finally { done(); } } /** * Push kill video encoding progress to the frontend. */ private onKillVideoProgress(progress: number) { const queued = Math.max(0, this.inProgressKillVideos.length); const perc = Math.round(progress); const status: KillVideoStatus = { queued, perc }; send('updateKillVideoStatus', status); } /** * Log an error processing the video. */ private static errorProcessingVideo(err: unknown) { console.error('[VideoProcessQueue] Error processing video', String(err)); } /** * Log an error uploading the video. */ private static errorUploadingVideo(err: unknown) { console.error('[VideoProcessQueue] Error uploading video', String(err)); } /** * Log an error downloading the video. */ private static errorDownloadingVideo(err: unknown) { console.error('[VideoProcessQueue] Error downloading video', String(err)); } /** * Log an error processing a kill video. */ private static errorKillVideo(err: unknown) { console.error('[VideoProcessQueue] Error creating kill video', String(err)); } /** * Log we are starting the processing and update the saving status icon. */ private startedProcessingVideo(item: VideoQueueItem) { console.info('[VideoProcessQueue] Now processing video', item.source); send('updateSaveStatus', SaveStatus.Saving); } /** * Log we are done, and update the saving status icon and refresh the * frontend. */ private async finishProcessingVideo(item: VideoQueueItem) { console.info('[VideoProcessQueue] Finished processing video', item.source); send('updateSaveStatus', SaveStatus.NotSaving); DiskClient.getInstance().refreshStatus(); DiskClient.getInstance().refreshVideos(); // Delete the source file if it's not a clip. Clips source files should be retained. if (!item.clip) { console.info('[VideoProcessQueue] Deleting source file', item.source); let success = await tryUnlink(item.source); if (!success) { // Wait a couple of seconds and try again. await new Promise((resolve) => setTimeout(resolve, 2000)); success = await tryUnlink(item.source); } if (!success) { // Not much else to do than log it. console.warn('[VideoProcessQueue] Failed to delete src', item.source); } } } /** * Actions on starting the processing of a kill video. */ private startedProcessingKillVideo(item: KillVideoQueueItem) { console.info('[VideoProcessQueue] Now processing kill video'); const status: KillVideoStatus = { queued: Math.max(0, this.inProgressKillVideos.length), perc: 0, }; send('updateKillVideoStatus', status); } /** * Actions on finishing processing a kill video. */ private finishProcessingKillVideo(item: KillVideoQueueItem) { console.info('[VideoProcessQueue] Finished processing kill video'); this.inProgressKillVideos = this.inProgressKillVideos.filter( (id) => id !== item.uuid, ); const status: KillVideoStatus = { perc: 0, queued: Math.max(0, this.inProgressKillVideos.length), }; send('updateKillVideoStatus', status); DiskClient.getInstance().refreshStatus(); DiskClient.getInstance().refreshVideos(); } /** * Called on the start of an upload. Set the upload bar to zero and log. */ private startedUploadingVideo(item: UploadQueueItem) { console.info('[VideoProcessQueue] Now uploading video', item.path); const queued = Math.max(0, this.inProgressUploads.length); send('updateUploadProgress', 0); send('updateUploadQueueLength', queued); } /** * Called on the end of an upload. */ private finishUploadingVideo(item: UploadQueueItem) { console.info('[VideoProcessQueue] Finished uploading video', item.path); this.inProgressUploads = this.inProgressUploads.filter( (p) => p !== item.path, ); const queued = Math.max(0, this.inProgressUploads.length); send('updateUploadQueueLength', queued); } /** * Called on the start of a download. Set the download bar to zero and log. */ private startedDownloadingVideo(video: RendererVideo) { const { videoName } = video; console.info('[VideoProcessQueue] Now downloading video', videoName); const queued = Math.max(0, this.inProgressDownloads.length); send('updateDownloadProgress', 0); send('updateDownloadQueueLength', queued); } /** * Called on the end of an upload. */ private async finishDownloadingVideo(video: RendererVideo) { const { videoName } = video; console.info('[VideoProcessQueue] Finished downloading video', videoName); this.inProgressDownloads = this.inProgressDownloads.filter( (p) => p !== videoName, ); const queued = Math.max(0, this.inProgressDownloads.length); send('updateDownloadQueueLength', queued); DiskClient.getInstance().refreshStatus(); DiskClient.getInstance().refreshVideos(); } /** * Run actions on the queue being empty. */ private async videoQueueEmpty() { console.info('[VideoProcessQueue] Video processing queue empty'); // Run the size monitor. const sizeMonitor = new DiskSizeMonitor(); sizeMonitor.run(); // Tidy the recording dir. const cfg = ConfigService.getInstance(); const { obsPath } = getBaseConfig(cfg); await Recorder.getInstance().cleanup(obsPath); // Update the frontend with the new usage. const usage = await sizeMonitor.usage(); const status: DiskStatus = { usage, limit: this.cfg.get('maxStorage') * 1024 ** 3, }; send('updateDiskStatus', status); } /** * Run actions on the upload queue being empty. */ private async uploadQueueEmpty() { console.info('[VideoProcessQueue] Upload processing queue empty'); } /** * Run actions on the download queue being empty. */ private async downloadQueueEmpty() { console.info('[VideoProcessQueue] Download processing queue empty'); const sizeMonitor = new DiskSizeMonitor(); sizeMonitor.run(); const usage = await sizeMonitor.usage(); const status: DiskStatus = { usage, limit: this.cfg.get('maxStorage') * 1024 ** 3, }; send('updateDiskStatus', status); } /** * Sanitize a filename and replace all invalid characters with a space. * * Multiple consecutive invalid characters will be replaced by a single space. * Multiple consecutive spaces will be replaced by a single space. */ private static sanitizeFilename(filename: string): string { return filename .replace(/[<>:"/|?*]/g, ' ') // Replace all invalid characters with space .replace(/ +/g, ' '); // Replace multiple spaces with a single space } /** * Returns the full path for a video cut operation's output file. */ private static getOutputVideoPath(data: VideoQueueItem, outputDir: string) { let videoName = data.name; if (data.suffix) { videoName += ' - '; videoName += data.suffix; } videoName = VideoProcessQueue.sanitizeFilename(videoName); // Always output MP4. MKV is just an intermediate format. return path.join(outputDir, `${videoName}.mp4`); } /** * This can be called either to cut a clip, or to cut a video on * finishing. Keep in mind that a video finishing may have a duration * less than the source video, if the recording was stopped by a log * timeout. */ private async cutVideo( data: VideoQueueItem, outputDir: string, ): Promise { console.info('[VideoProcessQueue] Cutting video:', { name: data.name, source: data.source, outputDir, suffix: data.suffix, offset: data.offset, duration: data.duration, }); let start = data.offset; if (data.offset < 0) { console.warn('[VideoProcessQueue] Negative offset set to zero'); start = 0; // Sanity check. } const outputPath = VideoProcessQueue.getOutputVideoPath(data, outputDir); const fn = ffmpeg(data.source) .setStartTime(start) .setDuration(data.duration) // Crucially we copy the video and audio, so we don't do any // re-encoding which would take time and CPU. .withVideoCodec('copy') .withAudioCodec('copy') // Avoid any negative timestamps, which can cause issues with // some players, but does extend the video slightly depending on // the keyframe alignment. .outputOption('-avoid_negative_ts make_zero') // Move the moov atom to the start of the file for faster playback start. // This means R2 doesn't need to seek to the end to start playback. .outputOption('-movflags +faststart') .output(outputPath); console.time('[VideoProcessQueue] Video cut took:'); await VideoProcessQueue.ffmpegWrapper(fn, 'Video cut'); console.timeEnd('[VideoProcessQueue] Video cut took:'); return outputPath; } /** * An async wrapper around ffmpeg-fluent to avoid a bunch of horrible promise * wrapping indented code being repeated. * * @param fn the ffmpeg function to wrap * @param descr a description of the command for logging */ private static async ffmpegWrapper( fn: ffmpeg.FfmpegCommand, descr: string, progressCallback?: (progress: number) => void, ): Promise { return new Promise((resolve, reject) => { const handleErr = (err: unknown) => { const msg = `[VideoProcessQueue] ${descr} failed! ${String(err)}`; console.error(msg); reject(msg); }; const handleEnd = async (err: unknown) => { if (err) handleErr(err); resolve(); }; const handleStart = (cmd: string) => console.info('[VideoProcessQueue] FFmpeg command:', cmd); const handleStderr = (cmd: string) => { // This is very verbose, so just do it if we're in debug mode. if (isDebug) console.info('[VideoProcessQueue] FFmpeg stderr:', cmd); }; const onProgress = (progress: { frames: number; currentFps: number; currentKbps: number; targetSize: number; timemark: string; percent?: number | undefined; }) => { console.info( '[VideoProcessQueue] Ffmpeg task:', descr, 'progress:', progress.percent?.toFixed(0), '%', ); if ( progressCallback && progress.percent !== undefined && // Technically covered by isFinite but typescript is dumb. Number.isFinite(progress.percent) // Sometimes ffmpeg-fluent gives NaN. ) { progressCallback(progress.percent); } }; fn.on('start', handleStart) .on('end', handleEnd) .on('error', handleErr) .on('stderr', handleStderr) .on('progress', onProgress) .run(); }); } /** * Prepare and return the audio map for a kill video. */ private static prepareKillVideoPath(video: RendererVideo) { const videoDate = video.start ?? video.mtime; let videoName = getOBSFormattedDate(new Date(videoDate)); videoName += ` - Multiview`; // We checked earlier that segments isn't empty so not // worrying about checking for undefined here. if (video.encounterName && video.difficulty) { // We should always have these fields for raids, and raids are // the only supported kill video category. videoName += ` - ${video.encounterName}`; videoName += ` [${video.difficulty}]`; } videoName += ` - Rendered at ${getOBSFormattedDate(new Date())}`; const storageDir = ConfigService.getInstance().get('storagePath'); const videoPath = path.join(storageDir, `${videoName}.mp4`); console.info('[VideoProcessQueue] Kill video path:', videoPath); return videoPath; } /** * Prepare and return the audio map for a kill video. */ private static prepareKillVideoAudioMap(item: KillVideoQueueItem) { const map = item.audioTrackIndex === -1 ? '-map [a]' : `-map ${item.audioTrackIndex}:a`; console.info('[VideoProcessQueue] Audio map filter:', map); return map; } /** * Prepare and return an ffmpeg complex filter graph that does the * appropriate trimming, scaling, padding and fading to render a kill * video. */ private static prepareKillVideoComplexFilter(item: KillVideoQueueItem) { let filter = ''; item.segments.forEach((pov, idx) => { const segmentDuration = pov.stop - pov.start; const fadeDuration = 1; const fadeOutStart = Math.max(0, segmentDuration - fadeDuration); const scale = `${item.width}:-2`; const pad = `${item.width}:${item.height}:(ow-iw)/2:(oh-ih)/2`; const fadeIn = `t=in:st=0:d=${fadeDuration}`; const fadeOut = `t=out:st=${fadeOutStart}:d=${fadeDuration}`; const trim = `start=${pov.start}:end=${pov.stop}`; // Video filter += `[${idx}:v]trim=${trim},setpts=PTS-STARTPTS,` + `fps=${item.fps},scale=${scale},pad=${pad},` + `fade=${fadeIn},fade=${fadeOut}[v${idx}];`; if (item.audioTrackIndex === -1) { // Audio filter += `[${idx}:a]atrim=${trim},asetpts=PTS-STARTPTS,` + `afade=${fadeIn},afade=${fadeOut}[a${idx}];`; } }); if (item.audioTrackIndex === -1) { const inputs = item.segments.map((_, i) => `[v${i}][a${i}]`).join(''); filter += `${inputs}concat=n=${item.segments.length}:v=1:a=1[v][a]`; } else { const inputs = item.segments.map((_, i) => `[v${i}]`).join(''); filter += `${inputs}concat=n=${item.segments.length}:v=1:a=0[v]`; } console.info('[VideoProcessQueue] Generated filter:', filter); return filter; } } ================================================ FILE: src/main/constants.ts ================================================ import { Phrase } from '../localisation/phrases'; import { VideoCategory } from '../types/VideoCategory'; import { ConfigurationSchemaKey } from '../config/configSchema'; import { NumberKeyToStringValueMapType, RaidInstanceType, StringKeyToNumberValueMapType, } from './types'; /** * The set of resolutions we allow users to select. */ const obsResolutions = { /* eslint-disable prettier/prettier */ '1024x768': { width: 1024, height: 768 }, '1280x720': { width: 1280, height: 720 }, '1280x800': { width: 1280, height: 800 }, '1280x1024': { width: 1280, height: 1024 }, '1360x768': { width: 1360, height: 768 }, '1366x768': { width: 1366, height: 768 }, '1440x900': { width: 1440, height: 900 }, '1600x900': { width: 1600, height: 900 }, '1680x1050': { width: 1680, height: 1050 }, '1920x1080': { width: 1920, height: 1080 }, '1920x1200': { width: 1920, height: 1200 }, '2560x1080': { width: 2560, height: 1080 }, '2560x1440': { width: 2560, height: 1440 }, '2560x1600': { width: 2560, height: 1600 }, '3360x1440': { width: 3360, height: 1440 }, '3440x1440': { width: 3440, height: 1440 }, '3440x1200': { width: 3440, height: 1200 }, '3520x990': { width: 3520, height: 990 }, '3840x1080': { width: 3840, height: 1080 }, '3840x1440': { width: 3840, height: 1440 }, '3840x1600': { width: 3840, height: 1600 }, '3840x2160': { width: 3840, height: 2160 }, '5120x1440': { width: 5120, height: 1440 }, '5120x2160': { width: 5120, height: 2160 }, '5760x1080': { width: 5760, height: 1080 }, '7680x2160': { width: 7680, height: 2160 }, /* eslint-enable prettier/prettier */ }; interface ICategoryRecordingSettings { allowRecordKey: ConfigurationSchemaKey; autoUploadKey: ConfigurationSchemaKey; } /** * Category specific settings for recording * * `configKey`: The configuration key name that specifies if we're allowed * to record content from that particular category. */ type CategoryRecordingSettingsBase = { [key in VideoCategory]: ICategoryRecordingSettings; }; const categoryRecordingSettings: Omit< CategoryRecordingSettingsBase, VideoCategory.Clips > = { [VideoCategory.TwoVTwo]: { allowRecordKey: 'recordTwoVTwo', autoUploadKey: 'cloudUpload2v2', }, [VideoCategory.ThreeVThree]: { allowRecordKey: 'recordThreeVThree', autoUploadKey: 'cloudUpload3v3', }, [VideoCategory.FiveVFive]: { allowRecordKey: 'recordFiveVFive', autoUploadKey: 'cloudUpload5v5', }, [VideoCategory.Skirmish]: { allowRecordKey: 'recordSkirmish', autoUploadKey: 'cloudUploadSkirmish', }, [VideoCategory.SoloShuffle]: { allowRecordKey: 'recordSoloShuffle', autoUploadKey: 'cloudUploadSoloShuffle', }, [VideoCategory.MythicPlus]: { allowRecordKey: 'recordDungeons', autoUploadKey: 'cloudUploadDungeons', }, [VideoCategory.Raids]: { allowRecordKey: 'recordRaids', autoUploadKey: 'cloudUploadRaids', }, [VideoCategory.Battlegrounds]: { allowRecordKey: 'recordBattlegrounds', autoUploadKey: 'cloudUploadBattlegrounds', }, [VideoCategory.Manual]: { allowRecordKey: 'manualRecord', autoUploadKey: 'manualRecordUpload', }, }; /** * Months of the year. */ const months: string[] = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; /** * Retail battlegrounds by ID. */ const retailBattlegrounds: NumberKeyToStringValueMapType = { 30: 'Alterac Valley', 2107: 'Arathi Basin', 1681: 'Arathi Basin', 1105: 'Deepwind Gorge', 2245: 'Deepwind Gorge', 566: 'Eye of the Storm', 968: 'Eye of the Storm', 628: 'Isle of Conquest', 1803: 'Seething Shore', 727: 'Silvershard Mines', 998: 'Temple of Kotmogu', 761: 'The Battle for Gilneas', 726: 'Twin Peaks', 489: 'Warsong Gulch', 2106: 'Warsong Gulch', 2656: 'Deephaul Ravine', 2188: 'Wintergrasp', }; /** * Classic battlegrounds by ID. This is probably totally wrong. */ const classicBattlegrounds: NumberKeyToStringValueMapType = { 30: 'Alterac Valley', 529: 'Arathi Basin', 566: 'Eye of the Storm', 607: 'Strand of the Ancients', 489: 'Warsong Gulch', }; /** * Retail arenas by ID. */ const retailArenas: NumberKeyToStringValueMapType = { 1672: "Blade's Edge", 617: 'Dalaran Sewers', 1505: 'Nagrand', 572: 'Ruins of Lordaeron', 2167: 'Robodrome', 1134: "Tiger's Peak", 980: "Tol'viron", 1504: 'Black Rook', 2373: 'Empyrean Domain', 1552: "Ashamane's Fall", 1911: 'Mugambala', 1825: 'Hook Point', 2509: 'Maldraxxus', 2547: 'Enigma Crucible', 2563: 'Nokhudon', 2759: 'Cage of Carnage', 2923: 'Voidscar Arena', // https://github.com/BigWigsMods/BigWigs/blob/master/Loader.lua#L638 }; /** * Classic arenas by ID. */ const classicArenas: NumberKeyToStringValueMapType = { 572: 'Ruins of Lordaeron', 559: 'Nagrand', 617: 'Dalaran', 562: "Blade's Edge", 1134: "Tiger's Peak", 980: "Tol'viron", }; /** * Shadowlands Tier 1 */ const encountersNathria: NumberKeyToStringValueMapType = { 2398: 'Shriekwing', 2418: 'Huntsman', 2402: 'Sun King', 2405: "Xy'mox", 2383: 'Hungering', 2406: 'Inerva', 2412: 'Council', 2399: 'Sludgefist', 2417: 'SLG', 2407: 'Denathrius', }; /** * Shadowlands Tier 2 */ const encountersSanctum: NumberKeyToStringValueMapType = { 2523: 'The Tarragrue', 2433: "Jailer's Eye", 2429: 'The Nine', 2432: "Ner'zhul", 2434: 'Souldrender', 2430: 'Painsmith', 2436: 'Guardian', 2431: 'Fatescribe', 2422: "Kel'Thuzad", 2435: 'Sylvanas', }; /** * Shadowlands Tier 3 */ const encountersSepulcher: NumberKeyToStringValueMapType = { 2537: 'Jailer', 2512: 'Guardian', 2529: 'Halondrus', 2539: 'Lihuvim', 2540: 'Dausegne', 2542: 'Skolex', 2543: 'Lords', 2544: 'Pantheon', 2546: 'Anduin', 2549: 'Rygelon', 2553: "Xy'mox", }; /** * Dragonflight Tier 1 */ const encountersVOI: NumberKeyToStringValueMapType = { 2587: 'Eranog', 2639: 'Terros', 2590: 'Primal', 2592: 'Sennarth', 2635: 'Dathea', 2605: 'Kurog', 2614: 'Diurna', 2607: 'Raszageth', }; /** * Dragonflight Tier 2 */ const encountersAberrus: NumberKeyToStringValueMapType = { 2688: 'Kazzara', 2687: 'Amalgamation Chamber', 2693: 'The Forgotten Experiments', 2682: 'Assault of the Zaqali', 2680: 'Rashok', 2689: 'Zskarn', 2683: 'Magmorax', 2684: 'Echo of Neltharion', 2685: 'Scalecommander Sarkareth', }; /** * WOTLK Classic Naxxrammas */ const encountersClassicNaxxramas: NumberKeyToStringValueMapType = { 1107: "Anub'Rekhan", 1110: 'Faerlina', 1116: 'Maexxna', 1118: 'Patchwerk', 1117: 'Noth', 1112: 'Heigan', 1115: 'Lotheb', 1113: 'Razuvious', 1109: 'Gothik', 1121: 'Horsemen', 1119: 'Sapphiron', 1120: 'Thaddius', 1114: "Kel'Thuzad", 1111: 'Grobbulus', 1108: 'Gluth', }; /** * WOTLK Classic Eye of Eternity */ const encountersClassicEye: NumberKeyToStringValueMapType = { 734: 'Malygos', }; /** * WOTLK Classic Obsidian Sanctum */ const encountersClassicObsidian: NumberKeyToStringValueMapType = { 742: 'Sartharion', }; /** * WOTLK Classic Vault of Archavon */ const encountersClassicVault: NumberKeyToStringValueMapType = { 772: 'Archavon', }; /** * WOTLK Classic Ulduar */ const encountersClassicUlduar: NumberKeyToStringValueMapType = { 744: 'Flame Leviathan', 745: 'Ignis', 746: 'Razorscale', 747: 'XT-002', 748: 'Assembly of Iron', 749: 'Kologarn', 750: 'Auriaya', 751: 'Hodir', 752: 'Thorim', 753: 'Freya', 754: 'Mimiron', 755: 'General Vezax', 756: 'Yogg-Saron', 757: 'Algalon', }; /** * WOTLK Classic TOC */ const encountersClassicTOC: NumberKeyToStringValueMapType = { 629: 'Northrend Beasts', 633: 'Lord Jaraxxus', 637: 'Faction Champions', 641: "Val'kyr Twins", 645: "Anub'arak", }; const raidEncountersById: NumberKeyToStringValueMapType = { ...encountersNathria, ...encountersSanctum, ...encountersSepulcher, ...encountersVOI, ...encountersAberrus, ...encountersClassicNaxxramas, ...encountersClassicEye, ...encountersClassicObsidian, ...encountersClassicVault, ...encountersClassicUlduar, ...encountersClassicTOC, }; /** * List of the raid encounters for the current tier of raid bosses. This * enables the "record current tier only" option, and needs updated with each * new tier. * * Typically will do a bit of overlap on a new tier so that we can cover PTR, * and retail at the same time. Eventually the older tiers should be removed. */ const currentRetailEncounters = [ // Midnight - The Voidspire 3176, // Imperator Averzian 3177, // Vorsaius 3178, // Fallen-King Salhadar 3179, // Vaelgor & Ezzorak 3180, // Lightblinded Vanguard 3181, // Crown of the Cosmos // Midnight - March on Quel'danas 3182, // Belo'ren, Child of Al'ar 3183, // Midnight Falls // Midnight - The Dreamrift 3306, // Chimaerus the Undreamt God // Test Encounter 9999, // Saves having to update the test button data. ]; /** * List of raids and their encounters * This is used to figure out the raid name of a given encounter as that * information is not available in `ENCOUNTER_START` and we shouldn't and * can't rely on `ZONE_CHANGE` for this. */ const raidInstances: RaidInstanceType[] = [ { zoneId: 13224, name: 'Castle Nathria', shortName: 'Nathria', encounters: encountersNathria, }, { zoneId: 13561, name: 'Sanctum of Domination', shortName: 'Sanctum', encounters: encountersSanctum, }, { zoneId: 13742, name: 'Sepulcher of the First Ones', shortName: 'Sepulcher', encounters: encountersSepulcher, }, { zoneId: 14030, name: 'Vault of the Incarnates', shortName: 'Vault', encounters: encountersVOI, }, { zoneId: 14663, name: 'Aberrus, the Shadowed Crucible', shortName: 'Aberrus', encounters: encountersAberrus, }, { zoneId: 3456, name: 'Naxxramas', shortName: 'Naxxramas', encounters: encountersClassicNaxxramas, }, { zoneId: 4500, name: 'Eye of Eternity', shortName: 'EoE', encounters: encountersClassicEye, }, { zoneId: 4493, name: 'Obsidian Sanctum', shortName: 'OS', encounters: encountersClassicObsidian, }, { zoneId: 4603, name: 'Vault of Archavon', shortName: 'VoA', encounters: encountersClassicVault, }, { zoneId: 4273, name: 'Ulduar', shortName: 'Ulduar', encounters: encountersClassicUlduar, }, { zoneId: 4722, name: 'Trial of the Crusader', shortName: 'ToC', encounters: encountersClassicTOC, }, ]; /** * Dungeons by zone ID. This might technically be "instanceID". Get these from * here: https://wowpedia.fandom.com/wiki/InstanceID. */ const dungeonsByZoneId: NumberKeyToStringValueMapType = { // Shadowlands 1651: 'Return to Karazhan', 1208: 'Grimrail Depot', 1195: 'Iron Docks', 2097: 'Operation: Mechagon', 2291: 'De Other Side', 2287: 'Halls of Atonement', 2290: 'Mists of Tirna Scithe', 2289: 'Plaguefall', 2284: 'Sanguine Depths', 2285: 'Spires of Ascension', 2286: 'The Necrotic Wake', 2293: 'Theater of Pain', 2441: 'Tazavesh the Veiled Market', // Dragonflight S1 2521: 'Ruby Life Pools', 2516: 'The Nokhud Offensive', 2515: 'The Azure Vault', 2526: "Algeth'ar Academy", 1477: 'Halls of Valor', 1571: 'Court of Stars', 1176: 'Shadowmoon Burial Grounds', 960: 'Temple of the Jade Serpent', // Dragonflight S2 2520: 'Brackenhide Hollow', 2527: 'Halls of Infusion', 2451: 'Uldaman: Legacy of Tyr', 2519: 'Neltharus', 1754: 'Freehold', 1841: 'The Underrot', 1458: "Neltharion's Lair", 657: 'The Vortex Pinnacle', // Dragonflight S3 2579: 'Dawn of the Infinite', 1862: 'Waycrest Manor', 1466: 'Darkheart Thicket', 1501: 'Black Rook Hold', 1763: "Atal'Dazar", 1279: 'The Everbloom', 643: 'Throne of the Tides', // TWW S1 - not on wiki yet. // Taken from https://github.com/BigWigsMods/BigWigs/blob/master/Loader.lua#L487. 670: 'Grim Batol', 1822: 'Siege of Boralus', 2652: 'The Stonevault', 2660: 'Ara-Kara, City of Echoes', 2662: 'The Dawnbreaker', 2669: 'City of Threads', // TWW S2. // Taken from https://github.com/BigWigsMods/BigWigs/blob/master/Loader.lua#L487. 2649: 'Priory of the Sacred Flame', 2651: 'Darkflame Cleft', 2648: 'The Rookery', 2661: 'Cinderbrew Meadery', 2773: 'Operation: Floodgate', 1594: 'THE MOTHERLODE!!', // TWW S3. // Taken from https://github.com/BigWigsMods/BigWigs/blob/master/Loader.lua#L487. 2830: "Eco-Dome Al'Dani", // Midnight S1. // Taken from https://github.com/BigWigsMods/BigWigs/blob/master/Loader.lua#L487. 2805: 'Windrunner Spire', 2811: "Magisters' Terrace", 2813: 'Murder Row', 2825: 'Den of Nalorakk', 2859: 'The Blinding Vale', 2874: 'Maisara Caverns', 2915: 'Nexus-Point Xenas', 658: 'Pit of Saron', 1209: 'Skyreach', 1753: 'Seat of the Triumvirate', }; /** * Dungeons by MapID. Get this by going to the keystone page on wowhead, e.g.: * https://www.wowhead.com/spell=393483/set-keystone-map-shadowmoon-burial-grounds * and extracting the effect ID. */ const dungeonsByMapId: NumberKeyToStringValueMapType = { // Shadowlands 166: 'Grimrail Depot', 169: 'Iron Docks', 227: 'Karazhan: Lower', 234: 'Karazhan: Upper', 369: 'Mechagon: Junkyard', 370: 'Mechagon: Workshop', 375: 'Mists of Tirna Scithe', 376: 'The Necrotic Wake', 377: 'De Other Side', 378: 'Halls of Atonement', 379: 'Plaguefall', 380: 'Sanguine Depths', 381: 'Spires of Ascension', 382: 'Theater of Pain', 391: 'Tazavesh: Streets', 392: 'Tazavesh: Gambit', // Dragonflight S1 399: 'Ruby Life Pools', 400: 'The Nokhud Offensive', 401: 'The Azure Vault', 402: "Algeth'ar Academy", 200: 'Halls of Valor', 210: 'Court of Stars', 165: 'Shadowmoon Burial Grounds', 2: 'Temple of the Jade Serpent', // Dragonflight S2 405: 'Brackenhide Hollow', 406: 'Halls of Infusion', 403: 'Uldaman: Legacy of Tyr', 404: 'Neltharus', 245: 'Freehold', 251: 'The Underrot', 206: "Neltharion's Lair", 438: 'The Vortex Pinnacle', // Dragonflight S3 463: "Galakrond's Fall", 464: "Murozond's Rise", 248: 'Waycrest Manor', 198: 'Darkheart Thicket', 199: 'Black Rook Hold', 244: "Atal'Dazar", 168: 'The Everbloom', 456: 'Throne of the Tides', // TWW S1 - taken from WoWhead, searching for "set keystone map X". 353: 'Siege of Boralus', 501: 'The Stonevault', 502: 'City of Threads', 503: 'Ara-Kara, City of Echoes', 505: 'The Dawnbreaker', 507: 'Grim Batol', // TWW S2 - taken from WoWhead, searching for "set keystone map X". 499: 'Priory of the Sacred Flame', 504: 'Darkflame Cleft', 500: 'The Rookery', 506: 'Cinderbrew Meadery', 247: 'THE MOTHERLODE!!', 525: 'Operation: Floodgate', // TWW S3 - taken from WoWhead, searching for "set keystone map X". 542: "Eco-Dome Al'Dani", // Midnight Season 1 - taken from WoWhead, searching for "set keystone map X". 558: "Magister's Terrace", 560: 'Maisara Caverns', 559: 'Nexus-Point Xenas', 557: 'Windrunner Spire', 556: 'Pit of Saron', 239: 'Seat of the Triumvirate', 161: 'Skyreach', }; /** * Alloted time for Mythic Keystone dungeons, in seconds, the format of: * * mapId: [1 chest, 2 chest, 3 chest] * * The first is obviously also the one the determines if a key was timed or not. * * Tip: It's easier to keep them as a calculation here, for comparison when Blizzard * occasionally adjusts timers for a dungeon. */ const dungeonTimersByMapId: { [id: number]: number[] } = { // Shadowlands 377: [43 * 60, 34 * 60 + 25, 25 * 60 + 49], 378: [32 * 60, 32 * 60 * 0.8, 32 * 60 * 0.6], // Halls of Atonement 375: [30 * 60, 24 * 60, 18 * 60], // Mists of Tirna Scithe 379: [38 * 60, 30 * 60 + 24, 22 * 60 + 38], 380: [41 * 60, 32 * 60 + 48, 24 * 60 + 36], 381: [39 * 60, 31 * 60 + 12, 23 * 60 + 24], 376: [32 * 60 + 30, 26 * 60 + 18, 20 * 60 + 6], // The Necrotic Wake 382: [34 * 60, 27 * 60 + 12, 20 * 60 + 24], // Theater of Pain 227: [42 * 60, 33 * 60 + 36, 25 * 60 + 12], 234: [35 * 60, 28 * 60, 21 * 60], 369: [38 * 60, 30 * 60 + 24, 22 * 60 + 38], 370: [32 * 60, 25 * 60 + 36, 19 * 60 + 12], // Operation: Mechagon - Workshop 391: [35 * 60, 35 * 60 * 0.8, 35 * 60 * 0.6], // Tazavesh: Streets of Wonder 392: [30 * 60, 24 * 60, 18 * 60], // Tazavesh: So'leah's Gambit 169: [30 * 60, 24 * 60, 18 * 60], 166: [30 * 60, 24 * 60, 18 * 60], // Dragonflight S1 399: [30 * 60, 24 * 60, 18 * 60], 400: [40 * 60, 32 * 60, 24 * 60], 401: [37 * 60 + 30, 30 * 60, 22 * 60 + 30], 200: [38 * 60, 30 * 60 + 24, 22 * 60 + 48], 210: [30 * 60, 24 * 60, 18 * 60], 165: [33 * 60, 26 * 60 + 24, 19 * 60 + 48], 2: [30 * 60, 24 * 60, 18 * 60], // Dragonflight S2 405: [35 * 60, 28 * 60, 21 * 60], 406: [35 * 60, 28 * 60, 21 * 60], 403: [35 * 60, 28 * 60, 21 * 60], 404: [33 * 60, 26 * 60 + 24, 19 * 60 + 48], 245: [30 * 60, 24 * 60, 18 * 60], 251: [30 * 60, 24 * 60, 18 * 60], 206: [33 * 60, 26 * 60 + 24, 19 * 60 + 48], 438: [30 * 60, 24 * 60, 18 * 60], // Dragonflight S3 463: [34 * 60, 27 * 60 + 12, 20 * 60 + 24], 464: [36 * 60, 28 * 60, 21 * 60], 248: [36 * 60 + 40, 29 * 60 + 20, 22 * 60], 198: [30 * 60, 24 * 60, 18 * 60], 199: [36 * 60, 28 * 60 + 48, 23 * 60 + 24], 244: [30 * 60, 24 * 60, 18 * 60], 168: [33 * 60, 26 * 60 + 24, 19 * 60 + 48], 456: [34 * 60, 27 * 60 + 12, 20 * 60 + 24], // TWW S1 // Don't have exact timers yet so for now have assumed 0.8 and 0.6 multipliers. // https://overgear.com/guides/wow/the-war-within-mythic-guide/. 501: [33 * 60, 33 * 60 * 0.8, 33 * 60 * 0.6], // The Stonevault 503: [30 * 60, 30 * 60 * 0.8, 30 * 60 * 0.6], // Ara-Kara, City of Echoes 353: [33 * 60, 33 * 60 * 0.8, 33 * 60 * 0.6], // Siege of Boralus 502: [35 * 60, 35 * 60 * 0.8, 35 * 60 * 0.6], // City of Threads 505: [31 * 60, 31 * 60 * 0.8, 31 * 60 * 0.6], // The Dawnbreaker 507: [34 * 60, 34 * 60 * 0.8, 34 * 60 * 0.6], // Grim Batol // TWW S2 // Don't have exact timers yet so for now have assumed 0.8 and 0.6 multipliers. // https://www.wowhead.com/guide/mythic-plus-dungeons/the-war-within-season-2/overview. 506: [33 * 60, 26 * 60 + 24, 19 * 60 + 48], // 'Cinderbrew Meadery' 504: [31 * 60, 24 * 60 + 24, 18 * 60 + 48], // 'Darkflame Cleft' 500: [29 * 60, 23 * 60 + 24, 17 * 60 + 48], // 'The Rookery' 499: [32 * 60 + 30, 26 * 60, 19 * 60 + 30], // 'Priory of the Sacred Flame' 525: [33 * 60, 26 * 60 + 24, 19 * 60 + 48], // 'Operation: Floodgate' 247: [33 * 60, 33 * 60 * 0.8, 33 * 60 * 0.6], // 'THE MOTHERLODE!!' // TWW S3 // Timings from https://www.wowhead.com/guide/mythic-plus-dungeons/the-war-within-season-3/overview. 542: [31 * 60, 31 * 60 * 0.8, 31 * 60 * 0.6], // "Eco-Dome Al'Dani" // Midnight S1 // Timings from https://www.wowhead.com/guide/midnight/mythic-plus-season-1-overview. 558: [34 * 60, 27 * 60 + 12, 20 * 60 + 24], // Magister's Terrace 560: [33 * 60, 26 * 60 + 24, 19 * 60 + 48], // Maisara Caverns 559: [30 * 60, 24 * 60, 18 * 60], // Nexus-Point Xenas 557: [33 * 60 + 30, 26 * 60 + 48, 20 * 60 + 6], // Windrunner Spire 402: [31 * 60, 24 * 60 + 48, 18 * 60 + 36], // Algeth'ar Academy 556: [30 * 60, 24 * 60, 18 * 60], // Pit of Saron 239: [34 * 60, 27 * 60 + 12, 20 * 60 + 24], // Seat of the Triumvirate 161: [28 * 60, 22 * 60 + 36, 16 * 60 + 42], // Skyreach }; // Useful database for grabbing this stuff: // https://wago.tools/db2/JournalEncounter?build=11.0.2.55789&filter[Name_lang]=Erudax&page=1 // Make sure to pick the most recent build. Column we need is "DungeonEncounterID". const dungeonEncounters: NumberKeyToStringValueMapType = { // Grimrail Depot 1715: 'Rocketspark and Borka', 1732: 'Nitrogg Thundertower', 1736: 'Skylord Tovra', // Iron Docks 1748: 'Grimrail Enforcers', 1749: "Fleshrender Nok'gar", 1750: 'Oshir', 1754: 'Skulloc, Son of Gruul', // Return to Karazhan: Lower 1954: 'Maiden of Virtue', 1957: 'Opera Hall', 1960: 'Attumen the Huntsman', 1961: 'Moroes', // Return to Karazhan: Upper 1964: 'The Curator', 1959: 'Mana Devourer', 1965: 'Shade of Medivh', 2017: "Viz'aduum the Watcher", // Mechagon: Workshop 2257: 'Tussle Tonks', 2258: 'K.U.-J.0.', 2259: "Machinist's Garden", 2260: 'King Mechagon', // Mechagon: Junkyard 2290: 'King Gobbamak', 2291: 'HK-8 Aerial Oppression Unit', 2292: 'Gunker', 2312: 'Trixie & Naeno', // Spires of Ascension 2356: 'Ventunax', 2357: 'Kin-Tara', 2358: 'Oryphrion', 2359: 'Devos, Paragon of Loyalty', // Sanguine Depths 2360: 'Kryxis the Voracious', 2361: 'Executor Tarvold', 2362: 'Grand Proctor Beryllia', 2363: 'General Kaal', // Theater of Pain 2364: "Kul'tharok", 2365: 'Gorechop', 2366: 'Xav the Unfallen', 2391: 'An Affront of Challengers', 2404: 'Mordretha', // Halls of Atonement 2380: 'Echelon', 2381: 'Lord Chamberlain', 2401: 'Halkias, the Sin-Stained Goliath', 2403: 'High Adjudicator Aleez', // Plaguefall 2382: 'Globgrog', 2384: 'Doctor Ickus', 2385: 'Domina Venomblade', 2386: 'Stradama Margrave', // Necrotic Wake 2387: 'Blightbone', 2388: 'Amarth, The Harvester', 2389: 'Surgeon Stitchflesh', 2390: 'Nalthor the Rimebinder', // De Other Side 2394: 'The Manastorms', 2395: 'Hakkar, the Soulflayer', 2396: "Mueh'zala", 2400: "Dealer Xy'exa", // Mists of Tirna Scithe 2397: 'Ingra Maloch', 2392: 'Mistcaller', 2393: "Tred'ova", // Tazavesh: So'leah's Gambit 2419: "Timecap'n Hooktail", 2426: 'Hylbrande', 2442: "So'leah", // Tazavesh: Streets of Wonder 2424: 'Mailroom Mayhem', 2425: "Zo'phex the Sentinel", 2441: 'The Grand Menagerie', 2437: "So'azmi", 2440: "Myza's Oasis", // Ruby Life Pools 2609: 'Melidrussa Chillworn', 2606: 'Kokia Blazehoof', 2623: 'Kyrakka and Erhkard Stormvein', // The Nokhud Offensive 2637: 'Granyth', 2636: 'The Raging Tempest', 2581: 'Teera and Maruuk', 2580: 'Balakar Khan', // The Azure Vault 2582: 'Leymor', 2585: 'Azureblade', 2583: 'Telash Greywing', 2584: 'Umbrelskul', // Algeth'ar Acedemy 2562: 'Vexamus', 2563: 'Overgrown Ancient', 2564: 'Crawth', 2565: 'Echo of Doragosa', // Halls of Valor 1805: 'Hymdall', 1806: 'Hyrja', 1807: 'Fenryr', 1808: 'God-King Skovald', 1809: 'Odyn', // Court of Stars 1868: 'Patrol Captain Gerdo', 1869: 'Talixae Flamewreath', 1870: 'Advisor Melandrus', // Shadowmmon Burial Grounds 1677: 'Sadana Bloodfury', 1688: 'Nhallish', 1679: 'Bonemaw', 1682: "Ner'zhul", // Temple of the Jade Serpent 1418: 'Wise Mari', 1417: 'Lorewalker Stonestep', 1416: 'Liu Flameheart', 1439: 'Sha of Doubt', // Brackenhide Hollow 2570: "Hackclaw's War-Band", 2567: 'Gutshot', 2568: 'Treemouth', 2569: 'Decatriarch Wratheye', // Halls of Infusion 2615: 'Watcher Irideus', 2616: 'Gulping Goliath', 2617: 'Khajin the Unyielding', 2618: 'Primal Tsunami', // Uldaman: Legacy of Tyr 2555: 'The Lost Dwarves', 2556: 'Bromach', 2557: 'Sentinel Talondras', 2558: 'Emberon', 2559: 'Chrono-Lord Deios', // Neltharus 2610: 'Magmatusk', 2611: 'Warlord Sargha', 2612: 'Forgemaster Gorek', 2613: 'Chargath, Bane of Scales', // Freehold 2093: "Skycap'n Kragg", 2094: "Council o' Captains", 2095: 'Ring of Booty', 2096: 'Harlan Sweete', // The Underrot 2111: 'Elder Leaxa', 2118: 'Cragmaw the Infested', 2112: 'Sporecaller Zancha', 2123: 'Unbound Abomination', // Neltharion's Lair 1790: 'Rokmora', 1791: 'Ularogg Cragshaper', 1792: 'Naraxas', 1793: 'Dargrul the Underking', // The Vortex Pinnacle 1041: 'Altairus', 1042: 'Asaad, Caliph of Zephyrs', 1043: 'Grand Vizier Ertan', // Waycrest Manor 2113: 'Heartsbane Triad', 2114: 'Soulbound Goliath', 2115: 'Raal the Gluttonous', 2116: 'Lord and Lady Waycrest', 2117: 'Gorak Tul', // Black Rook Hold 1832: 'The Amalgam of Souls', 1833: 'Illysanna Ravencrest', 1834: 'Smashspite the Hateful', 1835: "Lord Kur'talos Ravencrest", // Throne of the Tides 1045: "Lady Naz'jar", 1044: 'Commander Ulthok, the Festering Prince', 1046: "Mindbender Ghur'sha", 1047: 'Ozumat', // Everbloom 1746: 'Witherbark', 1757: 'Ancient Protectors', 1751: 'Archmage Sol', 1756: 'Yalnu', // Atal'Dazar 2084: "Priestess Alun'za", 2086: 'Rezan', 2085: "Vol'kaal", 2087: 'Yazma', // Dawn of the Infinites: Galakdron's Fall 2666: 'Chronikar', 2667: 'Manifested Timeways', 2668: 'Blight of Galakrond', 2669: 'Iridikron the Stonescaled', // Dawn of the Infinites: Murozond's Rise 2670: 'Tyr, the Infinite Keeper', 2671: 'Morchie', 2672: 'Time-Lost Battlefield', 2673: 'Chrono-Lord Deios', // Darkheart Thicket 1836: 'Archdruid Glaidalis', 1837: 'Oakheart', 1838: 'Dresaron', 1839: 'Shade of Xavius', // The Stonevault 2854: 'E.D.N.A', 2880: 'Skarmorak', 2888: 'Master Machinists', 2883: 'Void Speaker Eirich', // The Dawnbreaker 2837: 'Speaker Shadowcrown', 2838: "Anub'ikkaj", 2839: "Rasha'nan", // City of Threads 2907: "Orator Krix'vizk", 2908: 'Fangs of the Queen', 2905: 'The Coaglamation', 2909: 'Izo, the Grand Splicer', // Ara-Kara, City of Echoes 2926: 'Avanoxx', 2906: "Anub'zekt", 2901: "Ki'katal the Harvester", // Siege of Boralus 2098: 'Chopper Redhook', 2109: 'Dread Captain Lockwood', 2099: 'Hadal Darkfathom', 2100: "Viq'Goth", // Grim Batol 1051: 'General Umbriss', 1050: 'Forgemaster Throngus', 1048: 'Drahga Shadowburner', 1049: 'Erudax, the Duke of Below', // Priory of the Sacred Flame 2847: 'Captain Dailcry', 2835: 'Baron Braunpyke', 2848: 'Prioress Murrpray', // Darkflame Cleft 2829: "Ol' Waxbeard", 2826: 'Blazikon', 2787: 'The Candle King', 2788: 'The Darkness', // The Rookery 2816: 'Kyrioss', 2861: 'Stormguard Gorren', 2836: 'Voidstone Monstrosity', // Cinderbrew Meadery 2900: 'Brew Master Aldryr', 2929: "I'pa", 2931: 'Benk Buzzbee', 2930: 'Goldie Baronbottom', // Operation: Floodgate 3020: 'Big M.O.M.M.A.', 3054: 'Geezle Gigazap', 3053: 'Swampface', 3019: 'Demolition Duo', // THE MOTHERLODE!! 2105: 'Coin-Operated Crowd Pummeler', 2106: 'Azerokk', 2107: 'Rixxa Fluxflame', 2108: 'Mogul Razdunk', // Eco-Dome Al'Dani 3107: 'Azhiccar', 3108: "Taah'bat and A'wazj", 3109: 'Soul-Scribe', // Magister's Terrace 3071: 'Arcanotron Custos', 3073: 'Gemellus', 3072: 'Seranel Sunlash', 3074: 'Degentrius', // Maisara Caverns 3212: "Muro'jin and Nekraxx", 3214: "Rak'tul, Vessel of Souls", 3213: 'Vordaza', // Nexus-Point Xenas 3328: 'Chief Corewright Kasreth', 3332: 'Corewarden Nysarra', 3333: 'Lothraxion', // Windrunner Spire 3058: 'Commander Kroluk', 3057: 'Derelict Duo', 3056: 'Emberdawn', 3059: 'The Restless Heart', // Pit of Saron 1999: 'Forgemaster Garfrost', 2001: 'Ick and Krick', 2000: 'Scourgelord Tyrannus', // Seat of the Triumvirate 2068: "L'ura", 2066: 'Saprish', 2067: 'Viceroy Nezhar', 2065: 'Zuraal the Ascended', // Skyreach 1699: 'Araknath', 1701: 'High Sage Viryx', 1698: 'Ranjit', 1700: 'Rukhran', }; const instanceNamesByZoneId: NumberKeyToStringValueMapType = { ...retailBattlegrounds, ...classicBattlegrounds, ...retailArenas, ...classicArenas, ...dungeonsByZoneId, }; // https://www.wowhead.com/beta/affixes was handy when finding TWW beta affixes. // Remember to add thumbnails (from Wowhead) to assets/affixes/.jpg. // Some of these are shortened so they fit. const dungeonAffixesById: NumberKeyToStringValueMapType = { 1: 'Overflowing', 2: 'Skittish', 3: 'Volcanic', 4: 'Necrotic', 5: 'Teeming', 6: 'Raging', 7: 'Bolstering', 8: 'Sanguine', 9: 'Tyrannical', 10: 'Fortified', 11: 'Bursting', 12: 'Grievous', 13: 'Explosive', 14: 'Quaking', 117: 'Reaping', 120: 'Awakened', 121: 'Prideful', 122: 'Inspiring', 123: 'Spiteful', 124: 'Storming', 128: 'Tormented', 130: 'Encrypted', 131: 'Shrouded', 133: 'Focused', 134: 'Entangling', 135: 'Afflicted', 136: 'Incorporeal', 137: 'Shielding', 144: 'Thorned', 145: 'Reckless', 146: 'Attuned', 147: 'Guile', 148: 'Ascendant', 152: 'Peril', 153: 'Frenzied', 158: 'Voidbound', 159: 'Oblivion', 160: 'Devour', 162: 'Pulsar', }; /** * Zones by ID. */ const zones: NumberKeyToStringValueMapType = { ...retailArenas, ...classicArenas, ...raidEncountersById, ...retailBattlegrounds, ...classicBattlegrounds, ...dungeonsByZoneId, }; const instanceEncountersById: NumberKeyToStringValueMapType = { ...raidEncountersById, ...dungeonEncounters, }; type InstanceDifficultyPartyType = 'party' | 'raid' | 'pvp'; type InstanceDifficultyIdType = 'lfr' | 'normal' | 'heroic' | 'mythic' | 'pvp'; type InstanceDifficultyType = { difficultyID: InstanceDifficultyIdType; difficulty: string; partyType: InstanceDifficultyPartyType; phrase: Phrase; }; type InstanceDifficultyObjectType = { [key: number]: InstanceDifficultyType; }; // See https://wowpedia.fandom.com/wiki/DifficultyID. const instanceDifficulty: InstanceDifficultyObjectType = { 1: { difficultyID: 'normal', difficulty: 'N', partyType: 'party', phrase: Phrase.Normal, }, 2: { difficultyID: 'heroic', difficulty: 'HC', partyType: 'party', phrase: Phrase.Heroic, }, 3: { difficultyID: 'normal', difficulty: '10N', partyType: 'raid', phrase: Phrase.Normal, }, 4: { difficultyID: 'normal', difficulty: '25N', partyType: 'raid', phrase: Phrase.Normal, }, 5: { difficultyID: 'heroic', difficulty: '10HC', partyType: 'raid', phrase: Phrase.Heroic, }, 6: { difficultyID: 'heroic', difficulty: '25HC', partyType: 'raid', phrase: Phrase.Heroic, }, 7: { difficultyID: 'lfr', difficulty: 'LFR', partyType: 'raid', phrase: Phrase.LFR, }, 8: { difficultyID: 'mythic', difficulty: 'Mythic Keystone', partyType: 'party', phrase: Phrase.Mythic, }, 9: { difficultyID: 'normal', difficulty: '40', partyType: 'raid', phrase: Phrase.Normal, }, // Retail raids 14: { difficultyID: 'normal', difficulty: 'N', partyType: 'raid', phrase: Phrase.Normal, }, 15: { difficultyID: 'heroic', difficulty: 'HC', partyType: 'raid', phrase: Phrase.Heroic, }, 16: { difficultyID: 'mythic', difficulty: 'M', partyType: 'raid', phrase: Phrase.Mythic, }, 17: { difficultyID: 'lfr', difficulty: 'LFR', partyType: 'raid', phrase: Phrase.LFR, }, 23: { difficultyID: 'mythic', difficulty: 'M', partyType: 'party', phrase: Phrase.Mythic, }, 24: { difficultyID: 'normal', difficulty: 'T', partyType: 'party', phrase: Phrase.Normal, }, 33: { difficultyID: 'normal', difficulty: 'T', partyType: 'raid', phrase: Phrase.Normal, }, 34: { difficultyID: 'pvp', difficulty: 'PvP', partyType: 'pvp', phrase: Phrase.Pvp, }, 150: { difficultyID: 'normal', difficulty: 'N', partyType: 'party', phrase: Phrase.Normal, }, 151: { difficultyID: 'lfr', difficulty: 'T', partyType: 'raid', phrase: Phrase.LFR, }, 175: { difficultyID: 'normal', difficulty: '10N', partyType: 'raid', phrase: Phrase.Normal, }, 176: { difficultyID: 'normal', difficulty: '25N', partyType: 'raid', phrase: Phrase.Normal, }, 185: { difficultyID: 'normal', difficulty: 'N', partyType: 'raid', phrase: Phrase.Normal, }, 186: { difficultyID: 'normal', difficulty: 'N', partyType: 'raid', phrase: Phrase.Normal, }, 193: { difficultyID: 'heroic', difficulty: '10HC', partyType: 'raid', phrase: Phrase.Heroic, }, 194: { difficultyID: 'heroic', difficulty: '25HC', partyType: 'raid', phrase: Phrase.Heroic, }, // Classic era 10 man? 198: { difficultyID: 'normal', difficulty: '10N', partyType: 'raid', phrase: Phrase.Normal, }, 215: { difficultyID: 'normal', difficulty: '10N', partyType: 'raid', phrase: Phrase.Normal, }, 226: { difficultyID: 'normal', difficulty: 'N', partyType: 'raid', phrase: Phrase.Normal, }, }; const categoryTabSx = { padding: '12px', color: 'white', borderBottom: '1px solid', borderColor: 'black', minHeight: '1px', height: '30px', }; const categoryTabsSx = { borderColor: '#000000', textColor: 'secondary', width: '175px', overflow: 'visible', }; const videoButtonSx = { padding: '0px', margin: 0.5, border: '1px solid black', color: 'white', minHeight: '1px', height: '100px', width: '200px', opacity: 1, borderRadius: 2, }; const scrollBarSx = { scrollbarWidth: 'thin', '&::-webkit-scrollbar': { width: '0.125em', }, '&::-webkit-scrollbar-track': { background: 'transparent', boxSizing: 'border-box', }, '&::-webkit-scrollbar-thumb': { backgroundColor: 'hsl(var(--popover))', borderRadius: '6px', boxSizing: 'border-box', }, '&::-webkit-scrollbar-thumb:hover': { background: '#555', }, }; type WoWCharacterDamageType = 'melee' | 'ranged'; type WoWCharacterRoleType = 'tank' | 'healer' | 'damage'; type WoWCharacterClassType = | 'DEATHKNIGHT' | 'DEMONHUNTER' | 'DRUID' | 'HUNTER' | 'MAGE' | 'MONK' | 'PALADIN' | 'PRIEST' | 'ROGUE' | 'SHAMAN' | 'WARLOCK' | 'WARRIOR' | 'EVOKER' | 'UNKNOWN'; const WoWClassColor = { DEATHKNIGHT: '#C41E3A', DEMONHUNTER: '#A330C9', DRUID: '#FF7C0A', HUNTER: '#AAD372', MAGE: '#3FC7EB', MONK: '#00FF98', PALADIN: '#F48CBA', PRIEST: '#FFFFFF', ROGUE: '#FFF468', SHAMAN: '#0070DD', WARLOCK: '#8788EE', WARRIOR: '#C69B6D', EVOKER: '#33937F', UNKNOWN: 'grey', }; type SpecializationObjectType = { type: WoWCharacterDamageType; role: WoWCharacterRoleType; class: WoWCharacterClassType; label: string; name: string; }; const specializationById: { [id: number]: SpecializationObjectType } = { 250: { type: 'melee', role: 'tank', class: 'DEATHKNIGHT', label: 'Death Knight', name: 'Blood', }, 251: { type: 'melee', role: 'damage', class: 'DEATHKNIGHT', label: 'Death Knight', name: 'Frost', }, 252: { type: 'melee', role: 'damage', class: 'DEATHKNIGHT', label: 'Death Knight', name: 'Unholy', }, 577: { type: 'melee', role: 'damage', class: 'DEMONHUNTER', label: 'Demon Hunter', name: 'Havoc', }, 581: { type: 'melee', role: 'tank', class: 'DEMONHUNTER', label: 'Demon Hunter', name: 'Vengeance', }, 1480: { type: 'ranged', role: 'damage', class: 'DEMONHUNTER', label: 'Demon Hunter', name: 'Devourer', }, 102: { type: 'ranged', role: 'damage', class: 'DRUID', label: 'Druid', name: 'Balance', }, 103: { type: 'melee', role: 'damage', class: 'DRUID', label: 'Druid', name: 'Feral', }, 104: { type: 'ranged', role: 'tank', class: 'DRUID', label: 'Druid', name: 'Guardian', }, 105: { type: 'ranged', role: 'healer', class: 'DRUID', label: 'Druid', name: 'Restoration', }, 1467: { type: 'ranged', role: 'damage', class: 'EVOKER', label: 'Evoker', name: 'Devastation', }, 1468: { type: 'ranged', role: 'healer', class: 'EVOKER', label: 'Evoker', name: 'Preservation', }, 1473: { type: 'ranged', role: 'damage', class: 'EVOKER', label: 'Evoker', name: 'Augmentation', }, 253: { type: 'ranged', role: 'damage', class: 'HUNTER', label: 'Hunter', name: 'Beast Mastery', }, 254: { type: 'ranged', role: 'damage', class: 'HUNTER', label: 'Hunter', name: 'Marksmanship', }, 255: { type: 'melee', role: 'damage', class: 'HUNTER', label: 'Hunter', name: 'Survival', }, 62: { type: 'ranged', role: 'damage', class: 'MAGE', label: 'Mage', name: 'Arcane', }, 63: { type: 'ranged', role: 'damage', class: 'MAGE', label: 'Mage', name: 'Fire', }, 64: { type: 'ranged', role: 'damage', class: 'MAGE', label: 'Mage', name: 'Frost', }, 268: { type: 'melee', role: 'tank', class: 'MONK', label: 'Monk', name: 'Brewmaster', }, 269: { type: 'melee', role: 'damage', class: 'MONK', label: 'Monk', name: 'Windwalker', }, 270: { type: 'melee', role: 'healer', class: 'MONK', label: 'Monk', name: 'Mistweaver', }, 65: { type: 'melee', role: 'healer', class: 'PALADIN', label: 'Paladin', name: 'Holy', }, 66: { type: 'melee', role: 'tank', class: 'PALADIN', label: 'Paladin', name: 'Protection', }, 70: { type: 'melee', role: 'damage', class: 'PALADIN', label: 'Paladin', name: 'Retribution', }, 256: { type: 'ranged', role: 'healer', class: 'PRIEST', label: 'Priest', name: 'Discipline', }, 257: { type: 'ranged', role: 'healer', class: 'PRIEST', label: 'Priest', name: 'Holy', }, 258: { type: 'ranged', role: 'damage', class: 'PRIEST', label: 'Priest', name: 'Shadow', }, 259: { type: 'melee', role: 'damage', class: 'ROGUE', label: 'Rogue', name: 'Assassination', }, 260: { type: 'melee', role: 'damage', class: 'ROGUE', label: 'Rogue', name: 'Outlaw', }, 261: { type: 'melee', role: 'damage', class: 'ROGUE', label: 'Rogue', name: 'Subtlety', }, 262: { type: 'ranged', role: 'damage', class: 'SHAMAN', label: 'Shaman', name: 'Elemental', }, 263: { type: 'melee', role: 'damage', class: 'SHAMAN', label: 'Shaman', name: 'Enhancement', }, 264: { type: 'ranged', role: 'healer', class: 'SHAMAN', label: 'Shaman', name: 'Restoration', }, 265: { type: 'ranged', role: 'damage', class: 'WARLOCK', label: 'Warlock', name: 'Affliction', }, 266: { type: 'ranged', role: 'damage', class: 'WARLOCK', label: 'Warlock', name: 'Demonology', }, 267: { type: 'ranged', role: 'damage', class: 'WARLOCK', label: 'Warlock', name: 'Destruction', }, 71: { type: 'melee', role: 'damage', class: 'WARRIOR', label: 'Warrior', name: 'Arms', }, 72: { type: 'melee', role: 'damage', class: 'WARRIOR', label: 'Warrior', name: 'Fury', }, 73: { type: 'melee', role: 'tank', class: 'WARRIOR', label: 'Warrior', name: 'Protection', }, }; // Need this only for BG spec detection in retail. // These spells should be common, and unique to a spec. // More than one may be added per spec to improve chance of identifying. const retailUniqueSpecSpells: StringKeyToNumberValueMapType = { 'Heart Strike': 250, 'Frost Strike': 251, 'Festering Strike': 252, 'Eye Beam': 577, 'Fel Devastation': 581, Starfall: 102, "Tiger's Fury": 103, Maul: 104, Lifebloom: 105, Pyre: 1467, Echo: 1468, 'Ebon Might': 1473, 'Cobra Shot': 253, 'Aimed Shot': 254, 'Raptor Strike': 255, 'Arcane Barrage': 62, Pyroblast: 63, 'Ice Lance': 64, 'Keg Smash': 268, 'Fists of Fury': 269, 'Enveloping Mist': 270, 'Holy Shock': 65, "Avenger's Shield": 66, 'Blade of Justice': 70, Penance: 256, 'Holy Word: Serenity': 257, 'Devouring Plague': 258, Mutilate: 259, 'Sinister Strike': 260, 'Shadow Dance': 261, 'Earth Shock': 262, Stormstrike: 263, Riptide: 264, 'Malefic Rapture': 265, 'Call Dreadstalkers': 266, 'Chaos Bolt': 267, 'Mortal Strike': 71, Bloodthirst: 72, 'Ignore Pain': 73, }; // Need this for any non-raid spec detection in classic. More than one may be // added per spec to improve chance of identifying. // // These spells MUST be unique to the spec. Ideally they also are common so they // are more likely to be cast by the combatants, giving us higher chance at // identifying the spec. Obviously some risk they cast nothing so we can't be // perfect here. // // Some ordering conditions in how we identify combatants with a crawling mechanism // means we won't always react if we see one of these spells, so again; make these // common spells if possible. const classicUniqueSpecSpells: StringKeyToNumberValueMapType = { 'Heart Strike': 250, 'Howling Blast': 251, 'Summon Gargoyle': 252, Starfall: 102, Mangle: 103, Swiftmend: 105, Nourish: 105, Lifebloom: 105, 'Bestial Wrath': 253, 'Chimera Shot': 254, 'Explosive Shot': 255, 'Arcane Barrage': 62, "Dragon's Breath": 63, Combustion: 63, 'Ice Barrier': 64, 'Deep Freeze': 64, 'Holy Shock': 65, "Avenger's Shield": 66, 'Crusader Strike': 70, // in classic rets are 67 but use 70 for consistency with retail Penance: 256, 'Guardian Spirit': 257, 'Vampiric Touch': 258, Mutilate: 259, 'Killing Spree': 260, // might be wrong? assumed combatID === outlawID Shadowstep: 261, 'Lava Burst': 262, Thunderstorm: 262, 'Feral Spirit': 263, Riptide: 264, Haunt: 265, Metamorphosis: 266, 'Chaos Bolt': 267, 'Mortal Strike': 71, Bloodthirst: 72, Shockwave: 73, }; // Used in addition to the spells above to detect specs in classic. The same // logic applies here; these should be spec unique auras. const classicUniqueSpecAuras: StringKeyToNumberValueMapType = { 'Borrowed Time': 256, 'Unstable Affliction': 265, 'The Art of War': 70, // in classic rets are 67 but use 70 for consistency with retail }; // Taken from https://wago.tools/db2/MapChallengeMode?build=11.0.2.55789. const mopChallengeModes: Record = { 2: 'Temple of the Jade Serpent', 56: 'Stormstout Brewery', 57: 'Gate of the Setting Sun', 58: 'Shado-Pan Monastery', 59: 'Siege of Niuzao Temple', 60: "Mogu'shan Palace", 76: 'Scholomance', 77: 'Scarlet Halls', 78: 'Scarlet Monastery', }; // Gold, silver, bronze timers for Mists of Pandaria challenge // modes. Taken from in-game Challenges panel. const mopChallengeModesTimers: Record = { 2: [45, 25, 15], // Temple of the Jade Serpent 56: [45, 21, 12], // Stormstout Brewery 57: [45, 22, 13], // Gate of the Setting Sun 58: [60, 35, 21], // Shado-Pan Monastery 59: [50, 30, 17.5], // Siege of Niuzao Temple 60: [45, 21, 12], // Mogu'shan Palace 76: [55, 33, 19], // Scholomance 77: [45, 25, 13], // Scarlet Halls 78: [45, 22, 13], // Scarlet Monastery }; const wowInstallSearchPaths = [ 'C:\\World of Warcraft', 'C:\\Program Files\\World of Warcraft', 'C:\\Program Files (x86)\\World of Warcraft', 'D:\\World of Warcraft', 'D:\\Program Files\\World of Warcraft', 'D:\\Program Files (x86)\\World of Warcraft', 'E:\\World of Warcraft', 'E:\\Program Files\\World of Warcraft', 'E:\\Program Files (x86)\\World of Warcraft', ]; export { months, categoryTabSx, categoryTabsSx, videoButtonSx, zones, retailArenas, classicArenas, raidEncountersById, retailBattlegrounds, classicBattlegrounds, dungeonsByMapId, dungeonsByZoneId, instanceNamesByZoneId, dungeonTimersByMapId, dungeonAffixesById, dungeonEncounters, specializationById, instanceDifficulty, instanceEncountersById, InstanceDifficultyType, raidInstances, categoryRecordingSettings, classicUniqueSpecSpells, classicUniqueSpecAuras, retailUniqueSpecSpells, obsResolutions, WoWCharacterClassType, WoWClassColor, scrollBarSx, currentRetailEncounters, mopChallengeModes, mopChallengeModesTimers, wowInstallSearchPaths, }; ================================================ FILE: src/main/keystone.ts ================================================ enum TimelineSegmentType { BossEncounter = 'Boss', Trash = 'Trash', } type RawChallengeModeTimelineSegment = { segmentType?: TimelineSegmentType; logStart?: string; timestamp?: number; encounterId?: number; logEnd?: string; result?: string; }; class ChallengeModeTimelineSegment { logEnd: Date; result?: boolean; constructor( public segmentType: TimelineSegmentType, public logStart: Date, public timestamp: number, public encounterId?: number, ) { // Initially, let's set this to log start date to avoid logEnd // potentially being undefined. this.logEnd = logStart; } length(): number { return this.logEnd.getTime() - this.logStart.getTime(); } getRaw(): RawChallengeModeTimelineSegment { const rawSegment: RawChallengeModeTimelineSegment = { segmentType: this.segmentType, logStart: this.logStart.toISOString(), logEnd: this.logEnd.toISOString(), timestamp: this.timestamp, }; if (this.encounterId !== undefined) { rawSegment.encounterId = this.encounterId; } return rawSegment; } } export { TimelineSegmentType, ChallengeModeTimelineSegment, RawChallengeModeTimelineSegment, }; ================================================ FILE: src/main/main.ts ================================================ import path from 'path'; import { app, BrowserWindow, shell, ipcMain, dialog, Tray, Menu, clipboard, protocol, } from 'electron'; import os from 'os'; import { uIOhook } from 'uiohook-napi'; import assert from 'assert'; import { getLocalePhrase, Language } from 'localisation/translations'; import { resolveHtmlPath, openSystemExplorer, setupApplicationLogging, getAvailableDisplays, getAssetPath, handleSafeVodRequest, runFirstTimeSetupActionsObs, runFirstTimeSetupActionsNoObs, } from './util'; import { OurDisplayType, SoundAlerts, VideoPlayerSettings } from './types'; import ConfigService from '../config/ConfigService'; import Manager from './Manager'; import AppUpdater from './AppUpdater'; import MenuBuilder from './menu'; import { Phrase } from 'localisation/phrases'; import CloudClient from 'storage/CloudClient'; import DiskClient from 'storage/DiskClient'; import Poller from 'utils/Poller'; import Recorder from './Recorder'; import AsyncQueue from 'utils/AsyncQueue'; const logDir = setupApplicationLogging(); const appVersion = app.getVersion(); const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; const tzOffset = new Date().getTimezoneOffset() * -1; // Offset is wrong direction so flip it. const tzOffsetStr = `UTC${tzOffset >= 0 ? '+' : ''}${tzOffset / 60}`; console.info('[Main] App starting, version:', appVersion); console.info('[Main] Node version', process.versions.node); console.info('[Main] ICU version', process.versions.icu); console.info('[Main] On OS:', os.platform(), os.release()); console.info('[Main] In timezone:', tz, tzOffsetStr); let window: BrowserWindow | null = null; let tray: Tray | null = null; const manager = new Manager(); /** * Create a settings store to handle the config. * This defaults to a path like: * - (prod) "C:\Users\alexa\AppData\Roaming\WarcraftRecorder\config-v3.json" * - (dev) "C:\Users\alexa\AppData\Roaming\Electron\config-v3.json" */ const cfg = ConfigService.getInstance(); // Don't bother to signal any changes to the frontend here, they Window isn't // shown yet so they can't have opened the settings. const firstTimeSetup = cfg.get('firstTimeSetup'); if (firstTimeSetup) { // Things we want to do before we initialize OBS. console.info('[Main] Run first time setup actions'); runFirstTimeSetupActionsNoObs(); cfg.set('firstTimeSetup', false); // This gets done again when we default the encoder. } // It's a common problem that hardware acceleration causes rendering issues. // Unclear why this happens and surely not an application bug but we can // make it easy for users to disable it if they want to. if (!cfg.get('hardwareAcceleration')) { console.info('[Main] Disabling hardware acceleration'); app.disableHardwareAcceleration(); } // Register the vod:// protocol as privileged. Required to securely play // videos from disk. protocol.registerSchemesAsPrivileged([ { scheme: 'vod', privileges: { bypassCSP: true, standard: true, stream: true, supportFetchAPI: true, }, }, ]); /** * Default the video player settings on app start. */ const videoPlayerSettings: VideoPlayerSettings = { muted: false, volume: 1, }; if (process.env.NODE_ENV === 'production') { const sourceMapSupport = require('source-map-support'); sourceMapSupport.install(); } const isDebug = process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'; if (isDebug) { require('electron-debug').default(); } const installExtensions = async () => { const installer = require('electron-devtools-installer'); const forceDownload = !!process.env.UPGRADE_EXTENSIONS; const extensions = ['REACT_DEVELOPER_TOOLS']; return installer .default( extensions.map((name) => installer[name]), forceDownload, ) .catch(console.log); }; /** * Setup tray icon, menu and event listeners. */ const setupTray = () => { tray = new Tray(getAssetPath('./icon/small-icon.png')); // This wont update without an app restart but whatever. const language = cfg.get('language') as Language; const contextMenu = Menu.buildFromTemplate([ { label: getLocalePhrase(language, Phrase.SystemTrayOpen), click() { console.info('[Main] User clicked open on tray icon'); if (window) window.show(); }, }, { label: getLocalePhrase(language, Phrase.SystemTrayQuit), click() { console.info('[Main] User clicked close on tray icon'); if (window) { window.close(); } }, }, ]); tray.setToolTip('Warcraft Recorder'); tray.setContextMenu(contextMenu); tray.on('double-click', () => { console.info('[Main] User double clicked tray icon'); if (window) { window.show(); } }); }; /** * Creates the main window. */ const createWindow = async () => { if (isDebug) { await installExtensions(); } window = new BrowserWindow({ show: false, height: 1020 * 0.9, width: 1980 * 0.8, icon: getAssetPath('./icon/small-icon.png'), frame: false, title: `Warcraft Recorder v${appVersion}`, webPreferences: { sandbox: true, // Good security practice. preload: app.isPackaged ? path.join(__dirname, 'preload.js') : path.join(__dirname, '../../.erb/dll/preload.js'), }, }); // We need to do this AFTER creating the window as it's used by the preview. Recorder.getInstance().initializeObs(); await manager.startup(); if (firstTimeSetup) { console.info('[Main] Run first time setup actions'); runFirstTimeSetupActionsObs(); cfg.set('firstTimeSetup', false); } // This gets hit on a user triggering refresh with CTRL-R. window.on('ready-to-show', async () => { console.log('[Main] Ready to show'); const status = app.getGPUFeatureStatus(); const info = await app.getGPUInfo('complete'); console.info('[Main] GPU info', { status, info }); if (!window) { throw new Error('window is not defined'); } // This shows the correct version on a release build, not during development. window.webContents.send( 'updateVersionDisplay', `Warcraft Recorder v${appVersion}`, ); const startMinimized = cfg.get('startMinimized'); if (!startMinimized) window.show(); // Important to refresh status and videos after a user triggered // refresh, otherwise the frontend will be in its default state // which may not reflect reality. const disk = DiskClient.getInstance(); const cloud = CloudClient.getInstance(); await Promise.all([ manager.refreshStatus(), disk.refreshStatus(), disk.refreshVideos(), cloud.refreshStatus(), cloud.refreshVideos(), ]); manager.pushAdvancedLoggingStatus(); }); window.on('focus', () => { window?.webContents.send('window-focus-status', true); }); window.on('blur', () => { window?.webContents.send('window-focus-status', false); }); window.on('closed', () => { window = null; }); await window.loadURL(resolveHtmlPath('index.html')); setupTray(); // Open urls in the user's browser window.webContents.setWindowOpenHandler((edata) => { shell.openExternal(edata.url); return { action: 'deny' }; }); uIOhook.start(); // Runs the auto-updater, which checks GitHub for new releases // and will prompt the user if any are available. new AppUpdater(window); }; /** * window event listeners. */ ipcMain.on('window', (_event, args) => { if (window === null) return; if (args[0] === 'minimize') { console.info('[Main] User clicked minimize'); if (cfg.get('minimizeToTray')) { console.info('[Main] Minimize main window to tray'); window.webContents.send('pausePlayer'); window.hide(); } else { console.info('[Main] Minimize main window to taskbar'); window.minimize(); } } if (args[0] === 'resize') { console.info('[Main] User clicked resize'); if (window.isMaximized()) { window.unmaximize(); } else { window.maximize(); } } if (args[0] === 'quit') { console.info('[Main] User clicked quit button'); if (cfg.get('minimizeOnQuit')) { console.info('[Main] Hiding main window'); window.webContents.send('pausePlayer'); window.hide(); } else { console.info('[Main] Closing main window'); window.close(); } } }); /** * Opens a system explorer window to select a path. */ ipcMain.handle('selectPath', async () => { if (!window) { return ''; } const result = await dialog.showOpenDialog(window, { properties: ['openDirectory'], }); if (result.canceled) { console.info('[Main] User cancelled path selection'); return ''; } return result.filePaths[0]; }); /** * Opens a system explorer window to select a path. */ ipcMain.handle('selectFile', async () => { if (!window) { return ''; } const result = await dialog.showOpenDialog(window); if (result.canceled) { console.info('[Main] User cancelled file selection'); return ''; } return result.filePaths[0]; }); /** * Opens a system explorer window to select a path. */ ipcMain.handle('selectImage', async () => { if (!window) { return ''; } const result = await dialog.showOpenDialog(window, { properties: ['openFile'], filters: [{ name: 'Images', extensions: ['gif', 'png'] }], }); if (result.canceled) { console.info('[Main] User cancelled file selection'); return ''; } return result.filePaths[0]; }); /** * Listener to open the folder containing the Warcraft Recorder logs. */ ipcMain.on('logPath', (_event, args) => { if (args[0] === 'open') { openSystemExplorer(logDir); } }); /** * Listener to write to clipboard. */ ipcMain.on('writeClipboard', (_event, args) => { clipboard.writeText(args[0] as string); }); // Enforces serial execution of calls to reconfigureBase. Also has a limit // of 1 queued task and will drop any extra tasks, which is appropriate for // deduplicating reconfigure work. const reconfigureBaseQueue = new AsyncQueue(1); /** * A reconfig is triggered when a base setting changes. */ ipcMain.on('reconfigureBase', () => { console.info('[Main] Queue a reconfigure'); reconfigureBaseQueue.add(() => manager.reconfigureBase()); }); /** * Opens a URL in the default browser. */ ipcMain.on('openURL', (event, args) => { event.preventDefault(); require('electron').shell.openExternal(args[0]); }); /** * Get all displays. */ ipcMain.handle('getAllDisplays', (): OurDisplayType[] => { return getAvailableDisplays(); }); const refreshCloudGuilds = async () => { console.info('[Main] Frontend triggered cloud guilds refresh'); const client = CloudClient.getInstance(); await client.fetchAffiliations(true); client.refreshStatus(); }; ipcMain.on('refreshCloudGuilds', refreshCloudGuilds); ipcMain.handle('getOrCreateChatCorrelator', async (event, video) => { const client = CloudClient.getInstance(); return client.getOrCreateChatCorrelator(video); }); ipcMain.handle('getChatMessages', async (event, correlator) => { const client = CloudClient.getInstance(); return client.getChatMessages(correlator); }); ipcMain.on('postChatMessage', (event, correlator, message) => { const client = CloudClient.getInstance(); client.postChatMessage(correlator, message); }); ipcMain.on('deleteChatMessage', (event, id) => { const client = CloudClient.getInstance(); client.deleteChatMessage(id); }); /** * Set/get global video player settings. */ ipcMain.on('videoPlayerSettings', (event, args) => { const action = args[0]; if (action === 'get') { event.returnValue = videoPlayerSettings; return; } if (action === 'set') { const settings = args[1] as VideoPlayerSettings; videoPlayerSettings.muted = settings.muted; videoPlayerSettings.volume = settings.volume; } }); /** * Shutdown the app if all windows closed. */ app.on('window-all-closed', async () => { console.info('[Main] User closed app'); app.quit(); }); /** * Before quit events, also called invoked the automatic quit on upgrade. */ app.on('before-quit', () => { console.info('[Main] Running before-quit actions'); if (tray) { console.info('[Main] Destroy tray icon'); tray.destroy(); tray = null; } Poller.getInstance().stop(); uIOhook.stop(); Recorder.getInstance().shutdownOBS(); }); /** * App start-up. */ app .whenReady() .then(() => { console.info('[Main] App ready'); const singleInstanceLock = app.requestSingleInstanceLock(); if (!singleInstanceLock) { console.warn('[Main] Blocked attempt to launch a second instance'); app.quit(); return; } app.on('second-instance', () => { console.info('[Main] Second instance attempted, will restore app'); if (!window) return; if (window.isMinimized()) window.restore(); window.show(); window.focus(); }); new MenuBuilder().buildMenu(); // Required by the video player to safely play files from disk. protocol.handle('vod', handleSafeVodRequest); createWindow(); }) .catch(console.error); const send = (channel: string, ...args: unknown[]) => { if (!window || window.isDestroyed()) return; // Can happen on shutdown. window.webContents.send(channel, ...args); }; const playSoundAlert = (alert: SoundAlerts) => { if (!window || window.isDestroyed()) return; // Can happen on shutdown. console.info('[Main] Playing sound alert', alert); send('playAudio', alert); }; const getNativeWindowHandle = () => { assert(window); assert(!window.isDestroyed()); // Can't tolerate this here. But this shouldn't be possible. return window.getNativeWindowHandle(); }; export { send, getNativeWindowHandle, playSoundAlert }; ================================================ FILE: src/main/menu.ts ================================================ import { Menu, MenuItemConstructorOptions } from 'electron'; export default class MenuBuilder { constructor() {} buildMenu(): Menu { const template = this.buildDefaultTemplate(); const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); return menu; } private buildDefaultTemplate(): MenuItemConstructorOptions[] { const developSubmenu: MenuItemConstructorOptions[] = [ { label: 'Reload', accelerator: 'CommandOrControl+R', role: 'reload', }, { label: 'Toggle Developer Tools', role: 'toggleDevTools', accelerator: 'CommandOrControl+Shift+I', }, { label: 'Zoom In', accelerator: 'CommandOrControl+Plus', role: 'zoomIn', visible: false, enabled: true, }, { label: 'Zoom In Fix', accelerator: 'CommandOrControl+=', role: 'zoomIn', visible: false, enabled: true, }, { label: 'Zoom Out', accelerator: 'CommandOrControl+-', role: 'zoomOut', visible: false, enabled: true, }, { label: 'Zoom Out', accelerator: 'CommandOrControl+0', role: 'resetZoom', visible: false, enabled: true, }, ]; return [ { label: 'View', submenu: developSubmenu, visible: false, enabled: true }, ]; } } ================================================ FILE: src/main/obsEnums.ts ================================================ export const enum ERecordingState { None = 'none', Recording = 'recording', } export const enum EOBSOutputSignal { Starting = 'starting', Start = 'start', Stopping = 'stopping', Stop = 'stop', Activate = 'activate', Deactivate = 'deactivate', } export enum ESupportedEncoders { OBS_X264 = 'obs_x264', AMD_H264 = 'h264_texture_amf', AMD_AV1 = 'av1_texture_amf', NVENC_H264 = 'obs_nvenc_h264_tex', NVENC_AV1 = 'obs_nvenc_av1_tex', QSV_H264 = 'obs_qsv11_soft_v2', QSV_AV1 = 'obs_qsv11_av1', } export enum QualityPresets { ULTRA = 'Ultra', HIGH = 'High', MODERATE = 'Moderate', LOW = 'Low', } export const enum CaptureMode { WINDOW, GAME, MONITOR, NONE, } ================================================ FILE: src/main/preload.ts ================================================ import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; import { ObsProperty, SceneItemPosition, SourceDimensions } from 'noobs'; import { AudioSourceType, RendererVideo, SceneItem } from './types'; import { TChatMessageWithId } from 'types/api'; export type Channels = | 'window' | 'videoButtonDisk' | 'videoButtonCloud' | 'logPath' | 'openURL' | 'test' | 'getAllDisplays' | 'videoPlayerSettings' | 'recorder' | 'config' | 'getEncoders' | 'selectPath' | 'selectImage' | 'selectFile' | 'getNextKeyPress' | 'clip' | 'deleteVideosDisk' | 'deleteVideosCloud' | 'writeClipboard' | 'getShareableLink' | 'doAppUpdate' | 'volmeter' | 'audioSettingsOpen' | 'audioSettingsClosed' | 'updateSourcePos' | 'createAudioSource' | 'getAudioSourceProperties' | 'deleteAudioSource' | 'setAudioSourceDevice' | 'setAudioSourceWindow' | 'getDisplayInfo' | 'configurePreview' | 'showPreview' | 'hidePreview' | 'disablePreview' | 'getSourcePosition' | 'setSourcePosition' | 'resetSourcePosition' | 'setForceMono' | 'setAudioSuppression' | 'setCaptureCursor' | 'reconfigureBase' | 'reconfigureVideo' | 'reconfigureAudio' | 'reconfigureOverlay' | 'reconfigureCloud' | 'getSensibleEncoderDefault' | 'refreshCloudGuilds'; contextBridge.exposeInMainWorld('electron', { ipcRenderer: { sendMessage(channel: Channels, args: unknown[]) { ipcRenderer.send(channel, args); }, sendSync(channel: Channels, args: unknown[]) { return ipcRenderer.sendSync(channel, args); }, invoke(channel: Channels, args: unknown[]) { return ipcRenderer.invoke(channel, args); }, on(channel: Channels, func: (...args: unknown[]) => void) { const subscription = (_event: IpcRendererEvent, ...args: unknown[]) => func(...args); ipcRenderer.on(channel, subscription); return () => ipcRenderer.removeListener(channel, subscription); }, once(channel: Channels, func: (...args: unknown[]) => void) { ipcRenderer.once(channel, (_event, ...args) => func(...args)); }, removeAllListeners(channel: Channels) { ipcRenderer.removeAllListeners(channel); }, getDisplayInfo(): Promise<{ canvasWidth: number; canvasHeight: number; previewWidth: number; previewHeight: number; }> { return ipcRenderer.invoke('getDisplayInfo'); }, // This is async as it's useful to wait for the configuration to complete // before triggering frontend updates. configurePreview(x: number, y: number, width: number, height: number) { ipcRenderer.send('configurePreview', x, y, width, height); }, showPreview() { ipcRenderer.send('showPreview'); }, hidePreview() { ipcRenderer.send('hidePreview'); }, disablePreview() { ipcRenderer.send('disablePreview'); }, getSourcePosition( src: SceneItem, ): Promise { return ipcRenderer.invoke('getSourcePosition', src); }, setSourcePosition( src: SceneItem, target: { x: number; y: number; width: number; height: number; cropLeft: number; cropRight: number; cropTop: number; cropBottom: number; }, ) { ipcRenderer.send('setSourcePosition', src, target); }, resetSourcePosition(src: SceneItem) { ipcRenderer.send('resetSourcePosition', src); }, audioSettingsOpen(): Promise { return ipcRenderer.invoke('audioSettingsOpen'); }, audioSettingsClosed(): Promise { return ipcRenderer.invoke('audioSettingsClosed'); }, // Also returns the properties. createAudioSource(id: string, type: AudioSourceType): Promise { return ipcRenderer.invoke('createAudioSource', id, type); }, getAudioSourceProperties(id: string): Promise { return ipcRenderer.invoke('getAudioSourceProperties', id); }, deleteAudioSource(id: string): void { ipcRenderer.send('deleteAudioSource', id); }, setAudioSourceDevice(id: string, device: string): void { ipcRenderer.send('setAudioSourceDevice', id, device); }, setAudioSourceWindow(id: string, window: string): void { ipcRenderer.send('setAudioSourceWindow', id, window); }, setAudioSourceVolume(id: string, volume: number): void { ipcRenderer.send('setAudioSourceVolume', id, volume); }, setForceMono(enabled: boolean) { ipcRenderer.send('setForceMono', enabled); }, setAudioSuppression(enabled: boolean) { ipcRenderer.send('setAudioSuppression', enabled); }, reconfigureBase() { ipcRenderer.send('reconfigureBase'); }, reconfigureVideo() { ipcRenderer.send('reconfigureVideo'); }, reconfigureAudio() { ipcRenderer.send('reconfigureAudio'); }, reconfigureOverlay() { ipcRenderer.send('reconfigureOverlay'); }, reconfigureCloud() { ipcRenderer.send('reconfigureCloud'); }, getSensibleEncoderDefault(): Promise { return ipcRenderer.invoke('getSensibleEncoderDefault'); }, refreshCloudGuilds() { ipcRenderer.send('refreshCloudGuilds'); }, getOrCreateChatCorrelator(video: RendererVideo): Promise { return ipcRenderer.invoke('getOrCreateChatCorrelator', video); }, getChatMessages(correlator: string): Promise { return ipcRenderer.invoke('getChatMessages', correlator); }, postChatMessage(correlator: string, message: string) { ipcRenderer.send('postChatMessage', correlator, message); }, deleteChatMessage(id: number) { ipcRenderer.send('deleteChatMessage', id); }, toggleManualRecording() { ipcRenderer.send('toggleManualRecording'); }, forceStopRecording() { ipcRenderer.send('forceStopRecording'); }, createKillVideo( width: number, height: number, fps: number, sources: RendererVideo[], audioTrackIndex: number, ) { ipcRenderer.send( 'createKillVideo', width, height, fps, sources, audioTrackIndex, ); }, clipVideo(video: RendererVideo, offset: number, duration: number) { ipcRenderer.send('clip', video, offset, duration); }, }, }); ================================================ FILE: src/main/types.ts ================================================ import { Size } from 'electron'; import { Language } from '../localisation/translations'; import { RawChallengeModeTimelineSegment } from './keystone'; import { VideoCategory } from '../types/VideoCategory'; import { Tag } from 'react-tag-autocomplete'; import { DateValueType } from 'react-tailwindcss-datepicker'; import { QualityPresets } from './obsEnums'; /** * Application recording status. */ enum Flavour { Retail = 'Retail', Classic = 'Classic', } /** * Application recording status. */ enum RecStatus { WaitingForWoW, Recording, InvalidConfig, ReadyToRecord, FatalError, Overrunning, Reconfiguring, } type ActivityStatus = { category: VideoCategory; start: number; }; enum MicStatus { NONE, MUTED, LISTENING, } /** * Application saving status. */ enum SaveStatus { Saving, NotSaving, } /** * Kill video creation status. */ type KillVideoStatus = { queued: number; perc: number; }; /** * We display any OBS crashes on the frontend so we don't silently recover * and have the user think all is well. */ type ErrorReport = { date: Date; reason: string; }; /** * Unit flags from combat log events * See https://wowpedia.fandom.com/wiki/UnitFlag for more information */ enum UnitFlags { AFFILIATION_MINE = 0x00000001, AFFILIATION_PARTY = 0x00000002, AFFILIATION_RAID = 0x00000004, AFFILIATION_OUTSIDER = 0x00000008, AFFILIATION_MASK = 0x0000000f, // Reaction REACTION_FRIENDLY = 0x00000010, REACTION_NEUTRAL = 0x00000020, REACTION_HOSTILE = 0x00000040, REACTION_MASK = 0x000000f0, // Controller CONTROL_PLAYER = 0x00000100, CONTROL_NPC = 0x00000200, CONTROL_MASK = 0x00000300, // Type TYPE_PLAYER = 0x00000400, // Units directly controlled by players. TYPE_NPC = 0x00000800, // Units controlled by the server. TYPE_PET = 0x00001000, // Pets are units controlled by a player or NPC, including via mind control. TYPE_GUARDIAN = 0x00002000, // Units that are not controlled, but automatically defend their master. TYPE_OBJECT = 0x00004000, // Objects are everything else, such as traps and totems. TYPE_MASK = 0x0000fc00, // Special cases (non-exclusive) TARGET = 0x00010000, FOCUS = 0x00020000, MAINTANK = 0x00040000, MAINASSIST = 0x00080000, NONE = 0x80000000, // Whether the unit does not exist. SPECIAL_MASK = 0xffff0000, } /** * Type that describes the player deaths that are detected and stored * with the metadata for a video. */ type PlayerDeathType = { name: string; specId: number; date: Date; timestamp: number; friendly: boolean; }; /** * Type that describes selected video player settings that we want to keep * across changes in the UI like selecting a new video, new category, etc. */ type VideoPlayerSettings = { muted: boolean; volume: number; }; enum FileSortDirection { NewestFirst, OldestFirst, } /** * Signature for the file finder getSortedFiles() to be used as typing * for methods/classes that accept it being injected. */ type FileFinderCallbackType = ( dir: string, pattern: string, sortDirection?: FileSortDirection, ) => Promise; /** * Specifies the format that we use in Settings to display monitors * to the user. */ type OurDisplayType = { id: number; index: number; physicalPosition: string; primary: boolean; displayFrequency: number; depthPerComponent: number; size: Size; physicalSize: Size; aspectRatio: number; scaleFactor: number; }; type NumberKeyToStringValueMapType = { [id: number]: string; }; type StringKeyToNumberValueMapType = { [id: string]: number; }; type RaidInstanceType = { zoneId: number; name: string; shortName: string; encounters: NumberKeyToStringValueMapType; }; type FileInfo = { name: string; size: number; mtime: number; birthTime: number; }; type VideoQueueItem = { name: string; // Can be an OBS timestamp if recording or more complicated if clipping. source: string; // Can be either a path or a URL. suffix: string; // Typically details of the recording, but can also be a "clipped at ..." description. offset: number; duration: number; clip: boolean; metadata: Metadata; }; /** * This is what we write to the .json files. We use "raw" subtypes here to * represent any classes as writing entire classes to JSON files causes * problems on the frontend. */ type Metadata = { category: VideoCategory; parentCategory?: VideoCategory; // present if it's a clip duration: number; start?: number; // epoch start time of activity clippedAt?: number; // epoch time of clipping result: boolean; flavour: Flavour; zoneID?: number; zoneName?: string; encounterID?: number; difficultyID?: number; difficulty?: string; player?: RawCombatant; teamMMR?: number; deaths?: PlayerDeathType[]; upgradeLevel?: number; mapID?: number; challengeModeTimeline?: RawChallengeModeTimelineSegment[]; soloShuffleTimeline?: SoloShuffleTimelineSegment[]; level?: number; // back compatibility pre-cloud keystoneLevel?: number; encounterName?: string; protected?: boolean; soloShuffleRoundsWon?: number; soloShuffleRoundsPlayed?: number; combatants: RawCombatant[]; overrun: number; affixes?: number[]; tag?: string; delete?: boolean; // signals video should be deleted when possible uniqueHash?: string; // used for cloud video grouping bossPercent?: number; appVersion?: string; }; /** * We mandata some fields are present for cloud videos that are optional for * disk based videos. */ type CloudMetadata = Metadata & { videoName: string; videoKey: string; start: number; uniqueHash: string; }; /** * When we retrieve state from the WCR API, we have a few additional entries * in the data, these are signed by the API so that we can read them without * the client having credentials. */ type CloudSignedMetadata = CloudMetadata & { signedVideoKey: string; }; /** * All fields in the raw type can be undefined to force us to check them * before use. In theory anything can be present or not present in the * metadata files. */ type RawCombatant = { _GUID?: string; _teamID?: number; _specID?: number; _name?: string; _realm?: string; _region?: string; }; /** * Frontend metadata type, this is Metadata above plus a bunch of fields we * add when reading the file. */ type RendererVideo = Metadata & { videoName: string; mtime: number; videoSource: string; isProtected: boolean; cloud: boolean; multiPov: RendererVideo[]; // Used by frontend to uniquely identify a video, as videoName // is identical for a disk and cloud viewpoint. uniqueId: string; }; type SoloShuffleTimelineSegment = { round: number; timestamp: number; result: boolean; duration?: number; }; enum EDeviceType { audioInput = 'audioInput', audioOutput = 'audioOutput', videoInput = 'videoInput', } interface IOBSDevice { id: string; description: string; } interface IDevice { id: string; type: EDeviceType; description: string; } enum AudioSourceType { OUTPUT = 'wasapi_output_capture', INPUT = 'wasapi_input_capture', PROCESS = 'wasapi_process_output_capture', } type AudioSource = { id: string; // The source name type: AudioSourceType; friendly?: string; // A user-friendly name for the source device?: string | number; // Machine friendly identifier for the device or window, I think this can only be a string in practice. volume: number; // Current volume setting (0-1) }; /** * If we should be showing a certain page. This always takes priority over anything * else in TNavigatorState. */ enum Pages { 'None', 'SceneEditor', 'Settings', } /** * Storage filtering options. */ enum StorageFilter { DISK = 'Disk', CLOUD = 'Cloud', BOTH = 'Both', } /** * The state of the frontend. */ type AppState = { page: Pages; category: VideoCategory; selectedVideos: RendererVideo[]; multiPlayerMode: boolean; viewpointSelectionOpen: boolean; videoFilterTags: Tag[]; dateRangeFilter: DateValueType; storageFilter: StorageFilter; videoFullScreen: boolean; playing: boolean; language: Language; cloudStatus: CloudStatus; diskStatus: DiskStatus; chatOpen: boolean; preferredViewpoint: string; }; type AdvancedLoggingStatus = { retail: boolean; classic: boolean; era: boolean; retailPtr: boolean; classicPtr: boolean; }; type CloudState = { uploadProgress: number; downloadProgress: number; queuedUploads: number; queuedDownloads: number; }; type TPreviewPosition = { width: number; height: number; xPos: number; yPos: number; }; enum DeviceType { INPUT, OUTPUT, PROCESS, } enum EncoderType { HARDWARE, SOFTWARE, } type Encoder = { name: string; value: string; type: EncoderType; }; type BaseConfig = { storagePath: string; maxStorage: number; obsPath: string; obsOutputResolution: string; obsFPS: number; obsQuality: string; obsRecEncoder: string; recordRetail: boolean; retailLogPath: string; recordClassic: boolean; recordClassicPtr: boolean; classicLogPath: string; classicPtrLogPath: string; recordEra: boolean; eraLogPath: string; recordRetailPtr: boolean; retailPtrLogPath: string; validateLogPaths: boolean; }; type ObsVideoConfig = { obsCaptureMode: string; monitorIndex: number; captureCursor: boolean; forceSdr: boolean; videoSourceScale: number; videoSourceXPosition: number; videoSourceYPosition: number; }; type ObsOverlayConfig = { chatOverlayEnabled: boolean; chatOverlayOwnImage: boolean; chatOverlayOwnImagePath: string; chatOverlayScale: number; chatOverlayXPosition: number; chatOverlayYPosition: number; chatOverlayCropX: number; chatOverlayCropY: number; }; type ObsAudioConfig = { audioSources: AudioSource[]; obsAudioSuppression: boolean; obsForceMono: boolean; pushToTalk: boolean; pushToTalkKey: number; pushToTalkMouseButton: number; pushToTalkModifiers: string; }; type CloudConfig = { cloudStorage: boolean; cloudUpload: boolean; cloudAccountName: string; cloudAccountPassword: string; cloudGuildName: string; }; enum DeathMarkers { NONE = 'None', OWN = 'Own', ALL = 'All', } enum MarkerColors { WIN = 'rgba(30, 255, 0, 1)', LOSS = 'rgba(255, 0, 0, 1)', ENCOUNTER = 'rgba(163, 53, 238, 1)', } type VideoMarker = { time: number; duration: number; text: string; color: string; }; type SliderMark = { value: number; label: JSX.Element; }; type CloudStatus = { enabled: boolean; authenticated: boolean; authorized: boolean; guild: string; available: string[]; read: boolean; // Always true for now. write: boolean; del: boolean; usage: number; limit: number; }; type DiskStatus = { usage: number; limit: number; }; type CloudObject = { key: string; size: number; lastMod: Date; }; interface IBrowserWindow { webContents: { send: (channel: string) => void; }; } type UploadQueueItem = { path: string; }; type KillVideoQueueItem = { uuid: string; // unique job uuid width: number; height: number; fps: number; segments: KillVideoSegment[]; audioTrackIndex: number; // -1 for splicing all tracks }; type KillVideoSegment = { video: RendererVideo; start: number; stop: number; }; type CreateMultiPartUploadResponseBody = { urls: string[]; }; type CompleteMultiPartUploadRequestBody = { etags: string[]; key: string; }; export interface ISettingsSubCategory { nameSubCategory: string; codeSubCategory?: string; parameters: TObsFormData; } export type TObsStringList = { value: string }[]; export interface IObsFont { face?: string; flags?: number; size?: number; path?: string; style?: string; } export declare type TObsValue = | number | string | boolean | IObsFont | TObsStringList; export declare type TObsType = | 'OBS_PROPERTY_BOOL' | 'OBS_PROPERTY_INT' | 'OBS_PROPERTY_LIST' | 'OBS_PROPERTY_PATH' | 'OBS_PROPERTY_FILE' | 'OBS_PROPERTY_EDIT_TEXT' | 'OBS_PROPERTY_TEXT' | 'OBS_PROPERTY_UINT' | 'OBS_PROPERTY_COLOR' | 'OBS_PROPERTY_DOUBLE' | 'OBS_PROPERTY_FLOAT' | 'OBS_PROPERTY_SLIDER' | 'OBS_PROPERTY_FONT' | 'OBS_PROPERTY_EDITABLE_LIST' | 'OBS_PROPERTY_BUTTON' | 'OBS_PROPERTY_BITMASK' | 'OBS_INPUT_RESOLUTION_LIST'; export interface IObsInput { value: TValueType; currentValue?: TValueType; name: string; description: string; showDescription?: boolean; enabled?: boolean; visible?: boolean; masked?: boolean; type: TObsType; } export interface IObsListOption { description: string; value: TValue; } export interface IObsListInput extends IObsInput { options: IObsListOption[]; } export declare type TObsFormData = ( | IObsInput | IObsListInput )[]; type ObsSourceCallbackInfo = { name: string; width: number; height: number; flags: number; }; type ObsVolmeterCallbackInfo = { sourceName: string; magnitude: number[]; peak: number[]; inputPeak: number[]; }; enum SceneItem { OVERLAY = 'Overlay', GAME = 'Game', } enum SceneInteraction { NONE, MOVE, SCALE, } type BoxDimensions = { x: number; y: number; width: number; height: number; cropLeft: number; cropRight: number; cropTop: number; cropBottom: number; }; enum VideoSourceName { WINDOW = 'WCR Window Capture', GAME = 'WCR Game Capture', MONITOR = 'WCR Monitor Capture', OVERLAY = 'WCR Chat Overlay', } enum AudioSourcePrefix { SPEAKER = 'WCR Speaker Capture', MIC = 'WCR Mic Capture', PROCESS = 'WCR Process Capture', } enum WowProcessEvent { STARTED = 'wowProcessStart', STOPPED = 'wowProcessStop', } enum SoundAlerts { MANUAL_RECORDING_ERROR = 'manual-recording-error', MANUAL_RECORDING_START = 'manual-recording-start', MANUAL_RECORDING_STOP = 'manual-recording-stop', } export { RecStatus, SaveStatus, UnitFlags, PlayerDeathType, VideoPlayerSettings, FileSortDirection, OurDisplayType, NumberKeyToStringValueMapType, StringKeyToNumberValueMapType, RaidInstanceType, FileInfo, FileFinderCallbackType, VideoQueueItem, Metadata, RendererVideo, Flavour, SoloShuffleTimelineSegment, EDeviceType, IOBSDevice, IDevice, AudioSourceType, AppState, RawCombatant, TPreviewPosition, DeviceType, Pages, EncoderType, Encoder, BaseConfig, ObsVideoConfig, ObsOverlayConfig, ObsAudioConfig, CloudConfig, DeathMarkers, VideoMarker, MarkerColors, MicStatus, ErrorReport, SliderMark, CloudStatus, DiskStatus, CloudObject, IBrowserWindow, UploadQueueItem, CloudMetadata, CloudSignedMetadata, CreateMultiPartUploadResponseBody, CompleteMultiPartUploadRequestBody, StorageFilter, ObsSourceCallbackInfo, ObsVolmeterCallbackInfo, VideoSourceName, AudioSource, AudioSourcePrefix, SceneItem, SceneInteraction, BoxDimensions, WowProcessEvent, SoundAlerts, CloudState, ActivityStatus, AdvancedLoggingStatus, KillVideoQueueItem, KillVideoSegment, KillVideoStatus, }; ================================================ FILE: src/main/util.ts ================================================ import { URL } from 'url'; import path from 'path'; import fs, { createReadStream, existsSync, promises as fspromise, Stats, } from 'fs'; import { app, Display, screen } from 'electron'; import { EventType, uIOhook, UiohookKeyboardEvent, UiohookMouseEvent, } from 'uiohook-napi'; import checkDiskSpace from 'check-disk-space'; import { PTTEventType, PTTKeyPressEvent } from '../types/KeyTypesUIOHook'; import { Metadata, FileInfo, FileSortDirection, OurDisplayType, RendererVideo, ObsAudioConfig, ErrorReport, CloudSignedMetadata, KillVideoSegment, } from './types'; import { VideoCategory } from '../types/VideoCategory'; import ConfigService from 'config/ConfigService'; import { AxiosError } from 'axios'; import { send } from './main'; import { Readable } from 'stream'; import { ESupportedEncoders } from './obsEnums'; import Recorder from './Recorder'; import { specializationById, wowInstallSearchPaths } from './constants'; import { getPlayerName, getPlayerSpecID, secToMmSs, } from 'renderer/rendererutils'; /** * When packaged, we need to fix some paths */ const fixPathWhenPackaged = (p: string) => { return p.replace('app.asar', 'app.asar.unpacked'); }; /** * Setup logging. * * This works by overriding console log methods. All console log method will * go to both the console if it exists, and a file on disk. * * This only applies to main process console logs, not the renderer logs. */ const setupApplicationLogging = () => { const log = require('electron-log'); const date = new Date().toISOString().slice(0, 10); const logRelativePath = `logs/WarcraftRecorder-${date}.log`; const logPath = fixPathWhenPackaged(path.join(__dirname, logRelativePath)); log.transports.file.resolvePath = () => logPath; Object.assign(console, log.functions); return path.dirname(logPath); }; const { exec } = require('child_process'); const getResolvedHtmlPath = () => { if (process.env.NODE_ENV === 'development') { const port = process.env.PORT || 1212; return (htmlFileName: string) => { const url = new URL(`http://localhost:${port}`); url.pathname = htmlFileName; return url.href; }; } return (htmlFileName: string) => { return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`; }; }; export const resolveHtmlPath = getResolvedHtmlPath(); /** * Return information about a file needed for various parts of the application */ export const getFileInfo = async (pathSpec: string): Promise => { const filePath = path.resolve(pathSpec); const fstats = await fspromise.stat(filePath); const mtime = fstats.mtime.getTime(); const birthTime = fstats.birthtime.getTime(); const { size } = fstats; return { name: filePath, size, mtime, birthTime }; }; /** * Asynchronously find and return a list of files in the given directory, * that matches the given pattern sorted by modification time according * to `sortDirection`. Ensure to properly escape patterns, e.g. ".*\\.mp4". */ const getSortedFiles = async ( dir: string, pattern: string, sortDirection: FileSortDirection = FileSortDirection.NewestFirst, ): Promise => { // We use fs.promises.readdir here instead of glob, which we used to // use but it caused problems with NFS paths, see this issue: // https://github.com/isaacs/node-glob/issues/74. const files = (await fs.promises.readdir(dir)) .filter((f) => f.match(new RegExp(pattern))) .map((f) => path.join(dir, f)); const mappedFileInfo: FileInfo[] = []; for (let i = 0; i < files.length; i++) { // This loop can take a bit of time so we're deliberately // awaiting inside the loop to not induce a 1000ms periodic // freeze on the frontend. Probably can do better here, // suspect something in getFileInfo isn't as async as it could be. // If that can be solved, then we can drop the await here and then // do an await Promises.all() on the following line. mappedFileInfo.push(await getFileInfo(files[i])); } if (sortDirection === FileSortDirection.NewestFirst) { return mappedFileInfo.sort((A: FileInfo, B: FileInfo) => B.mtime - A.mtime); } return mappedFileInfo.sort((A: FileInfo, B: FileInfo) => A.mtime - B.mtime); }; /** * Get sorted video files. Shorthand for `getSortedFiles()` because it's used in quite a few places */ const getSortedVideos = async ( storageDir: string, sortDirection: FileSortDirection = FileSortDirection.NewestFirst, ): Promise => { return getSortedFiles(storageDir, '.*\\.mp4', sortDirection); }; /** * Get the filename for the metadata file associated with the given video file. */ const getMetadataFileNameForVideo = (video: string) => { const videoFileName = path.basename(video, '.mp4'); const videoDirName = path.dirname(video); return path.join(videoDirName, `${videoFileName}.json`); }; /** * The Korean build of WCR had translated video categories. Now that * they are going to use the main build with the localisation feature, * this translates them back to english so we can process them. This is * purely to bridge the gap, and in theory could be removed in the future. */ const convertKoreanVideoCategory = ( metadata: Metadata | CloudSignedMetadata, ) => { const raw = metadata as any; if (raw.category === '연습전투') { raw.category = VideoCategory.Skirmish; } else if (raw.category === '1인전') { raw.category = VideoCategory.SoloShuffle; } else if (raw.category === '쐐기+') { raw.category = VideoCategory.MythicPlus; } else if (raw.category === '레이드') { raw.category = VideoCategory.Raids; } else if (raw.category === '전장') { raw.category = VideoCategory.Battlegrounds; } else if (raw.category === '클립') { raw.category = VideoCategory.Clips; } if (raw.parentCategory === '연습전투') { raw.parentCategory = VideoCategory.Skirmish; } else if (raw.parentCategory === '1인전') { raw.parentCategory = VideoCategory.SoloShuffle; } else if (raw.parentCategory === '쐐기+') { raw.parentCategory = VideoCategory.MythicPlus; } else if (raw.parentCategory === '레이드') { raw.parentCategory = VideoCategory.Raids; } else if (raw.parentCategory === '전장') { raw.parentCategory = VideoCategory.Battlegrounds; } else if (raw.parentCategory === '클립') { raw.parentCategory = VideoCategory.Clips; } }; /** * Get the metadata object for a video from the accompanying JSON file. */ const getMetadataForVideo = async (video: string) => { const metadataFilePath = getMetadataFileNameForVideo(video); await fspromise.access(metadataFilePath); const metadataJSON = await fspromise.readFile(metadataFilePath); const metadata = JSON.parse(metadataJSON.toString()) as Metadata; convertKoreanVideoCategory(metadata); return metadata; }; /** * Try to unlink a file and return a boolean indicating the success * Logs any errors to the console, if the file couldn't be deleted for some reason. */ const tryUnlink = async (file: string): Promise => { try { console.info(`[Util] Deleting: ${file}`); await fspromise.access(file); await fs.promises.unlink(file); return true; } catch (e) { console.error(`[Util] Unable to delete file: ${file}.`); console.error((e as Error).message); return false; } }; /** * Delete a video and its metadata file if it exists. */ const deleteVideoDisk = async (videoPath: string) => { console.info('[Util] Deleting video', videoPath); const deletedMp4 = await tryUnlink(videoPath); if (!deletedMp4) { return false; } const metadataPath = getMetadataFileNameForVideo(videoPath); const deletedJson = await tryUnlink(metadataPath); return deletedJson; }; /** * Delete a video and it's accompanying files after a short delay. Use case * is when we are mid refresh of the frontend and spot a video marked for * deletion in its metadata. * * We can't always immediately delete the video because the frontend might * have it open, but once the refresh has kicked in we're safe as we won't * display a video marked for delete. * * The timeout of 2000 is somewhat arbitrary, don't want to be too long * in-case we go through multiple refreshes and set a bunch of timers * to delete the same file. Not the end of the world either way, just * looks ugly in logs. */ const delayedDeleteVideo = (video: RendererVideo) => { const src = video.videoSource; console.info('[Util] Will soon remove a video marked for deletion', src); setTimeout(() => { console.info('[Util] Removing a video marked for deletion', src); deleteVideoDisk(video.videoSource); }, 2000); }; /** * Load video details from the metadata and add it to videoState. */ const loadVideoDetailsDisk = async ( video: FileInfo, ): Promise => { try { const metadata = await getMetadataForVideo(video.name); const videoName = path.basename(video.name, '.mp4'); const uniqueId = `${videoName}-disk`; return { ...metadata, videoName, mtime: video.mtime, videoSource: video.name, isProtected: Boolean(metadata.protected), cloud: false, multiPov: [], uniqueId, }; } catch (error) { // Just log it and rethrow. Want this to be diagnosable. console.warn('[Util] Failed to load video:', video.name, String(error)); throw error; } }; /** * Writes video metadata asynchronously and returns a Promise */ const writeMetadataFile = async (videoPath: string, metadata: Metadata) => { console.info('[Util] Write Metadata file for video:', videoPath); const metadataFileName = getMetadataFileNameForVideo(videoPath); const jsonString = JSON.stringify(metadata, null, 2); fspromise.writeFile(metadataFileName, jsonString, { encoding: 'utf-8', }); }; /** * Open a folder in system explorer. */ const openSystemExplorer = (filePath: string) => { const windowsPath = filePath.replace(/\//g, '\\'); const cmd = `explorer.exe /select,"${windowsPath}"`; exec(cmd, () => {}); }; /** * Get a text string that indicates the physical position of a display depending * on its index. */ const getDisplayPhysicalPosition = (count: number, index: number): string => { if (index === 0) return 'Left'; if (index === count - 1) return 'Right'; return `Middle #${index}`; }; /** * Get and return a list of available displays on the system sorted by their * physical position. * * This makes no attempts at being perfect - it completely ignores the `bounds.y` * property for people who might have stacked their displays vertically rather than * horizontally. This is okay. */ const getAvailableDisplays = (): OurDisplayType[] => { const primaryDisplay = screen.getPrimaryDisplay(); const allDisplays = screen.getAllDisplays(); // Create an unsorted list of Display IDs to zero based monitor index // So we're can use that index later, after sorting the displays according // to their physical location. const displayIdToIndex: { [key: number]: number } = {}; allDisplays.forEach((display: Display, index: number) => { displayIdToIndex[display.id] = index; }); // Iterate over all available displays and make our own list with the // relevant attributes and some extra stuff to make it easier for the // frontend. const ourDisplays: OurDisplayType[] = []; const numberOfMonitors = allDisplays.length; allDisplays .sort((A: Display, B: Display) => A.bounds.x - B.bounds.x) .forEach((display: Display, index: number) => { const isPrimary = display.id === primaryDisplay.id; const displayIndex = displayIdToIndex[display.id]; const { width, height } = display.size; ourDisplays.push({ id: display.id, index: displayIndex, physicalPosition: getDisplayPhysicalPosition(numberOfMonitors, index), primary: isPrimary, displayFrequency: display.displayFrequency, depthPerComponent: display.depthPerComponent, size: display.size, scaleFactor: display.scaleFactor, aspectRatio: width / height, physicalSize: { width: Math.floor(width * display.scaleFactor), height: Math.floor(height * display.scaleFactor), }, }); }); return ourDisplays; }; const deferredPromiseHelper = () => { let resolveHelper!: (value: T | PromiseLike) => void; let rejectHelper!: (reason?: any) => void; const promise = new Promise((resolve, reject) => { resolveHelper = resolve; rejectHelper = reject; }); return { resolveHelper, rejectHelper, promise }; }; const getAssetPath = (...paths: string[]): string => { const RESOURCES_PATH = app.isPackaged ? path.join(process.resourcesPath, 'assets') : path.join(__dirname, '../../assets'); return path.join(RESOURCES_PATH, ...paths); }; /** * Find and return the flavour of WoW that the log directory * belongs to by means of the '.flavor.info' file. */ const getWowFlavour = (pathSpec: string): string => { const flavourInfoFile = path.normalize( path.join(pathSpec, '../.flavor.info'), ); // If this file doesn't exist, it's not a subdirectory of a WoW flavour. if (!fs.existsSync(flavourInfoFile)) { return 'unknown'; } const content = fs.readFileSync(flavourInfoFile).toString().split('\n'); return content.length > 1 ? content[1] : 'unknown'; }; /** * Check if advanced combat logging is enabled in the Config.wtf file for the given log path. */ const getConfigWtfPath = (logPath: string): string => { return path.normalize(path.join(logPath, '../WTF/Config.wtf')); }; const checkAdvancedCombatLogging = async ( logPath: string, ): Promise => { const configWtfFile = getConfigWtfPath(logPath); if (!(await exists(configWtfFile))) { console.warn('[Util] Config.wtf not found at', configWtfFile); return false; } const content = (await fs.promises.readFile(configWtfFile)).toString(); const match = content.match(/^SET advancedCombatLogging\s+"(\d+)"/m); if (match && match[1] === '1') { return true; } console.warn('[Util] Advanced combat logging is disabled', configWtfFile); return false; }; /** * Adds an error to the error report component. */ const emitErrorReport = (data: unknown) => { console.error('[Util] Emitting error report', String(data)); const report: ErrorReport = { date: new Date(), reason: String(data), }; send('updateErrorReport', report); }; const isPushToTalkHotkey = ( config: ObsAudioConfig, event: PTTKeyPressEvent, ) => { const { keyCode, mouseButton, altKey, ctrlKey, shiftKey, metaKey } = event; const { pushToTalkKey, pushToTalkMouseButton, pushToTalkModifiers } = config; const buttonMatch = (keyCode > 0 && keyCode === pushToTalkKey) || (mouseButton > 0 && mouseButton === pushToTalkMouseButton); if (event.type === PTTEventType.EVENT_KEY_RELEASED) { // If they release the button we ignore modifier config. That covers mainline // use of regular key and modifier but also naked modifier key as the PTT hoykey // which doesnt show a modifier on release. return buttonMatch; } // Deliberately permissive here, we check all the modifiers we have in // config are met but we don't enforce the inverse, i.e. we'll accept // an additional modifier present (so CTRL + SHIFT + E will trigger // a CTRL + E hotkey). const modifierMatch = pushToTalkModifiers.split(',').reduce((acc, mod) => { if (mod === 'alt') return acc && altKey; if (mod === 'ctrl') return acc && ctrlKey; if (mod === 'shift') return acc && shiftKey; if (mod === 'win') return acc && metaKey; return acc; // Ignore unknown modifiers }, true); return buttonMatch && modifierMatch; }; const isManualRecordHotKey = (event: UiohookKeyboardEvent) => { const { keycode, altKey, ctrlKey, shiftKey, metaKey, type } = event; const cfg = ConfigService.getInstance(); if (type !== EventType.EVENT_KEY_PRESSED) { // We should never hit this but just being safe. return false; } const manualRecordHotKey = cfg.get('manualRecordHotKey'); const manualRecordHotKeyModifiers = cfg.get( 'manualRecordHotKeyModifiers', ); const buttonMatch = keycode > 0 && keycode === manualRecordHotKey; // Deliberately permissive here, we check all the modifiers we have in // config are met but we don't enforce the inverse, i.e. we'll accept // an additional modifier present (so CTRL + SHIFT + E will trigger // a CTRL + E hotkey). const modifierMatch = manualRecordHotKeyModifiers .split(',') .reduce((acc, mod) => { if (mod === 'alt') return acc && altKey; if (mod === 'ctrl') return acc && ctrlKey; if (mod === 'shift') return acc && shiftKey; if (mod === 'win') return acc && metaKey; return acc; // Ignore unknown modifiers }, true); return buttonMatch && modifierMatch; }; const convertUioHookKeyPressEvent = ( event: UiohookKeyboardEvent, type: PTTEventType, ): PTTKeyPressEvent => { return { altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, keyCode: event.keycode, mouseButton: -1, type, }; }; const convertUioHookMousePressEvent = ( event: UiohookMouseEvent, type: PTTEventType, ): PTTKeyPressEvent => { return { altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, keyCode: -1, mouseButton: event.button as number, type, }; }; const convertUioHookEvent = ( event: UiohookKeyboardEvent | UiohookMouseEvent, ): PTTKeyPressEvent => { if (event.type === EventType.EVENT_KEY_PRESSED) { return convertUioHookKeyPressEvent( event as UiohookKeyboardEvent, PTTEventType.EVENT_MOUSE_PRESSED, ); } if (event.type === EventType.EVENT_KEY_RELEASED) { return convertUioHookKeyPressEvent( event as UiohookKeyboardEvent, PTTEventType.EVENT_KEY_RELEASED, ); } if (event.type === EventType.EVENT_MOUSE_PRESSED) { return convertUioHookMousePressEvent( event as UiohookMouseEvent, PTTEventType.EVENT_MOUSE_PRESSED, ); } if (event.type === EventType.EVENT_MOUSE_RELEASED) { return convertUioHookMousePressEvent( event as UiohookMouseEvent, PTTEventType.EVENT_MOUSE_RELEASED, ); } return { altKey: false, shiftKey: false, ctrlKey: false, metaKey: false, keyCode: -1, mouseButton: -1, type: PTTEventType.UNKNOWN, }; }; const nextKeyPressPromise = (): Promise => { return new Promise((resolve) => { uIOhook.once('keyup', (event) => { resolve(convertUioHookEvent(event)); }); }); }; const nextMousePressPromise = (): Promise => { return new Promise((resolve) => { // Deliberatly 'mousedown' else we fire on the initial click // and always get mouse button 1. uIOhook.once('mousedown', (event) => { resolve(convertUioHookEvent(event)); }); }); }; /** * Returns a promise that will reject after a given fuse time. Also provides * handlers to pause the timer, and also to reset the timer to the initial * fuse. */ const getPromiseBomb = (fuse: number, reason: string) => { return new Promise((resolve, reject) => { setTimeout(() => reject(reason), fuse * 1000); }); }; const buildClipMetadata = (initial: Metadata, duration: number, date: Date) => { const final = initial; final.duration = duration; final.parentCategory = initial.category; final.category = VideoCategory.Clips; final.protected = true; final.clippedAt = date.getTime(); return final; }; const buildKillVideoMetadata = ( initial: Metadata, segments: KillVideoSegment[], ) => { const final = initial; final.duration = segments[segments.length - 1].stop; final.parentCategory = initial.category; final.category = VideoCategory.Clips; final.protected = true; final.clippedAt = Date.now(); final.tag = `Multipov Kill Video\n`; // Build a tag for the video like this: // // Multipov Kill Video // Created by WCR at: 2025-10-29 20-38-50 // A YouTube compatible description timeline is below. // // 00:00 - Imprvedziniq (Destruction) // 01:06 - Visk (Arcane) // 02:12 - Phrixosdk (Frost) // 03:18 - Titzy (Elemental) // 04:24 - Alextides (Restoration) // 05:29 - Catza (Restoration) // 06:35 - Meraned (Beast Mastery) // 07:41 - Rubiscodrage (Devastation) if (initial.start) { // All modern videos have this. It is possible legacy videos might not. final.tag += `Created by WCR at: ${getOBSFormattedDate(new Date(initial.start))}\n`; } final.tag += `A YouTube compatible description timeline is below.\n`; segments.forEach((segment) => { let playerName = getPlayerName(segment.video); const playerSpecID = getPlayerSpecID(segment.video); if (!playerName) { // Should basically never happen but guard for it anyway. playerName = 'Unknown'; } const spec = playerSpecID < 1 ? 'Unknown' : specializationById[playerSpecID].name; final.tag += '\n'; final.tag += `${secToMmSs(segment.start)} - ${playerName} (${spec})`; }); final.player = { _GUID: 'WCR MultiPov GUID', _teamID: -1, _specID: -1, _name: 'WCR Multipov Name', _realm: 'WCR Multipov Realm', _region: 'WCR Multipov Region', }; return final; }; const getOBSFormattedDate = (date: Date) => { const toFixedDigits = (n: number, d: number) => n.toLocaleString('en-US', { minimumIntegerDigits: d, useGrouping: false }); const day = toFixedDigits(date.getDate(), 2); const month = toFixedDigits(date.getMonth() + 1, 2); const year = toFixedDigits(date.getFullYear(), 4); const secs = toFixedDigits(date.getSeconds(), 2); const mins = toFixedDigits(date.getMinutes(), 2); const hours = toFixedDigits(date.getHours(), 2); return `${year}-${month}-${day} ${hours}-${mins}-${secs}`; }; /** * Check a disk has the required free space, including any files in the * directory currently. * @param dir folder to check * @param req size required in GB */ const checkDisk = async (dir: string, req: number) => { const files = await getSortedFiles(dir, '.*'); let inUseBytes = 0; files.forEach((file) => { inUseBytes += file.size; }); let space; try { space = await checkDiskSpace(dir); } catch (error) { // If we fail to check how much space is free then just log a warning and // return, we don't want to fail config validation in this case. See issue 478. console.warn('[Util] Failed to get free disk space from OS'); console.warn(String(error)); return; } const disk = space.diskPath; const freeBytes = space.free; const reqBytes = req * 1024 ** 3 - inUseBytes; if (freeBytes < reqBytes) { const msg = `Disk '${disk}' does not have enough free space, needs ${req}GB.`; console.error(`Disk check failed: ${msg}`); throw new Error(msg); } }; /** * We use start as the preference here: the genuine start date of the activity. * It was only added for cloud support, so if it doesn't exist, fallback to * the mtime which is particularly worse on cloud storage. It worked fine on * disk storage where there is no upload delay. */ const reverseChronologicalVideoSort = (A: RendererVideo, B: RendererVideo) => { const metricA = A.start ? A.start : A.mtime; const metricB = B.start ? B.start : B.mtime; return metricB - metricA; }; /** * Check if two dates are within sec of each other. */ const areDatesWithinSeconds = (d1: Date, d2: Date, sec: number) => { const differenceMilliseconds = Math.abs(d1.getTime() - d2.getTime()); const millisecondsInMinute = sec * 1000; // 60 seconds * 1000 milliseconds return differenceMilliseconds <= millisecondsInMinute; }; /** * Re-write the metadata file on disk with a flag saying the video should * NOT be loaded, and should be deleted at earliest conviencence. * * This helps us avoid any scenario where we attempt and fail to delete a * video open by the player. */ const markForVideoForDelete = async (videoPath: string) => { try { const metadata = await getMetadataForVideo(videoPath); metadata.delete = true; await writeMetadataFile(videoPath, metadata); } catch (error) { // This isn't a total disaster, but might cause some duplicates to // display in the UI; i.e. a cloud and disk version of the same video. // Just log it so it's diagnosable, a user could fix it easily with a // manual delete. console.error( '[Util] Failed to mark a video for deletion', videoPath, String(error), ); } }; /** * Convert a RendererVideo type to a Metadata type, used when downloading * videos from cloud to disk. */ const rendererVideoToMetadata = (video: RendererVideo) => { const data = video as any; delete data.videoSource; delete data.videoName; delete data.mtime; delete data.isProtected; delete data.cloud; delete data.multiPov; delete data.uniqueId; return data as Metadata; }; /** * Convert a CloudSignedMetadata object to a RendererVideo object. */ const cloudSignedMetadataToRendererVideo = (metadata: CloudSignedMetadata) => { // For cloud videos, the signed URLs are the sources. const videoSource = metadata.signedVideoKey; const uniqueId = `${metadata.videoName}-cloud`; // We don't want the signed properties themselves. const mutable: any = metadata; delete mutable.signedVideoKey; const video: RendererVideo = { ...mutable, videoSource, multiPov: [], cloud: true, isProtected: Boolean(mutable.protected), mtime: 0, uniqueId, }; return video; }; /** * Check if a file or folder exists. */ const exists = async (file: string) => { try { await fs.promises.access(file); return true; } catch { return false; } }; /** * Check if the folder contains the managed.txt file indicating it is owned * by Warcraft Recorder. */ const isFolderOwned = async (dir: string) => { const file = path.join(dir, 'managed.txt'); if (await exists(file)) { console.info('[Util] Ownership file exists in', dir); return true; } console.info('[Util] Ownership file does not exist in', dir); return false; }; /** * Take ownership of a directory as the storage directory by writing a file to * indicate our ownership. This does the necessary checks that it doesn't contain * files we don't recognise first, to avoid the case where a user sets a storage * path that contains other files which Warcraft Recorder may go on to delete. * More context: https://github.com/aza547/wow-recorder/issues/400. */ const takeOwnershipStorageDir = async (dir: string) => { const helptext = 'If you are setting up Warcraft Recorder for the first time, this folder should be empty.'; const content = 'This folder is managed by Warcraft Recorder, files in it may be automatically created, modified or deleted.'; const files = await fs.promises.readdir(dir); // Check for any files that don't match the extensions Warcraft // Recorder creates. We won't take ownership of a directory with // other files in it. const unexpected = files .filter((file) => !file.endsWith('.mp4')) .filter((file) => !file.endsWith('.json')) .filter((file) => !file.endsWith('.png')) .filter((file) => file !== '.temp') .filter((file) => file !== 'managed.txt') .filter((file) => file !== 'desktop.ini'); if (unexpected.length > 0) { console.warn( '[Util] Found', unexpected.length, 'unexpected files in storage dir', dir, unexpected, ); throw new Error(`Can not take ownership of ${dir}. ${helptext}`); } // Ensure that every MP4 file we saw has a corresponding JSON and PNG file, // this covers the case that we've seen before where someone was otherwise // recording MP4s to the same directory as they configured Warcraft Recorder // to use. const mp4s = files.filter((file) => file.endsWith('.mp4')); for (let i = 0; i < mp4s.length; i++) { const mp4 = mp4s[i]; const base = path.basename(mp4, '.mp4'); const metadata = `${base}.json`; if (!files.includes(metadata)) { console.warn('[Util] Mismatch of files in storage dir', base); throw new Error(`Can not take ownership of ${dir}. ${helptext}`); } } const file = path.join(dir, 'managed.txt'); await fs.promises.writeFile(file, content); }; /** * Take ownership of a directory as the buffer directory by writing a file to * indicate our ownership. This does the necessary checks that it doesn't contain * files we don't recognise first, to avoid the case where a user sets a buffer * storage path that contains other files which Warcraft Recorder may go on to delete. * More context: https://github.com/aza547/wow-recorder/issues/400. */ const takeOwnershipBufferDir = async (dir: string) => { const helptext = 'If you are setting up Warcraft Recorder for the first time, this folder should be empty.'; const content = 'This folder is managed by Warcraft Recorder, files in it may be automatically created, modified or deleted.'; const files = await fs.promises.readdir(dir); const unexpected = files .filter((file) => !file.endsWith('.mp4')) .filter((file) => !file.endsWith('.mkv')) .filter((file) => file !== 'managed.txt'); if (unexpected.length > 0) { console.warn( '[Util] Found', unexpected.length, 'unexpected files in buffer dir', dir, unexpected, ); throw new Error(`Can not take ownership of ${dir}. ${helptext}`); } const regex = /^\d{4}-\d{2}-\d{2} \d{2}-\d{2}-\d{2}.(mp4|mkv)$/; files .filter((file) => file.endsWith('.mp4') || file.endsWith('.mkv')) .forEach((file) => { const match = regex.test(file); if (!match) { console.warn('[Util] Unrecognized file in buffer dir', file); throw new Error(`Can not take ownership of ${dir}. ${helptext}`); } }); const file = path.join(dir, 'managed.txt'); await fs.promises.writeFile(file, content); }; /** * Convience method to log an axios error. */ const logAxiosError = (msg: string, error: AxiosError) => { console.error(msg, { message: error.message, status: error.status, code: error.code, cause: error.cause, url: error.config?.url, rspStatus: error.response?.status, rspData: error.response?.data, }); }; /** * Custom protocol that enables the frontend to request MP4 files from disk. */ const handleSafeVodRequest = async (request: Request) => { try { const sliced = request.url.toString().slice('vod://wcr/'.length); const requestUrl = decodeURIComponent(sliced); if (!requestUrl) { console.error('[Util] Bad URL:', requestUrl); return new Response('', { status: 400, statusText: 'Bad URL', }); } const filePath = Buffer.from(requestUrl).toString('utf-8').split('#')[0]; // Remove any timestamps, the frontend handles those. if (!filePath.endsWith('.mp4')) { console.error('[Util] Not an MP4 file:', filePath); return new Response('', { status: 400, statusText: 'Must be MP4', }); } let stats: Stats; try { // This will throw if the file doesn't exist. stats = await fspromise.stat(filePath); } catch (err) { console.error('[Util] Error stating file:', err, filePath); return new Response('', { status: 404, statusText: 'File Not Found', }); } const fileSize = stats.size; const rangeHeader = request.headers.get('Range'); if (rangeHeader) { const rangeParts = rangeHeader.replace(/bytes=/, '').split('-'); const start = parseInt(rangeParts[0], 10); const end = rangeParts[1] ? parseInt(rangeParts[1], 10) : fileSize - 1; const chunkSize = end - start + 1; const stream = createReadStream(filePath, { start, end }); const body = Readable.toWeb(stream); return new Response(body as ReadableStream, { status: 206, statusText: 'Partial Content', headers: { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunkSize.toString(), 'Content-Type': 'video/mp4', 'Cache-Control': 'no-cache', }, }); } else { const stream = createReadStream(filePath); const body = Readable.toWeb(stream); return new Response(body as ReadableStream, { status: 200, headers: { 'Content-Length': fileSize.toString(), 'Accept-Ranges': 'bytes', 'Content-Type': 'video/mp4', 'Cache-Control': 'no-cache', }, }); } } catch (error) { console.error('[Util] Protocol handler error:', error); return new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error', }); } }; const runFirstTimeSetupActionsObs = () => { const cfg = ConfigService.getInstance(); const defaultEncoder = cfg.get('obsRecEncoder') === ESupportedEncoders.OBS_X264; if (defaultEncoder) { // If this is first time setup, auto-pick an encoder for the user. Only do it // if the current value is the software encoder, as this is new in 7.0.0, all // users would be subject to it. This way, only the few people who really do // prefer the software encoder will be inconvenienced. console.info('[Util] Picking sensible OBS recorder encoder'); const encoder = Recorder.getInstance().getSensibleEncoderDefault(); cfg.set('obsRecEncoder', encoder); } }; const runFirstTimeSetupActionsNoObs = () => { const cfg = ConfigService.getInstance(); const isRetailConfigured = cfg.get('recordRetail') && cfg.get('retailLogPath'); if (!isRetailConfigured) { console.info('[Util] Attempt to first time configure retail installation'); for (let i = 0; i < wowInstallSearchPaths.length; i++) { const installPath = wowInstallSearchPaths[i] + '\\_retail_\\Logs'; const installExists = existsSync(installPath); if (installExists) { console.info('[Util] Found retail WoW installation at', installPath); cfg.set('retailLogPath', installPath); cfg.set('recordRetail', true); break; } } } const isClassicConfigured = cfg.get('recordClassic') && cfg.get('classicLogPath'); if (!isClassicConfigured) { console.info('[Util] Attempt to first time configure classic installation'); for (let i = 0; i < wowInstallSearchPaths.length; i++) { const installPath = wowInstallSearchPaths[i] + '\\_classic_\\Logs'; const installExists = existsSync(installPath); if (installExists) { console.info('[Util] Found classic WoW installation at', installPath); cfg.set('classicLogPath', installPath); cfg.set('recordClassic', true); break; } } } if (!cfg.get('storagePath')) { console.info('[Util] Setting up default storage path'); const baseVideoPath = app.getPath('userData'); const initialStorageDir = path.join( baseVideoPath, 'Warcraft Recorder Videos', ); fs.mkdirSync(initialStorageDir, { recursive: true }); cfg.set('storagePath', initialStorageDir); } }; export { setupApplicationLogging, writeMetadataFile, deleteVideoDisk, openSystemExplorer, fixPathWhenPackaged, getSortedVideos, getAvailableDisplays, getSortedFiles, tryUnlink, getMetadataForVideo, deferredPromiseHelper, getAssetPath, getWowFlavour, isPushToTalkHotkey, nextKeyPressPromise, nextMousePressPromise, convertUioHookEvent, getPromiseBomb, emitErrorReport, buildClipMetadata, buildKillVideoMetadata, getOBSFormattedDate, checkDisk, getMetadataFileNameForVideo, loadVideoDetailsDisk, reverseChronologicalVideoSort, areDatesWithinSeconds, markForVideoForDelete, rendererVideoToMetadata, cloudSignedMetadataToRendererVideo, exists, isFolderOwned, takeOwnershipStorageDir, takeOwnershipBufferDir, convertKoreanVideoCategory, isManualRecordHotKey, delayedDeleteVideo, logAxiosError, handleSafeVodRequest, runFirstTimeSetupActionsObs, runFirstTimeSetupActionsNoObs, checkAdvancedCombatLogging, getConfigWtfPath, }; ================================================ FILE: src/parsing/ClassicLogHandler.ts ================================================ import { classicArenas, classicBattlegrounds, classicUniqueSpecAuras, classicUniqueSpecSpells, mopChallengeModes, } from '../main/constants'; import LogHandler from './LogHandler'; import { Flavour } from '../main/types'; import ArenaMatch from '../activitys/ArenaMatch'; import { isUnitFriendly, isUnitPlayer, isUnitSelf } from './logutils'; import Battleground from '../activitys/Battleground'; import LogLine from './LogLine'; import { VideoCategory } from '../types/VideoCategory'; import Combatant from 'main/Combatant'; import ChallengeModeDungeon from 'activitys/ChallengeModeDungeon'; import ConfigService from 'config/ConfigService'; /** * Classic log handler class. */ export default class ClassicLogHandler extends LogHandler { constructor(logPath: string) { super(logPath, 2); /* eslint-disable prettier/prettier */ this.combatLogWatcher .on('ENCOUNTER_START', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleEncounterStartLine(line)) }) .on('ENCOUNTER_END', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleEncounterEndLine(line)) }) .on('ZONE_CHANGE', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleZoneChange(line)) }) .on('SPELL_AURA_APPLIED', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleSpellAuraAppliedLine(line)) }) .on('UNIT_DIED', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleUnitDiedLine(line)) }) .on('SPELL_CAST_SUCCESS', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleSpellCastSuccess(line)) }) .on('COMBATANT_INFO', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleCombatantInfoLine(line)) }) .on('CHALLENGE_MODE_START', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleChallengeModeStartLine(line)) }) .on('CHALLENGE_MODE_END', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleChallengeModeEndLine(line)) }); /* eslint-enable prettier/prettier */ } protected async handleEncounterStartLine(line: LogLine) { console.debug('[ClassicLogHandler] Handling ENCOUNTER_START line:', line); if (this.isManual()) { console.info('[ClassicLogHandler] Ignoring line as in manual recording'); return; } await super.handleEncounterStartLine(line, Flavour.Classic); } private handleSpellAuraAppliedLine(line: LogLine) { if (!LogHandler.activity || this.isManual()) { // Deliberately don't log anything here as we can hit this a lot return; } const srcGUID = line.arg(1); const srcFlags = parseInt(line.arg(3), 16); const srcNameRealm = line.arg(2); const destGUID = line.arg(5); const destFlags = line.arg(7); const destNameRealm = line.arg(6); const spellName = line.arg(10); const alreadyKnowCombatant = LogHandler.activity.getCombatant(srcGUID) !== undefined; const combatant = this.processClassicCombatant( srcGUID, srcNameRealm, srcFlags, destGUID, destNameRealm, destFlags, ); if (combatant === undefined) { // It's not an event we want to add a combatant for. return; } const isEnemyCombatant = combatant.teamID === 0; // If it's the first time we have spotted an enemy combatant in arena, // then the gates have just opened. Adjust the activity start time. if (this.isArena() && !alreadyKnowCombatant && isEnemyCombatant) { const combatants = LogHandler.activity.combatantMap.values(); const enemyCombatants = [...combatants].filter((c) => c.teamID === 0); if (enemyCombatants.length === 1) { const newStartDate = line.date(); console.info( '[ClassicLogHandler] Adjusting game start date:', newStartDate, ); LogHandler.activity.startDate = newStartDate; } } if (combatant.specID === undefined) { const knownSpell = Object.prototype.hasOwnProperty.call( classicUniqueSpecAuras, spellName, ); if (knownSpell) { combatant.specID = classicUniqueSpecAuras[spellName]; } } } private async handleZoneChange(line: LogLine) { console.info('[ClassicLogHandler] Handling ZONE_CHANGE line:', line); if (this.isManual()) { console.info('[ClassicLogHandler] Ignoring line as in manual recording'); return; } const zoneID = parseInt(line.arg(1), 10); const isZoneArena = Object.prototype.hasOwnProperty.call( classicArenas, zoneID, ); const isZoneBG = Object.prototype.hasOwnProperty.call( classicBattlegrounds, zoneID, ); if (LogHandler.activity) { const isActivityBG = this.isBattleground(); const isActivityArena = this.isArena(); // Sometimes (maybe always) see a double ZONE_CHANGE fired on the way into arena. // Explicitly check here that the zoneID we're going to is different than that // of the activity we are in to avoid ending the arena on the duplicate event. if (isActivityArena && zoneID !== LogHandler.activity.zoneID) { console.info('[ClassicLogHandler] Zone change out of Arena'); await this.endArena(line.date()); } if (isActivityBG && zoneID !== LogHandler.activity.zoneID) { console.info('[ClassicLogHandler] Zone change out of battleground'); await this.battlegroundEnd(line); } } else if (isZoneBG) { console.info('[ClassicLogHandler] Zone change into BG'); await this.battlegroundStart(line); } else if (isZoneArena) { console.info('[ClassicLogHandler] Zone change into Arena'); const startDate = line.date(); await this.startArena(startDate, zoneID); } else { console.info('[ClassicLogHandler] Uninteresting zone change'); } } protected handleUnitDiedLine(line: LogLine) { if (!LogHandler.activity || this.isManual()) { // Deliberately don't log anything here as we can hit this a lot return; } const unitFlags = parseInt(line.arg(7), 16); const isPlayer = isUnitPlayer(unitFlags); const isFeignDeath = Boolean(parseInt(line.arg(9), 10)); if (!isPlayer || isFeignDeath) { // Deliberatly not logging here as not interesting and frequent. return; } super.handleUnitDiedLine(line); if (this.isArena()) { this.processArenaDeath(line.date()); } } private handleSpellCastSuccess(line: LogLine) { if (!LogHandler.activity || this.isManual()) { // Deliberately don't log anything here as we can hit this a lot return; } const srcGUID = line.arg(1); const srcNameRealm = line.arg(2); const srcFlags = parseInt(line.arg(3), 16); const destGUID = line.arg(5); const destFlags = line.arg(7); const destNameRealm = line.arg(6); const spellName = line.arg(10); const combatant = this.processClassicCombatant( srcGUID, srcNameRealm, srcFlags, destGUID, destNameRealm, destFlags, ); if (combatant === undefined) { // Not an event we can add a combatant for. return; } if (combatant.specID === undefined) { const knownSpell = Object.prototype.hasOwnProperty.call( classicUniqueSpecSpells, spellName, ); if (knownSpell) { combatant.specID = classicUniqueSpecSpells[spellName]; } } } private async startArena(startDate: Date, zoneID: number) { if (LogHandler.activity) { console.error( "[ClassicLogHandler] Another activity in progress, can't start arena", ); return; } console.debug('[ClassicLogHandler] Starting arena at date:', startDate); const category = VideoCategory.TwoVTwo; const activity = new ArenaMatch( startDate, category, zoneID, Flavour.Classic, ); await LogHandler.startActivity(activity); } private static calculateArenaResult(arenaMatch: ArenaMatch) { // We decide who won by counting the deaths. The winner is the // team with the least deaths. Classic doesn't have team IDs // but we cheated a bit earlier always assigning the player as // team 1. So effectively 0 is a loss and 1 is a win here. const friendsDead = arenaMatch.deaths.filter((d) => d.friendly).length; const enemiesDead = arenaMatch.deaths.filter((d) => !d.friendly).length; console.info('[ClassicLogHandler] Friendly deaths: ', friendsDead); console.info('[ClassicLogHandler] Enemy deaths: ', enemiesDead); const result = friendsDead < enemiesDead ? 1 : 0; return result; } private async endArena(endDate: Date) { if (!LogHandler.activity) { console.error( '[ClassicLogHandler] Arena stop with no active arena match', ); return; } console.debug('[ClassicLogHandler] Stopping arena at date:', endDate); const arenaMatch = LogHandler.activity as ArenaMatch; const result = await ClassicLogHandler.calculateArenaResult(arenaMatch); arenaMatch.endArena(endDate, result); await LogHandler.endActivity(); } protected processClassicCombatant( srcGUID: string, srcNameRealm: string, srcFlags: number, destGUID: string, destNameRealm: string, destFlags: number, ) { if (!LogHandler.activity) { return undefined; } const srcCombatant = LogHandler.activity.getCombatant(srcGUID); const destCombatant = LogHandler.activity.getCombatant(destGUID); const srcIdentified = srcCombatant !== undefined; const destIdentified = destCombatant !== undefined; if ( this.isArena() && !isUnitSelf(srcFlags) && !srcIdentified && !destIdentified ) { // Drop out of this function if certain conditions are met, long // description below. We only ever get here if we're in arena and the event // isn't from the player themself. // // In classic arena we mandate that combatants are only identified by // interaction with the player, or a unit that the player has interacted // with. This is to avoid counting outsiders. // // We also avoid this branch if we have registered either the source or // destination as a combatant as: // 1. It's fine to include a source combatant we have'nt registered so // long as they are interacting with a destination combatant we have // registered. // 2. We may rely on spells that do not have a destination for spec // detection, e.g. Bladestorm. // // This approach can be thought of a bit like a web crawler, where we // start from the player and crawl for the other combatants. return undefined; } if (srcIdentified && !destIdentified) { // If we know the source but not the dest, we want to add the dest so // we can fill in the combatant details later. That allows the crawling // to go both directions. super.processCombatant(destGUID, destNameRealm, destFlags, true); } const combatant = super.processCombatant( srcGUID, srcNameRealm, srcFlags, true, ); if (combatant === undefined) { return combatant; } // Classic doesn't have team IDs, we cheat a bit here and always assign // the player team 1 to share logic with retail. if (isUnitFriendly(srcFlags)) { combatant.teamID = 1; } else { combatant.teamID = 0; } return combatant; } private processArenaDeath(deathDate: Date) { if (!LogHandler.activity) { return; } let totalFriends = 0; let totalEnemies = 0; LogHandler.activity.combatantMap.forEach((combatant) => { if (combatant.teamID === 1) { totalFriends++; } else { totalEnemies++; } }); const deadFriends = LogHandler.activity.deaths.filter( (d) => d.friendly, ).length; const aliveFriends = totalFriends - deadFriends; if (aliveFriends < 1) { console.info( '[ClassicLogHandler] No friendly players left so ending game.', ); this.endArena(deathDate); return; } const deadEnemies = LogHandler.activity.deaths.filter( (d) => !d.friendly, ).length; const aliveEnemies = totalEnemies - deadEnemies; if (aliveEnemies < 1) { console.info('[ClassicLogHandler] No enemy players left so ending game.'); this.endArena(deathDate); } } private async battlegroundStart(line: LogLine) { if (LogHandler.activity) { console.error( "[ClassicLogHandler] Another activity in progress, can't start battleground", ); return; } const startTime = line.date(); const category = VideoCategory.Battlegrounds; const zoneID = parseInt(line.arg(1), 10); const activity = new Battleground( startTime, category, zoneID, Flavour.Classic, ); await LogHandler.startActivity(activity); } private async battlegroundEnd(line: LogLine) { if (!LogHandler.activity) { console.error( "[ClassicLogHandler] Can't stop battleground as no active activity", ); return; } const endTime = line.date(); LogHandler.activity.end(endTime, false); await LogHandler.endActivity(); } private handleCombatantInfoLine(line: LogLine) { console.debug('[ClassicLogHandler] Handling COMBATANT_INFO line:', line); if (this.isManual()) { console.info('[ClassicLogHandler] Ignoring line as in manual recording'); return; } if (!LogHandler.activity) { console.warn( '[ClassicLogHandler] No activity in progress, ignoring COMBATANT_INFO', ); return; } const GUID = line.arg(1); // In CMs we see COMBANTANT_INFO events for each encounter. // Don't bother overwriting them if we have them already. const combatant = LogHandler.activity.getCombatant(GUID); if (combatant) { return; } console.info( '[RetailLogHandler] Adding combatant from COMBATANT_INFO', GUID, ); // We weirdly MOP classic doesn't include class or spec in // the COMBATANT_INFO. const newCombatant = new Combatant(GUID); LogHandler.activity.addCombatant(newCombatant); } private async handleChallengeModeStartLine(line: LogLine) { console.debug( '[ClassicLogHandler] Handling CHALLENGE_MODE_START line:', line, ); if (this.isManual()) { console.info('[ClassicLogHandler] Ignoring line as in manual recording'); return; } if ( LogHandler.activity && LogHandler.activity.category === VideoCategory.MythicPlus ) { // This can happen if you zone in and out of a key mid pull. // If it's a new key, we see a CHALLENGE_MODE_END event first. console.info('[ClassicLogHandler] Subsequent start event for dungeon'); return; } const zoneID = parseInt(line.arg(2), 10); const mapID = parseInt(line.arg(3), 10); const unknownMap = !Object.prototype.hasOwnProperty.call( mopChallengeModes, mapID, ); if (unknownMap) { console.error('[ClassicLogHandler] Unknown map', mapID); return; } const recordChallengeModes = ConfigService.getInstance().get( 'recordChallengeModes', ); if (!recordChallengeModes) { console.info( '[ClassicLogHandler] Ignoring MoP Challenge Mode (disabled in settings)', ); return; } const startTime = line.date(); const activity = new ChallengeModeDungeon( startTime, zoneID, mapID, 0, [], Flavour.Classic, ); await LogHandler.startActivity(activity); } private async handleChallengeModeEndLine(line: LogLine) { console.debug( '[ClassicLogHandler] Handling CHALLENGE_MODE_END line:', line, ); if (this.isManual()) { console.info('[ClassicLogHandler] Ignoring line as in manual recording'); return; } if (!LogHandler.activity) { console.error( '[ClassicLogHandler] Challenge mode stop with no active ChallengeModeDungeon', ); return; } const challengeModeActivity = LogHandler.activity as ChallengeModeDungeon; const endDate = line.date(); challengeModeActivity.endChallengeMode(endDate, 0, true); await LogHandler.endActivity(); } } ================================================ FILE: src/parsing/CombatLogWatcher.ts ================================================ import { EventEmitter } from 'stream'; import fs, { watch, FSWatcher } from 'fs'; import util from 'util'; import { FileInfo } from 'main/types'; import path from 'path'; import { getFileInfo, getSortedFiles } from '../main/util'; import LogLine from './LogLine'; import AsyncQueue from 'utils/AsyncQueue'; /** * Setup a bunch of promisified fs calls for convienence. */ const open = util.promisify(fs.open); const read = util.promisify(fs.read); const close = util.promisify(fs.close); /** * Watches a directory for combat logs, read the new data from them * and emits events containing a LogLine object for processing elsewhere. * * I'm a bit nervous about race conditions here in multiple read calls. I * considered using p-queue, but I think it's probably fine as almost * certainly we won't have combat log writes close enough together to hit it. */ export default class CombatLogWatcher extends EventEmitter { /** * The directory to watch for logs. */ private logDir: string; /** * The watcher object itself. */ private watcher?: FSWatcher; /** * A duration after seeing a log write to send a timeout event in if no * other subsequent log writes seen. In seconds. */ private timeout: number; /** * A handle to the timeout timer so we can easily reset it when we see * additional logs. */ private timer?: NodeJS.Timeout; /** * We need to keep track of some info about each log file to know how much we * should read. */ private state: Record = {}; /** * A promise queue we use to ensure that we only have one active attempt to * parse the file at a time. */ private queue = new AsyncQueue(Number.MAX_SAFE_INTEGER); /** * The most recently updated log file, we remember this purely so we can * log when it changes. */ private current = ''; /** * Constructor, unit of timeout is minutes. No events will be emitted until * watch() is called. */ constructor(logDir: string, timeout: number) { super(); this.timeout = timeout * 1000 * 60; this.logDir = logDir; } /** * Start watching the directory. */ public async watch() { await this.getLogDirectoryState(); this.watcher = watch(this.logDir); this.watcher.on('change', (type, file) => { if (typeof file !== 'string') { return; } if (!file.startsWith('WoWCombatLog')) { return; } if (type === 'rename') { // Despite this being a 'change' listener, we can still get // rename events here, see the Node watch API. The rename event // misleadingly fires for both file creation and deletion. // // We reset the position in a file on either, such that a file // recreated with the same name will be read from the start. See // Issue 624. console.info('[CombatLogWatcher] Create or delete event', file); const fullPath = path.join(this.logDir, file); delete this.state[fullPath]; return; } if (file !== this.current) { console.info('[CombatLogWatcher] New active log file', file); this.current = file; } this.queue.add(() => this.process(file)); }); } /** * Stop watching the directory. */ public async unwatch() { if (this.watcher) { await this.watcher.close(); } } /** * We need this in-case WCR is launched mid activity where a partial log file * already exists. */ private async getLogDirectoryState() { const logs = await getSortedFiles(this.logDir, 'WoWCombatLog.*.txt'); if (logs.length < 1) { return; } const fileInfoPromises = logs.map((f) => f.name).map(getFileInfo); const fileInfo = await Promise.all(fileInfoPromises); fileInfo.forEach((info) => { this.state[info.name] = info; }); } /** * Process a change event receieved from the directory watcher. */ private async process(file: string) { const fullPath = path.join(this.logDir, file); const currentInfo = await getFileInfo(fullPath); const lastInfo = this.state[fullPath]; let bytesToRead; let startPosition; if (lastInfo) { // Existing file, read from the last known length. bytesToRead = currentInfo.size - lastInfo.size; startPosition = lastInfo.size; } else { // New file, we want to read from the start. bytesToRead = currentInfo.size; startPosition = 0; } if (bytesToRead < 1) { // The node fs watcher is known to sometimes emit multiple events for // the same write. This lets us drop out early if there is nothing to read. return; } await this.parseFileChunk(fullPath, bytesToRead, startPosition); this.state[fullPath] = currentInfo; } /** * Parse a chunk of the file of length bytes from a specified position. */ private async parseFileChunk(file: string, bytes: number, position: number) { const buffer = Buffer.alloc(bytes); const handle = await open(file, 'r'); const { bytesRead } = await read(handle, buffer, 0, bytes, position); close(handle); if (bytesRead !== bytes) { console.error( '[CombatLogParser] Read attempted for', bytes, 'bytes, but read', bytesRead, ); } const lines = buffer .toString('utf-8') .split('\n') .map((s) => s.trim()) .filter((s) => s); lines.forEach((line) => { this.handleLogLine(line); }); this.resetTimeout(); } /** * Handle a line from the WoW log. Public as this is called by the test * button. */ public handleLogLine(line: string) { const logLine = new LogLine(line); const logEventType = logLine.type(); this.emit(logEventType, logLine); } /** * Sends a timeout event signalling no activity in the combat log directory * for the timeout period. That's handy as a catch-all for ending any active * events. Typically a Mythic+ that's been abandoned. */ private resetTimeout() { if (this.timer) { clearTimeout(this.timer); } this.timer = setTimeout(() => { this.emit('timeout', this.timeout); }, this.timeout); } } ================================================ FILE: src/parsing/EraLogHandler.ts ================================================ import LogHandler from './LogHandler'; import { Flavour } from '../main/types'; import { isUnitPlayer } from './logutils'; import LogLine from './LogLine'; import Combatant from '../main/Combatant'; import { classicUniqueSpecSpells } from '../main/constants'; /** * Classic era log handler class. */ export default class EraLogHandler extends LogHandler { constructor(logPath: string) { super(logPath, 2); /* eslint-disable prettier/prettier */ this.combatLogWatcher .on('ENCOUNTER_START', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleEncounterStartLine(line)) }) .on('ENCOUNTER_END', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleEncounterEndLine(line)) }) .on('SPELL_AURA_APPLIED', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleSpellAuraAppliedLine(line)) }) .on('SPELL_CAST_SUCCESS', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleSpellCastSuccess(line)) }) .on('COMBATANT_INFO', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleCombatantInfoLine(line)) }) .on('UNIT_DIED', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleUnitDiedLine(line)) }) /* eslint-enable prettier/prettier */ } protected async handleEncounterStartLine(line: LogLine) { console.debug('[EraLogHandler] Handling ENCOUNTER_START line:', line); if (this.isManual()) { console.info('[EraLogHandler] Ignoring line as in manual recording'); return; } await super.handleEncounterStartLine(line, Flavour.Classic); } private handleCombatantInfoLine(line: LogLine): void { console.debug('[EraLogHandler] Handling COMBATANT_INFO line:', line); if (this.isManual()) { console.info('[EraLogHandler] Ignoring line as in manual recording'); return; } if (!LogHandler.activity) { console.warn( '[EraLogHandler] No activity in progress, ignoring COMBATANT_INFO', ); return; } const GUID = line.arg(1); const teamID = parseInt(line.arg(2), 10); // This just gives zero in classic era annoyingly. const specID = parseInt(line.arg(24), 10); // This gives talent point breakdown, could use it in conjunction // with some class detection to make spec detection easier. // const talents = line.arg(25); console.info( '[EraLogHandler] Adding combatant from COMBATANT_INFO', GUID, teamID, specID, ); const newCombatant = new Combatant(GUID, teamID, specID); LogHandler.activity.addCombatant(newCombatant); } private handleSpellAuraAppliedLine(line: LogLine) { if (!LogHandler.activity || this.isManual()) { // Deliberately don't log anything here as we hit this a lot return; } const srcGUID = line.arg(1); const srcFlags = parseInt(line.arg(3), 16); const srcNameRealm = line.arg(2); this.processCombatant(srcGUID, srcNameRealm, srcFlags, false); } private handleSpellCastSuccess(line: LogLine) { if (!LogHandler.activity || this.isManual()) { // Deliberately don't log anything here as we hit this a lot return; } const srcGUID = line.arg(1); const srcNameRealm = line.arg(2); const srcFlags = parseInt(line.arg(3), 16); const spellName = line.arg(10); const combatant = this.processEraCombatant(srcGUID, srcNameRealm, srcFlags); if (combatant === undefined) { // Not an event we can add a combatant for. return; } if (!combatant.specID) { const knownSpell = Object.prototype.hasOwnProperty.call( classicUniqueSpecSpells, spellName, ); if (knownSpell) { combatant.specID = classicUniqueSpecSpells[spellName]; } } } protected processEraCombatant( srcGUID: string, srcNameRealm: string, srcFlags: number, ) { if (!LogHandler.activity) { return undefined; } const combatant = super.processCombatant( srcGUID, srcNameRealm, srcFlags, false, ); if (combatant === undefined) { return combatant; } return combatant; } protected handleUnitDiedLine(line: LogLine) { console.debug('[EraLogHandler] Handling UNIT_DIED line:', line); if (this.isManual()) { console.info('[EraLogHandler] Ignoring line as in manual recording'); return; } if (!LogHandler.activity) { console.info('[EraLogHandler] Ignoring line as no activity in progress'); return; } const unitFlags = parseInt(line.arg(7), 16); const isPlayer = isUnitPlayer(unitFlags); const isFeignDeath = Boolean(parseInt(line.arg(9), 10)); if (!isPlayer || isFeignDeath) { // Deliberatly not logging here as not interesting and frequent. return; } super.handleUnitDiedLine(line); } } ================================================ FILE: src/parsing/LogHandler.ts ================================================ import VideoProcessQueue from '../main/VideoProcessQueue'; import Combatant from '../main/Combatant'; import CombatLogWatcher from './CombatLogWatcher'; import ConfigService from '../config/ConfigService'; import { instanceDifficulty } from '../main/constants'; import Recorder from '../main/Recorder'; import { Flavour, PlayerDeathType, SoundAlerts, VideoQueueItem, } from '../main/types'; import Activity from '../activitys/Activity'; import RaidEncounter from '../activitys/RaidEncounter'; import { ambiguate, isUnitFriendly, isUnitPlayer, isUnitSelf, } from './logutils'; import LogLine from './LogLine'; import { VideoCategory } from '../types/VideoCategory'; import { allowRecordCategory } from '../utils/configUtils'; import { assert } from 'console'; import Manual from 'activitys/Manual'; import { playSoundAlert } from 'main/main'; import Poller from 'utils/Poller'; import { emitErrorReport } from 'main/util'; import AsyncQueue from 'utils/AsyncQueue'; import path from 'path'; /** * Generic LogHandler class. Everything in this class must be valid for both * classic and retail combat logs. * * If you need something flavour specific then put it in the appropriate * subclass; i.e. RetailLogHandler, ClassicLogHandler or EraLogHandler. * * Static fields in this class provide locking function. While we will * typically have up to 4 child classes, we don't want multiple concurrent * activities. */ export default abstract class LogHandler { public static activity: Activity | undefined; public static overrunning = false; private static minBossHp = 100 * 10 ** 6; public combatLogWatcher: CombatLogWatcher; protected player: Combatant | undefined; private static stateChangeCallback: () => void; /** * Enforces ordered processing of log lines. Some log line processing * is asynchronous so we need to ensure later lines don't get processed * before earlier ones. */ protected logProcessQueue = new AsyncQueue(Number.MAX_SAFE_INTEGER); constructor(logPath: string, dataTimeout: number) { this.combatLogWatcher = new CombatLogWatcher(logPath, dataTimeout); this.combatLogWatcher.watch(); const lpq = this.logProcessQueue; this.combatLogWatcher.on('timeout', (ms) => { lpq.add(async () => this.dataTimeout(ms)); }); // For ease of testing force stop. this.combatLogWatcher.on('WARCRAFT_RECORDER_FORCE_STOP', () => { lpq.add(async () => LogHandler.forceEndActivity()); }); } public static setStateChangeCallback = ( cb: typeof LogHandler.stateChangeCallback, ) => { this.stateChangeCallback = cb; }; public destroy() { this.combatLogWatcher.unwatch(); this.combatLogWatcher.removeAllListeners(); } protected async handleEncounterStartLine(line: LogLine, flavour: Flavour) { console.debug('[LogHandler] Handling ENCOUNTER_START line:', line); if (LogHandler.activity) { console.warn('[LogHandler] Activity already in progress'); return; } const startDate = line.date(); const encounterID = parseInt(line.arg(1), 10); const difficultyID = parseInt(line.arg(3), 10); const encounterName = line.arg(2); const isRecognisedDifficulty = Object.prototype.hasOwnProperty.call( instanceDifficulty, difficultyID, ); if (!isRecognisedDifficulty) { throw new Error(`[LogHandler] Unknown difficulty ID: ${difficultyID}`); } const isRaidEncounter = instanceDifficulty[difficultyID].partyType === 'raid'; if (!isRaidEncounter) { console.debug('[LogHandler] Not a raid encounter, do nothing'); return; } const activity = new RaidEncounter( startDate, encounterID, encounterName, difficultyID, flavour, ); await LogHandler.startActivity(activity); } protected async handleEncounterEndLine(line: LogLine) { console.debug('[LogHandler] Handling ENCOUNTER_END line:', line); if (this.isManual()) { console.info('[ClassicLogHandler] Ignoring line as in manual recording'); return; } if (!LogHandler.activity) { console.info('[LogHandler] Encounter stop with no active encounter'); return; } const difficultyID = parseInt(line.arg(3), 10); const isRaidEncounter = instanceDifficulty[difficultyID].partyType === 'raid'; if (!isRaidEncounter) { console.debug('[LogHandler] Not a raid encounter, do nothing'); return; } const result = Boolean(parseInt(line.arg(5), 10)); if (result) { const overrun = ConfigService.getInstance().get('raidOverrun'); LogHandler.activity.overrun = overrun; } LogHandler.activity.end(line.date(), result); await LogHandler.endActivity(); } protected handleUnitDiedLine(line: LogLine): void { if (!LogHandler.activity) { return; } const unitFlags = parseInt(line.arg(7), 16); if (!isUnitPlayer(unitFlags)) { // Deliberatly not logging here as not interesting and frequent. return; } const isUnitUnconsciousAtDeath = Boolean(parseInt(line.arg(9), 10)); if (isUnitUnconsciousAtDeath) { // Deliberatly not logging here as not interesting and frequent. return; } const playerName = line.arg(6); const playerGUID = line.arg(5); const playerSpecId = LogHandler.activity.getCombatant(playerGUID)?.specID ?? 0; // Add player death and subtract 2 seconds from the time of death to allow the // user to view a bit of the video before the death and not at the actual millisecond // it happens. const deathDate = (line.date().getTime() - 2) / 1000; const activityStartDate = LogHandler.activity.startDate.getTime() / 1000; let relativeTime = deathDate - activityStartDate; if (relativeTime < 0) { console.error('[LogHandler] Tried to set timestamp to', relativeTime); relativeTime = 0; } const playerDeath: PlayerDeathType = { name: playerName, specId: playerSpecId, date: line.date(), timestamp: relativeTime, friendly: isUnitFriendly(unitFlags), }; LogHandler.activity.addDeath(playerDeath); } protected static async startActivity(activity: Activity) { const { category } = activity; const allowed = allowRecordCategory(ConfigService.getInstance(), category); if (!allowed) { console.info('[LogHandler] Not configured to record', category); return; } console.info( `[LogHandler] Start recording a video for category: ${category}`, ); // Offset is the number of seconds to cut back into the buffer. That way // the buffer length is irrelevant. It is physically impossible to have // a negative offset. That would mean an activity started in the future. const offset = (Date.now() - activity.startDate.getTime()) / 1000; console.info(`[LogHandler] Calculated offset seconds`, offset); assert(offset >= 0); try { LogHandler.activity = activity; await Recorder.getInstance().startRecording(offset); LogHandler.stateChangeCallback(); } catch (error) { console.error('[LogHandler] Error starting activity', String(error)); LogHandler.activity = undefined; } } /** * End the recording after the overrun has elasped. Every single activity * ending comes through this function. */ protected static async endActivity() { if (!LogHandler.activity) { console.error("[LogHandler] No active activity so can't stop"); return; } console.info( `[LogHandler] Ending recording video for category: ${LogHandler.activity.category}`, ); // It's important we clear the activity before we call stop as stop will // await for the overrun, and we might do weird things if the player // immediately starts a new activity while we're awaiting. See issue 291. const lastActivity = LogHandler.activity; LogHandler.overrunning = true; LogHandler.activity = undefined; const { overrun } = lastActivity; if (overrun > 0) { console.info('[LogHandler] Awaiting overrun:', overrun); LogHandler.stateChangeCallback(); await new Promise((resolve) => setTimeout(resolve, 1000 * overrun)); console.info('[LogHandler] Done awaiting overrun'); } LogHandler.overrunning = false; const recorder = Recorder.getInstance(); const poller = Poller.getInstance(); let videoFile; const stopPromise = recorder.stop(); // Queue the stop. const wowRunning = poller.isWowRunning(); if (wowRunning) { // Immediately queue the buffer start so it's ready if we go instantly into another activity. console.info('[LogHandler] Queue buffer start as WoW still running'); recorder.startBuffer(); // No assignment, we don't care about when it's done. } try { // Now await the stop so we get the file from the recorder. Clear it // when we do to prevent it being reused. await stopPromise; videoFile = recorder.getAndClearLastFile(); } catch (error) { console.error( '[LogHandler] Failed to stop recording, discarding video', error, ); const report = 'Failed to stop recording, discarding: ' + lastActivity.getFileName(); emitErrorReport(report); return; } if (!videoFile) { console.error('[LogHandler] No video file available'); const report = 'No video file produced, discarding: ' + lastActivity.getFileName(); emitErrorReport(report); return; } try { const metadata = lastActivity.getMetadata(); const { duration } = metadata; const suffix = lastActivity.getFileName(); if (lastActivity.category === VideoCategory.Raids) { const minDuration = ConfigService.getInstance().get( 'minEncounterDuration', ); const notLongEnough = duration < minDuration; if (notLongEnough) { console.info('[LogHandler] Discarding raid encounter, too short'); return; } } // This looks redundant as we also pass videoFile but this allows us to // share logic with clipping of remote videos where there is no file path. const videoName = path.basename(videoFile, path.extname(videoFile)); const queueItem: VideoQueueItem = { name: videoName, source: videoFile, suffix, offset: 0, // We don't need to offset here, we've already cut the buffer back. duration, metadata, clip: false, }; VideoProcessQueue.getInstance().queueVideo(queueItem); } catch (error) { // We've failed to get the Metadata from the activity. Throw away the // video and log why. Example of when we hit this is on raid resets // where we don't have long enough to get a GUID for the player. console.warn( '[LogHandler] Discarding video as failed to get Metadata:', String(error), ); } } protected async dataTimeout(ms: number) { console.info( `[LogHandler] Haven't received data for combatlog in ${ ms / 1000 } seconds.`, ); if (LogHandler.activity) { await LogHandler.forceEndActivity(-ms / 1000); } } public static async forceEndActivity(timedelta = 0) { if (!LogHandler.activity) { console.error('[LogHandler] forceEndActivity called but no activity'); return; } console.info('[LogHandler] Force ending activity, timedelta:', timedelta); const endDate = new Date(); endDate.setTime(endDate.getTime() + timedelta * 1000); LogHandler.activity.overrun = 0; LogHandler.activity.end(endDate, false); await LogHandler.endActivity(); LogHandler.activity = undefined; } public static dropActivity() { LogHandler.overrunning = false; LogHandler.activity = undefined; } protected async zoneChangeStop(line: LogLine) { if (!LogHandler.activity) { console.error('[LogHandler] No active activity on zone change stop'); return; } const endDate = line.date(); LogHandler.activity.end(endDate, false); await LogHandler.endActivity(); } protected isArena() { if (!LogHandler.activity) { return false; } const { category } = LogHandler.activity; return ( category === VideoCategory.TwoVTwo || category === VideoCategory.ThreeVThree || category === VideoCategory.FiveVFive || category === VideoCategory.Skirmish || category === VideoCategory.SoloShuffle ); } protected isBattleground() { if (!LogHandler.activity) { return false; } const { category } = LogHandler.activity; return category === VideoCategory.Battlegrounds; } protected isMythicPlus() { if (!LogHandler.activity) { return false; } const { category } = LogHandler.activity; return category === VideoCategory.MythicPlus; } protected isManual() { if (!LogHandler.activity) return false; return LogHandler.activity.category === VideoCategory.Manual; } protected processCombatant( srcGUID: string, srcNameRealm: string, srcFlags: number, allowNew: boolean, ) { let combatant: Combatant | undefined; if (!LogHandler.activity) { return combatant; } // Logs sometimes emit this GUID and we don't want to include it. // No idea what causes it. Seems really common but not exlusive on // "Shadow Word: Death" casts. if (srcGUID === '0000000000000000') { return combatant; } if (!isUnitPlayer(srcFlags)) { return combatant; } // We check if we already know the playerGUID here, no point updating it // because it can't change, unless the user changes characters mid // recording like in issue 355, in which case better to retain the initial // character details. if (!LogHandler.activity.playerGUID && isUnitSelf(srcFlags)) { LogHandler.activity.playerGUID = srcGUID; } // Even if the combatant exists already we still update it with the info it // may not have yet. We can't tell the name, realm or if it's the player // from COMBATANT_INFO events. combatant = LogHandler.activity.getCombatant(srcGUID); if (allowNew && combatant === undefined) { // We've failed to get a pre-existing combatant, but we are allowed to add it. combatant = new Combatant(srcGUID); } else if (combatant === undefined) { // We've failed to get a pre-existing combatant, and we're not allowed to add it. return combatant; } if (combatant.isFullyDefined()) { // No point doing anything more here, we already know all the details. return combatant; } [combatant.name, combatant.realm, combatant.region] = ambiguate(srcNameRealm); LogHandler.activity.addCombatant(combatant); return combatant; } protected handleSpellDamage(line: LogLine) { if ( !LogHandler.activity || LogHandler.activity.category !== VideoCategory.Raids ) { // We only care about this event for working out boss HP, which we // only do in raids. return; } const max = parseInt(line.arg(15), 10); if ( LogHandler.activity.flavour === Flavour.Retail && max < LogHandler.minBossHp ) { // Assume that if the HP is less than 100 million then it's not a boss. // That avoids us marking bosses as 0% when they haven't been touched // yet, i.e. short pulls on Gallywix before the shield is broken and we are // yet to see SPELL_DAMAGE events (and instead get SPELL_ABSORBED). Only do // this for retail as classic will have lower HP bosses and I can't be // bothered worrying about it there. return; } const raid = LogHandler.activity as RaidEncounter; const current = parseInt(line.arg(14), 10); // We don't check the unit here, the RaidEncounter class has logic // to discard an update that lowers the max HP. That's a strategy to // avoid having to maintain a list of boss unit names. It's a reasonable // assumption usually that the boss has the most HP of all the units. raid.updateHp(current, max); } /** * Handle the pressing of the manual recording hotkey. */ public static async handleManualRecordingHotKey() { const sounds = ConfigService.getInstance().get('manualRecordSoundAlert'); if (!LogHandler.activity) { console.info('[LogHandler] Starting manual recording'); const startDate = new Date(); const activity = new Manual(startDate, Flavour.Retail); await LogHandler.startActivity(activity); if (sounds) playSoundAlert(SoundAlerts.MANUAL_RECORDING_START); return; } if (LogHandler.activity.category === VideoCategory.Manual) { console.info('[LogHandler] Stopping manual recording'); const endDate = new Date(); LogHandler.activity.end(endDate, true); // Result is meaningless but required. await LogHandler.endActivity(); if (sounds) playSoundAlert(SoundAlerts.MANUAL_RECORDING_STOP); return; } console.warn('[LogHandler] Unable to start manual recording'); if (sounds) playSoundAlert(SoundAlerts.MANUAL_RECORDING_ERROR); } } ================================================ FILE: src/parsing/LogLine.ts ================================================ /** * A just-in-time parsed line from the WoW combat log. * * The object is constructed from the original combat log line and will * parse log line arguments incrementally as they are requested. * * A log line like CHALLENGE_MODE_START which has few arguments won't see * much performance gain over parsing everything, but COMBATANT_INFO will * due to its absurdly long list of arguments that we most often won't use. */ export default class LogLine { // Current parsing position in the original line private _linePosition = 0; // Length of the original line to avoid reevaluating it // many times. private _lineLength = 0; // Multi-dimensional array of arguments // Example: 'ARENA_MATCH_START', '2547', '33', '2v2', '1' private _args: any[] = []; // Length of this.args to avoid evaluating this.args.length // may times. private _argsListLen = 0; // Timestamp in string format, as-is, from the log // Example: '8/3 22:09:58.548' public timestamp: string = ''; constructor(public original: string) { this._lineLength = this.original.length; // Combat log line always has ' ' format, // that is, two spaces between ts and line. this._linePosition = this.original.indexOf(' ') + 2; this.timestamp = this.original.substring(0, this._linePosition - 2); // Parse the first argument, which is the event type and will always // be needed. this.parseLogArg(1); } arg(index: number): any { if (!this._args || index >= this._argsListLen) { const maxsplit = Math.max(index + 1, this._argsListLen); this.parseLogArg(maxsplit); } return this._args[index]; } /** * Parse the timestamp from a log line and create a Date object from it. */ date(): Date { const timeParts = this.timestamp.split(/[^0-9]/); const dateObj = new Date(); if (timeParts.length >= 7) { // In TWW, Blizzard changed the timestamp format to include the year. // e.g. "7/27/2024 21:39:13.0951" const [month, day, year, hours, mins, secs] = timeParts.map((v) => parseInt(v, 10), ); dateObj.setMonth(month - 1); dateObj.setDate(day); dateObj.setFullYear(year); dateObj.setHours(hours); dateObj.setMinutes(mins); dateObj.setSeconds(secs); dateObj.setMilliseconds(0); } else { // Non-TWW timestamp, doesn't include year. // e.g. "4/9 20:04:44.359" const [month, day, hours, mins, secs] = timeParts.map((v) => parseInt(v, 10), ); dateObj.setMonth(month - 1); dateObj.setDate(day); dateObj.setHours(hours); dateObj.setMinutes(mins); dateObj.setSeconds(secs); dateObj.setMilliseconds(0); } return dateObj; } /** * Returns the combat log event type of the log line * E.g. `ENCOUNTER_START`. */ type(): string { return this.arg(0); } /** * Splits a WoW combat line intelligently with respect to quotes, * lists, tuples, and what have we. * * @param maxSplits Maximum number of elements to find (same as `limit` for `string.split()` ) */ private parseLogArg(maxSplits?: number): void { // Array of items that has been parsedin the current scope of the parsing. // // This can end up being multidimensional in the case of some combat events // that have complex data stored, like `COMBATANT_INFO`. const listItems: any[] = []; let inQuotedString = false; let openListCount = 0; let value: any = ''; for ( this._linePosition; this._linePosition < this._lineLength; this._linePosition++ ) { const char = this.original.charAt(this._linePosition); if (char === '\n') { break; } if (maxSplits && this._argsListLen >= maxSplits) { break; } if (inQuotedString) { if (char === '"') { inQuotedString = false; continue; } } else { switch (char) { case ',': if (openListCount > 0) { listItems.at(-1)?.push(value); } else { this.addArg(value); } value = ''; continue; case '"': inQuotedString = true; continue; case '[': case '(': listItems.push([]); openListCount++; continue; case ']': case ')': if (!listItems.length) { throw new Error(`Unexpected ${char}. No list is open.`); } if (value) { listItems.at(-1)?.push(value); } value = listItems.pop(); openListCount--; continue; default: // Linter is upset without a default case so now we have one. } } value += char; } if (value) { this.addArg(value); } if (openListCount > 0) { throw new Error( `Unexpected EOL. There are ${openListCount} open list(s).`, ); } } /** * Add an argument to the list */ private addArg(value: any): void { this._args.push(value); this._argsListLen = this._args.length; } toString(): string { return this.original; } } ================================================ FILE: src/parsing/RetailLogHandler.ts ================================================ import Combatant from '../main/Combatant'; import { currentRetailEncounters, dungeonEncounters, dungeonsByMapId, dungeonTimersByMapId, instanceDifficulty, retailBattlegrounds, retailUniqueSpecSpells, } from '../main/constants'; import ArenaMatch from '../activitys/ArenaMatch'; import LogHandler from './LogHandler'; import Battleground from '../activitys/Battleground'; import ChallengeModeDungeon from '../activitys/ChallengeModeDungeon'; import { ChallengeModeTimelineSegment, TimelineSegmentType, } from '../main/keystone'; import { Flavour } from '../main/types'; import SoloShuffle from '../activitys/SoloShuffle'; import LogLine from './LogLine'; import { VideoCategory } from '../types/VideoCategory'; import { isUnitSelf } from './logutils'; import ConfigService from 'config/ConfigService'; /** * RetailLogHandler class. */ export default class RetailLogHandler extends LogHandler { private isPtr = false; constructor(logPath: string) { super(logPath, 10); /* eslint-disable prettier/prettier */ this.combatLogWatcher .on('ENCOUNTER_START', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleEncounterStartLine(line));}) .on('ENCOUNTER_END', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleEncounterEndLine(line)); }) .on('ZONE_CHANGE', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleZoneChange(line)); }) .on('SPELL_AURA_APPLIED', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleSpellAuraAppliedLine(line)); }) .on('UNIT_DIED', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleUnitDiedLine(line)); }) .on('ARENA_MATCH_START', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleArenaStartLine(line)); }) .on('ARENA_MATCH_END', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleArenaEndLine(line)); }) .on('CHALLENGE_MODE_START', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleChallengeModeStartLine(line)); }) .on('CHALLENGE_MODE_END', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleChallengeModeEndLine(line)); }) .on('COMBATANT_INFO', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleCombatantInfoLine(line)); }) .on('SPELL_CAST_SUCCESS', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleSpellCastSuccess(line)); }) .on('SPELL_DAMAGE', (line: LogLine) => { this.logProcessQueue.add(async () => this.handleSpellDamage(line)); }); /* eslint-enable prettier/prettier */ } public setIsPtr() { this.isPtr = true; } private async handleArenaStartLine(line: LogLine) { console.debug('[RetailLogHandler] Handling ARENA_MATCH_START line:', line); if (this.isManual()) { console.info('[RetailLogHandler] Ignoring line as in manual recording'); return; } if ( LogHandler.activity && LogHandler.activity.category !== VideoCategory.SoloShuffle ) { // Important we don't end an activity if we're in a Solo Shuffle, // we use the ARENA_MATCH_START events to keep track of the rounds. console.warn('[RetailLogHandler] Active activity on ARENA_START'); await LogHandler.forceEndActivity(); } const startTime = line.date(); const zoneID = parseInt(line.arg(1), 10); const arenaType = line.arg(3); let category; if (arenaType === 'Rated Solo Shuffle') { category = VideoCategory.SoloShuffle; } else if (arenaType === '2v2') { category = VideoCategory.TwoVTwo; } else if (arenaType === '3v3') { category = VideoCategory.ThreeVThree; } else if (arenaType === '5v5') { // For some bizzare reason, 3v3 retail war games are logged as 5v5. // Thanks Blizz - https://github.com/aza547/wow-recorder/issues/285. category = VideoCategory.ThreeVThree; } else if (arenaType === 'Skirmish') { category = VideoCategory.Skirmish; } else { console.error( '[RetailLogHandler] Unrecognised arena category:', arenaType, ); return; } if (!LogHandler.activity && category === VideoCategory.SoloShuffle) { console.info('[RetailLogHandler] Fresh Solo Shuffle game starting'); const activity = new SoloShuffle(startTime, zoneID); await LogHandler.startActivity(activity); } else if (LogHandler.activity && category === VideoCategory.SoloShuffle) { console.info( '[RetailLogHandler] New round of existing Solo Shuffle starting', ); const soloShuffle = LogHandler.activity as SoloShuffle; soloShuffle.startRound(startTime); } else { console.info('[RetailLogHandler] New', category, 'arena starting'); const activity = new ArenaMatch( startTime, category, zoneID, Flavour.Retail, ); await LogHandler.startActivity(activity); } } private async handleArenaEndLine(line: LogLine) { console.debug('[RetailLogHandler] Handling ARENA_MATCH_END line:', line); if (this.isManual()) { console.info('[RetailLogHandler] Ignoring line as in manual recording'); return; } if (!LogHandler.activity) { console.error('[RetailLogHandler] Arena stop with no active arena match'); return; } if (LogHandler.activity.category === VideoCategory.SoloShuffle) { const soloShuffle = LogHandler.activity as SoloShuffle; soloShuffle.endGame(line.date()); await LogHandler.endActivity(); } else { const arenaMatch = LogHandler.activity as ArenaMatch; const endTime = line.date(); const winningTeamID = parseInt(line.arg(1), 10); arenaMatch.endArena(endTime, winningTeamID); await LogHandler.endActivity(); } } private async handleChallengeModeStartLine(line: LogLine) { console.debug( '[RetailLogHandler] Handling CHALLENGE_MODE_START line:', line, ); if (this.isManual()) { console.info('[RetailLogHandler] Ignoring line as in manual recording'); return; } if ( LogHandler.activity && LogHandler.activity.category === VideoCategory.MythicPlus ) { // This can happen if you zone in and out of a key mid pull. // If it's a new key, we see a CHALLENGE_MODE_END event first. console.info('[RetailLogHandler] Subsequent start event for dungeon'); return; } const zoneID = parseInt(line.arg(2), 10); const mapID = parseInt(line.arg(3), 10); const unknownMap = !Object.prototype.hasOwnProperty.call( dungeonsByMapId, mapID, ); if (unknownMap) { console.error('[RetailLogHandler] Unknown map', mapID); return; } const unknownTimer = !Object.prototype.hasOwnProperty.call( dungeonTimersByMapId, mapID, ); if (unknownTimer) { console.error('[RetailLogHandler] Unknown timer', mapID); return; } const startTime = line.date(); const level = parseInt(line.arg(4), 10); const affixes = line.arg(5).map(Number); const minLevelToRecord = ConfigService.getInstance().get('minKeystoneLevel'); if (level < minLevelToRecord) { console.info('[RetailLogHandler] Ignoring key below recording threshold'); return; } const activity = new ChallengeModeDungeon( startTime, zoneID, mapID, level, affixes, Flavour.Retail, ); const initialSegment = new ChallengeModeTimelineSegment( TimelineSegmentType.Trash, activity.startDate, 0, ); activity.addTimelineSegment(initialSegment); await LogHandler.startActivity(activity); } private async handleChallengeModeEndLine(line: LogLine) { console.debug('[RetailLogHandler] Handling CHALLENGE_MODE_END line:', line); if (this.isManual()) { console.info('[RetailLogHandler] Ignoring line as in manual recording'); return; } if (!LogHandler.activity) { console.error( '[RetailLogHandler] Challenge mode stop with no active ChallengeModeDungeon', ); return; } const challengeModeActivity = LogHandler.activity as ChallengeModeDungeon; const endDate = line.date(); // Need to convert to int here as "0" evaluates to truthy. const result = Boolean(parseInt(line.arg(2), 10)); // The actual log duration of the dungeon, from which keystone upgrade // levels can be calculated. This includes player death penalty. const CMDuration = parseInt(line.arg(4), 10) / 1000; if (result) { const overrun = ConfigService.getInstance().get('dungeonOverrun'); challengeModeActivity.overrun = overrun; } challengeModeActivity.endChallengeMode(endDate, CMDuration, result); await LogHandler.endActivity(); } protected async handleEncounterStartLine(line: LogLine) { console.debug('[RetailLogHandler] Handling ENCOUNTER_START line:', line); if (this.isManual()) { console.info('[RetailLogHandler] Ignoring line as in manual recording'); return; } const encounterID = parseInt(line.arg(1), 10); const knownDungeonEncounter = Object.prototype.hasOwnProperty.call( dungeonEncounters, encounterID, ); if (!LogHandler.activity && knownDungeonEncounter) { // We can hit this branch due to a few cases: // - It's a regular dungeon, we don't record those // - It's a M+ below the recording threshold console.info('[RetailLogHandler] Known dungeon encounter and not in M+'); return; } if (LogHandler.activity && !knownDungeonEncounter) { // Not a known dungeon encounter and in an activity so end the // activity so we can start a raid encounter. This can happen // if you abandon a key mid-pull and quickly start a raid boss // before WCR has realized the M+ is over. console.info('[RetailLogHandler] Active M+ but not a dungeon encounter'); await LogHandler.forceEndActivity(); } if (!LogHandler.activity) { const currentRaidOnly = ConfigService.getInstance().get( 'recordCurrentRaidEncountersOnly', ); if ( !this.isPtr && currentRaidOnly && !currentRetailEncounters.includes(encounterID) ) { console.warn('[RetailLogHandler] Not a current encounter'); return; } const logDifficultyID = parseInt(line.arg(3), 10); const { difficultyID } = instanceDifficulty[logDifficultyID]; const orderedDifficulty = ['lfr', 'normal', 'heroic', 'mythic']; const minDifficultyToRecord = ConfigService.getInstance() .get('minRaidDifficulty') .toLowerCase(); const actualIndex = orderedDifficulty.indexOf(difficultyID); const configuredIndex = orderedDifficulty.indexOf(minDifficultyToRecord); if (actualIndex < configuredIndex) { console.info( '[RetailLogHandler] Not recording as threshold not met by', actualIndex, configuredIndex, ); return; } await super.handleEncounterStartLine(line, Flavour.Retail); return; } const { category } = LogHandler.activity; const isChallengeMode = category === VideoCategory.MythicPlus; if (!isChallengeMode) { console.error( '[RetailLogHandler] Encounter is already in progress and not a ChallengeMode', ); return; } const activeChallengeMode = LogHandler.activity as ChallengeModeDungeon; const eventDate = line.date(); const segment = new ChallengeModeTimelineSegment( TimelineSegmentType.BossEncounter, eventDate, this.getRelativeTimestampForTimelineSegment(eventDate), encounterID, ); activeChallengeMode.addTimelineSegment(segment, eventDate); console.debug( `[RetailLogHandler] Starting new boss encounter: ${dungeonEncounters[encounterID]}`, ); } protected async handleEncounterEndLine(line: LogLine) { console.debug('[RetailLogHandler] Handling ENCOUNTER_END line:', line); if (this.isManual()) { console.info('[RetailLogHandler] Ignoring line as in manual recording'); return; } if (!LogHandler.activity) { console.error( '[RetailLogHandler] Encounter end event spotted but not in activity', ); return; } const { category } = LogHandler.activity; const isChallengeMode = category === VideoCategory.MythicPlus; if (!isChallengeMode) { console.debug( '[RetailLogHandler] Must be raid encounter, calling super method.', ); await super.handleEncounterEndLine(line); } else { console.debug('[RetailLogHandler] Challenge mode boss encounter.'); const activeChallengeMode = LogHandler.activity as ChallengeModeDungeon; const eventDate = line.date(); const result = Boolean(parseInt(line.arg(5), 10)); const encounterID = parseInt(line.arg(1), 10); const { currentSegment } = activeChallengeMode; if (currentSegment) { currentSegment.result = result; } const segment = new ChallengeModeTimelineSegment( TimelineSegmentType.Trash, eventDate, this.getRelativeTimestampForTimelineSegment(eventDate), ); // Add a trash segment as the boss encounter ended activeChallengeMode.addTimelineSegment(segment, eventDate); console.debug( `[RetailLogHandler] Ending boss encounter: ${dungeonEncounters[encounterID]}`, ); } } private async handleZoneChange(line: LogLine) { console.info('[RetailLogHandler] Handling ZONE_CHANGE line:', line); if (this.isManual()) { console.info('[RetailLogHandler] Ignoring line as in manual recording'); return; } const zoneID = parseInt(line.arg(1), 10); const isZoneBG = Object.prototype.hasOwnProperty.call( retailBattlegrounds, zoneID, ); if (LogHandler.activity) { const { category } = LogHandler.activity; const isActivityBG = category === VideoCategory.Battlegrounds; const isActivityArena = this.isArena(); if (isZoneBG && isActivityBG) { console.info('[RetailLogHandler] Internal BG zone change: ', zoneID); } else if (!isZoneBG && isActivityBG) { console.info('[RetailLogHandler] Zone change out of BG'); await this.battlegroundEnd(line); } else if (isActivityArena) { if (zoneID === LogHandler.activity.zoneID) { console.info( '[RetailLogHandler] ZONE_CHANGE within arena, no action taken', ); } else { console.info( '[RetailLogHandler] ZONE_CHANGE out of arena, ending match', ); await this.zoneChangeStop(line); } } else if (isZoneBG && !isActivityBG) { console.error( '[RetailLogHandler] Zoned into BG but in a different activity', ); await LogHandler.forceEndActivity(); await this.battlegroundStart(line); } else { console.info( '[RetailLogHandler] Unknown zone change, no action taken: ', zoneID, ); } } else if (isZoneBG) { console.info('[RetailLogHandler] Zone change into BG'); await this.battlegroundStart(line); } else { console.info('[RetailLogHandler] Uninteresting zone change'); } } private handleCombatantInfoLine(line: LogLine): void { console.info('[RetailLogHandler] Handling COMBATANT_INFO line:', line); if (this.isManual()) { console.info('[RetailLogHandler] Ignoring line as in manual recording'); return; } if (!LogHandler.activity) { console.warn( '[RetailLogHandler] No activity in progress, ignoring COMBATANT_INFO', ); return; } const GUID = line.arg(1); // In Mythic+ we see COMBANTANT_INFO events for each encounter. // Don't bother overwriting them if we have them already. const combatant = LogHandler.activity.getCombatant(GUID); if (combatant && combatant.isFullyDefined()) { return; } const teamID = parseInt(line.arg(2), 10); const specID = parseInt(line.arg(25), 10); console.info( '[RetailLogHandler] Adding combatant from COMBATANT_INFO', GUID, teamID, specID, ); const newCombatant = new Combatant(GUID, teamID, specID); LogHandler.activity.addCombatant(newCombatant); } private handleSpellAuraAppliedLine(line: LogLine) { if (!LogHandler.activity || this.isManual()) { // Deliberately don't log anything here as we can hit this a lot return; } const srcGUID = line.arg(1); const srcFlags = parseInt(line.arg(3), 16); const srcNameRealm = line.arg(2); // The isUnitSelf() check is important here as for M+ we won't see // COMBATANT_INFO events till the boss is pulled, which would cause // us to drop the recording on an abandoned key with no boss pulls. // Adding the combatant here if it's ourselves ensures we atleast // have a partially defined combatant. See issue 650 & 683. const allowNew = this.isBattleground() || isUnitSelf(srcFlags); this.processCombatant(srcGUID, srcNameRealm, srcFlags, allowNew); } private handleSpellCastSuccess(line: LogLine) { if (!LogHandler.activity || this.isManual()) { // Deliberately don't log anything here as we can hit this a lot return; } const srcGUID = line.arg(1); const srcNameRealm = line.arg(2); const srcFlags = parseInt(line.arg(3), 16); // The isUnitSelf() check is important here as for M+ we won't see // COMBATANT_INFO events till the boss is pulled, which would cause // us to drop the recording on an abandoned key with no boss pulls. // Adding the combatant here if it's ourselves ensures we atleast // have a partially defined combatant. See issue 650 & 683. const allowNew = this.isBattleground() || isUnitSelf(srcFlags); const combatant = this.processCombatant( srcGUID, srcNameRealm, srcFlags, allowNew, ); if ( combatant === undefined || combatant.specID !== undefined || !this.isBattleground() ) { // Nothing to do here either of: // - No combatant was processed (e.g. it's not a player) // - We already know their spec (we've already processed them) // - It's not a BG (every other retail activity fires COMBATANT_INFO) return; } const spellName = line.arg(10); const knownSpell = Object.prototype.hasOwnProperty.call( retailUniqueSpecSpells, spellName, ); if (knownSpell) { combatant.specID = retailUniqueSpecSpells[spellName]; } } private getRelativeTimestampForTimelineSegment(eventDate: Date) { if (!LogHandler.activity) { console.error( '[RetailLogHandler] getRelativeTimestampForTimelineSegment called but no active activity', ); return 0; } const activityStartDate = LogHandler.activity.startDate; const relativeTime = (eventDate.getTime() - activityStartDate.getTime()) / 1000; return relativeTime; } private async battlegroundStart(line: LogLine) { if (LogHandler.activity) { console.error( "[RetailLogHandler] Another activity in progress, can't start battleground", ); return; } const startTime = line.date(); const category = VideoCategory.Battlegrounds; const zoneID = parseInt(line.arg(1), 10); const activity = new Battleground( startTime, category, zoneID, Flavour.Retail, ); await LogHandler.startActivity(activity); } private async battlegroundEnd(line: LogLine) { if (!LogHandler.activity) { console.error( "[RetailLogHandler] Can't stop battleground as no active activity", ); return; } const endTime = line.date(); LogHandler.activity.end(endTime, false); await LogHandler.endActivity(); } } ================================================ FILE: src/parsing/logutils.ts ================================================ import { UnitFlags } from '../main/types'; export const hasFlag = (flags: number, flag: number) => { return (flags & flag) !== 0; }; export const isUnitFriendly = (flags: number) => { return hasFlag(flags, UnitFlags.REACTION_FRIENDLY); }; export const isUnitSelf = (flags: number) => { const isFriendly = hasFlag(flags, UnitFlags.REACTION_FRIENDLY); const isMine = hasFlag(flags, UnitFlags.AFFILIATION_MINE); return isFriendly && isMine; }; export const isUnitPlayer = (flags: number) => { const isPlayerControlled = hasFlag(flags, UnitFlags.CONTROL_PLAYER); const isPlayerType = hasFlag(flags, UnitFlags.TYPE_PLAYER); return isPlayerControlled && isPlayerType; }; export const ambiguate = (nameRealm: string): string[] => { const split = nameRealm.split('-'); const name = split[0]; const realm = split[1]; const region = split[3]; return [name, realm, region]; }; ================================================ FILE: src/renderer/App.css ================================================ /* * @NOTE: Prepend a `~` to css file paths that are in your node_modules * See https://github.com/webpack-contrib/sass-loader#imports */ @import url('https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap'); @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 0 0% 9%; --background-higher: 0 0% 11%; --background-dark-gradient-from: 0 0% 7%; --background-dark-gradient-to: 0 0% 8%; --foreground: 0, 0%, 45%; --foreground-lighter: 0, 0%, 66%; --muted: 223 47% 11%; --muted-foreground: 215.4 16.3% 56.9%; --accent: 216 34% 17%; --accent-foreground: 210 40% 98%; --popover: 0 0% 30%; --popover-foreground: 0 0% 86%; --popover-border: 0 0% 25%; --popover-inset: 0 0% 35%; --border: 216 34% 17%; --input: 216 34% 17%; --card: 0 0% 23%; --card-foreground: 0 0% 69%; --primary: 14 71% 43%; --primary-foreground: 0 0% 100%; --secondary: 0 0% 28%; --secondary-foreground: 0 0% 69%; --destructive: 0 63% 31%; --destructive-foreground: 210 40% 98%; --ring: 216 34% 17%; --radius: 0.5rem; --font-sans: 'Inter'; --success: 149, 69%, 51%; --success-border: 149, 66%, 39%; --error: 0, 70%, 35%; --error-border: 0, 68%, 26%; --error-foreground: 0, 70%, 66%; --warning: 45, 93%, 47%; --warning-border: 45, 90%, 35%; --blue-accent: 200, 98%, 39%; --blue-accent-border: 200, 95%, 27%; --video-item-background: 0 0% 18%; --video-item-background-hover: 0 0% 24%; --video-item-border: 0 0% 21.16%; --video-item-foreground: 0 0% 77.17%; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; font-feature-settings: 'rlig' 1, 'calt' 1; word-break: keep-all; } } html { margin: 0px; height: 100%; width: 100%; overflow: clip; background-color: var(--background); } body { margin: 0px; height: 100%; width: 100%; font-family: sans-serif; } div#root { height: 100%; width: 100%; } #title-bar { -webkit-app-region: drag; } #title { color: #bb4220; line-height: 32px; font-size: 14px; text-align: center; font-weight: bold; } #title-bar-btns { -webkit-app-region: no-drag; } #navigator { color: white; } .center { border: 5px solid #ffff00; text-align: center; } .status-buttons { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: 108.75px; padding-top: 0.5rem; padding-left: 0.25rem; justify-items: center; gap: 0.125rem 0rem; position: fixed; bottom: 0px; left: 0px; } .status-buttons div button { background-color: transparent; border-width: 0px; cursor: pointer; } .status-buttons div button:hover { background-color: darkgrey; border-width: 0px; border-radius: 10px; } .status-buttons-fatal-error { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 75px; padding-top: 0.5rem; padding-left: 0.25rem; gap: 0.125rem 0rem; } .status-buttons-fatal-error div button { background-color: transparent; border-width: 0px; cursor: pointer; } .status-buttons-fatal-error div button:hover { background-color: darkgrey; border-width: 0px; border-radius: 10px; } .app-buttons { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); width: 145px; padding-top: 0.5rem; padding-left: 0.25rem; justify-items: center; gap: 0.125rem 0rem; position: fixed; bottom: 0px; right: 0px; } .app-buttons div button { background-color: transparent; border-width: 0px; cursor: pointer; } .app-buttons div button:hover { background-color: darkgrey; border-width: 0px; border-radius: 10px; } .app-buttons-fatal-error { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 75px; padding-top: 0.5rem; padding-left: 0.25rem; gap: 0.125rem 0rem; } .app-buttons-fatal-error div button { background-color: transparent; border-width: 0px; cursor: pointer; } .app-buttons-fatal-error div button:hover { background-color: darkgrey; border-width: 0px; border-radius: 10px; } #spec-icon { width: 20px; height: 20px; } .goodResult { -webkit-text-fill-color: #1eff00; } .decentResult { -webkit-text-fill-color: #7fff00; } .moderateResult { -webkit-text-fill-color: yellow; } .mehResult { -webkit-text-fill-color: orange; } .badResult { -webkit-text-fill-color: red; } .difficulty-normal { -webkit-text-fill-color: #1eff00; } .difficulty-heroic { -webkit-text-fill-color: #0070dd; } .difficulty-mythic { -webkit-text-fill-color: #ff8000; } .segment-entry { width: -webkit-fill-available; } .segment-entry .goodResult { -webkit-text-fill-color: rgb(53, 164, 50); } .segment-entry .badResult { -webkit-text-fill-color: rgb(156, 21, 21); } .segment-entry .segment-type { padding-right: 20px; float: left; } .segment-entry .segment-result { float: right; } .segment-entry .segment-result { text-align: right; } @keyframes blink { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } } .DEATHKNIGHT { -webkit-text-fill-color: #c41e3a; } .DEMONHUNTER { -webkit-text-fill-color: #a330c9; } .DRUID { -webkit-text-fill-color: #ff7c0a; } .HUNTER { -webkit-text-fill-color: #aad372; } .MAGE { -webkit-text-fill-color: #3fc7eb; } .MONK { -webkit-text-fill-color: #00ff98; } .PALADIN { -webkit-text-fill-color: #f48cba; } .PRIEST { -webkit-text-fill-color: #ffffff; } .ROGUE { -webkit-text-fill-color: #fff468; } .SHAMAN { -webkit-text-fill-color: #0070dd; } .WARLOCK { -webkit-text-fill-color: #8788ee; } .WARRIOR { -webkit-text-fill-color: #c69b6d; } .EVOKER { -webkit-text-fill-color: #33937f; } .hidden { display: none; } .version-update-widget { position: fixed; text-align: center; width: 130px; top: 325px; left: 9px; border-style: solid; border-color: #bb4220; background-color: #bb4220; font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif; font-weight: 500; font-size: 0.875rem; } .version-update-widget > div { text-align: center; padding: 0 0.5rem; } a { color: #fff; text-decoration: none; } a:hover { color: black; text-decoration: none; } .btn { display: inline-block; font-weight: 400; color: #212529; text-align: center; vertical-align: middle; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; background-color: transparent; border: 1px solid transparent; padding: 0.375rem 0.75rem; font-size: 1rem; line-height: 1.5; border-radius: 0.25rem; transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; width: 65px; } @media (prefers-reduced-motion: reduce) { .btn { transition: none; } } .btn:hover { color: #212529; text-decoration: none; } .btn.focus, .btn:focus { outline: 0; box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); } .btn.disabled, .btn:disabled { opacity: 0.65; } .btn:not(:disabled):not(.disabled) { cursor: pointer; } a.btn.disabled, fieldset:disabled a.btn { pointer-events: none; } .btn-primary { color: #fff; background-color: #bb4220; border-color: #bb4220; } .btn-primary:hover { color: #fff; background-color: #de5e3b; } .btn-primary.focus, .btn-primary:focus { color: #fff; background-color: #de5e3b; box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5); } .btn-primary.disabled, .btn-primary:disabled { color: #fff; background-color: #bb4220; } .btn-primary:not(:disabled):not(.disabled).active, .btn-primary:not(:disabled):not(.disabled):active, .show > .btn-primary.dropdown-toggle { color: #fff; background-color: #de5e3b; } .btn-primary:not(:disabled):not(.disabled).active:focus, .btn-primary:not(:disabled):not(.disabled):active:focus, .show > .btn-primary.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5); } .btn-secondary { color: #fff; background-color: #636b83; } .btn-secondary:hover { color: #fff; background-color: #99a0b2; } .btn-secondary.focus, .btn-secondary:focus { color: #fff; background-color: #99a0b2; box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5); } .btn-secondary.disabled, .btn-secondary:disabled { color: #fff; background-color: #99a0b2; } .btn-secondary:not(:disabled):not(.disabled).active, .btn-secondary:not(:disabled):not(.disabled):active, .show > .btn-secondary.dropdown-toggle { color: #fff; background-color: #99a0b2; } .btn-secondary:not(:disabled):not(.disabled).active:focus, .btn-secondary:not(:disabled):not(.disabled):active:focus, .show > .btn-secondary.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5); } .btn-success { color: #fff; background-color: #28a745; border-color: #28a745; } .btn-success:hover { color: #fff; background-color: #218838; border-color: #1e7e34; } .btn-success.focus, .btn-success:focus { color: #fff; background-color: #218838; border-color: #1e7e34; box-shadow: 0 0 0 0.2rem rgba(72, 180, 97, 0.5); } .btn-success.disabled, .btn-success:disabled { color: #fff; background-color: #28a745; border-color: #28a745; } .btn-success:not(:disabled):not(.disabled).active, .btn-success:not(:disabled):not(.disabled):active, .show > .btn-success.dropdown-toggle { color: #fff; background-color: #1e7e34; border-color: #1c7430; } .btn-success:not(:disabled):not(.disabled).active:focus, .btn-success:not(:disabled):not(.disabled):active:focus, .show > .btn-success.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(72, 180, 97, 0.5); } .btn-info { color: #fff; background-color: #17a2b8; border-color: #17a2b8; } .btn-info:hover { color: #fff; background-color: #138496; border-color: #117a8b; } .btn-info.focus, .btn-info:focus { color: #fff; background-color: #138496; border-color: #117a8b; box-shadow: 0 0 0 0.2rem rgba(58, 176, 195, 0.5); } .btn-info.disabled, .btn-info:disabled { color: #fff; background-color: #17a2b8; border-color: #17a2b8; } .btn-info:not(:disabled):not(.disabled).active, .btn-info:not(:disabled):not(.disabled):active, .show > .btn-info.dropdown-toggle { color: #fff; background-color: #117a8b; border-color: #10707f; } .btn-info:not(:disabled):not(.disabled).active:focus, .btn-info:not(:disabled):not(.disabled):active:focus, .show > .btn-info.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(58, 176, 195, 0.5); } .btn-warning { color: #212529; background-color: #ffc107; border-color: #ffc107; } .btn-warning:hover { color: #212529; background-color: #e0a800; border-color: #d39e00; } .btn-warning.focus, .btn-warning:focus { color: #212529; background-color: #e0a800; border-color: #d39e00; box-shadow: 0 0 0 0.2rem rgba(222, 170, 12, 0.5); } .btn-warning.disabled, .btn-warning:disabled { color: #212529; background-color: #ffc107; border-color: #ffc107; } .btn-warning:not(:disabled):not(.disabled).active, .btn-warning:not(:disabled):not(.disabled):active, .show > .btn-warning.dropdown-toggle { color: #212529; background-color: #d39e00; border-color: #c69500; } .btn-warning:not(:disabled):not(.disabled).active:focus, .btn-warning:not(:disabled):not(.disabled):active:focus, .show > .btn-warning.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(222, 170, 12, 0.5); } .btn-danger { color: #fff; background-color: #dc3545; border-color: #dc3545; } .btn-danger:hover { color: #fff; background-color: #c82333; border-color: #bd2130; } .btn-danger.focus, .btn-danger:focus { color: #fff; background-color: #c82333; border-color: #bd2130; box-shadow: 0 0 0 0.2rem rgba(225, 83, 97, 0.5); } .btn-danger.disabled, .btn-danger:disabled { color: #fff; background-color: #dc3545; border-color: #dc3545; } .btn-danger:not(:disabled):not(.disabled).active, .btn-danger:not(:disabled):not(.disabled):active, .show > .btn-danger.dropdown-toggle { color: #fff; background-color: #bd2130; border-color: #b21f2d; } .btn-danger:not(:disabled):not(.disabled).active:focus, .btn-danger:not(:disabled):not(.disabled):active:focus, .show > .btn-danger.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(225, 83, 97, 0.5); } .btn-light { color: #212529; background-color: #f8f9fa; border-color: #f8f9fa; } .btn-light:hover { color: #212529; background-color: #e2e6ea; border-color: #dae0e5; } .btn-light.focus, .btn-light:focus { color: #212529; background-color: #e2e6ea; border-color: #dae0e5; box-shadow: 0 0 0 0.2rem rgba(216, 217, 219, 0.5); } .btn-light.disabled, .btn-light:disabled { color: #212529; background-color: #f8f9fa; border-color: #f8f9fa; } .btn-light:not(:disabled):not(.disabled).active, .btn-light:not(:disabled):not(.disabled):active, .show > .btn-light.dropdown-toggle { color: #212529; background-color: #dae0e5; border-color: #d3d9df; } .btn-light:not(:disabled):not(.disabled).active:focus, .btn-light:not(:disabled):not(.disabled):active:focus, .show > .btn-light.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(216, 217, 219, 0.5); } .btn-dark { color: #fff; background-color: #343a40; border-color: #343a40; } .btn-dark:hover { color: #fff; background-color: #23272b; border-color: #1d2124; } .btn-dark.focus, .btn-dark:focus { color: #fff; background-color: #23272b; border-color: #1d2124; box-shadow: 0 0 0 0.2rem rgba(82, 88, 93, 0.5); } .btn-dark.disabled, .btn-dark:disabled { color: #fff; background-color: #343a40; border-color: #343a40; } .btn-dark:not(:disabled):not(.disabled).active, .btn-dark:not(:disabled):not(.disabled):active, .show > .btn-dark.dropdown-toggle { color: #fff; background-color: #1d2124; border-color: #171a1d; } .btn-dark:not(:disabled):not(.disabled).active:focus, .btn-dark:not(:disabled):not(.disabled):active:focus, .show > .btn-dark.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(82, 88, 93, 0.5); } .btn-outline-primary { color: #007bff; border-color: #007bff; } .btn-outline-primary:hover { color: #fff; background-color: #007bff; border-color: #007bff; } .btn-outline-primary.focus, .btn-outline-primary:focus { box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5); } .btn-outline-primary.disabled, .btn-outline-primary:disabled { color: #007bff; background-color: transparent; } .btn-outline-primary:not(:disabled):not(.disabled).active, .btn-outline-primary:not(:disabled):not(.disabled):active, .show > .btn-outline-primary.dropdown-toggle { color: #fff; background-color: #007bff; border-color: #007bff; } .btn-outline-primary:not(:disabled):not(.disabled).active:focus, .btn-outline-primary:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-primary.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5); } .btn-outline-secondary { color: #6c757d; border-color: #6c757d; } .btn-outline-secondary:hover { color: #fff; background-color: #6c757d; border-color: #6c757d; } .btn-outline-secondary.focus, .btn-outline-secondary:focus { box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); } .btn-outline-secondary.disabled, .btn-outline-secondary:disabled { color: #6c757d; background-color: transparent; } .btn-outline-secondary:not(:disabled):not(.disabled).active, .btn-outline-secondary:not(:disabled):not(.disabled):active, .show > .btn-outline-secondary.dropdown-toggle { color: #fff; background-color: #6c757d; border-color: #6c757d; } .btn-outline-secondary:not(:disabled):not(.disabled).active:focus, .btn-outline-secondary:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-secondary.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); } .btn-outline-success { color: #28a745; border-color: #28a745; } .btn-outline-success:hover { color: #fff; background-color: #28a745; border-color: #28a745; } .btn-outline-success.focus, .btn-outline-success:focus { box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5); } .btn-outline-success.disabled, .btn-outline-success:disabled { color: #28a745; background-color: transparent; } .btn-outline-success:not(:disabled):not(.disabled).active, .btn-outline-success:not(:disabled):not(.disabled):active, .show > .btn-outline-success.dropdown-toggle { color: #fff; background-color: #28a745; border-color: #28a745; } .btn-outline-success:not(:disabled):not(.disabled).active:focus, .btn-outline-success:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-success.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5); } .btn-outline-info { color: #17a2b8; border-color: #17a2b8; } .btn-outline-info:hover { color: #fff; background-color: #17a2b8; border-color: #17a2b8; } .btn-outline-info.focus, .btn-outline-info:focus { box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5); } .btn-outline-info.disabled, .btn-outline-info:disabled { color: #17a2b8; background-color: transparent; } .btn-outline-info:not(:disabled):not(.disabled).active, .btn-outline-info:not(:disabled):not(.disabled):active, .show > .btn-outline-info.dropdown-toggle { color: #fff; background-color: #17a2b8; border-color: #17a2b8; } .btn-outline-info:not(:disabled):not(.disabled).active:focus, .btn-outline-info:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-info.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5); } .btn-outline-warning { color: #ffc107; border-color: #ffc107; } .btn-outline-warning:hover { color: #212529; background-color: #ffc107; border-color: #ffc107; } .btn-outline-warning.focus, .btn-outline-warning:focus { box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5); } .btn-outline-warning.disabled, .btn-outline-warning:disabled { color: #ffc107; background-color: transparent; } .btn-outline-warning:not(:disabled):not(.disabled).active, .btn-outline-warning:not(:disabled):not(.disabled):active, .show > .btn-outline-warning.dropdown-toggle { color: #212529; background-color: #ffc107; border-color: #ffc107; } .btn-outline-warning:not(:disabled):not(.disabled).active:focus, .btn-outline-warning:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-warning.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5); } .btn-outline-danger { color: #dc3545; border-color: #dc3545; } .btn-outline-danger:hover { color: #fff; background-color: #dc3545; border-color: #dc3545; } .btn-outline-danger.focus, .btn-outline-danger:focus { box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5); } .btn-outline-danger.disabled, .btn-outline-danger:disabled { color: #dc3545; background-color: transparent; } .btn-outline-danger:not(:disabled):not(.disabled).active, .btn-outline-danger:not(:disabled):not(.disabled):active, .show > .btn-outline-danger.dropdown-toggle { color: #fff; background-color: #dc3545; border-color: #dc3545; } .btn-outline-danger:not(:disabled):not(.disabled).active:focus, .btn-outline-danger:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-danger.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5); } .btn-outline-light { color: #f8f9fa; border-color: #f8f9fa; } .btn-outline-light:hover { color: #212529; background-color: #f8f9fa; border-color: #f8f9fa; } .btn-outline-light.focus, .btn-outline-light:focus { box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5); } .btn-outline-light.disabled, .btn-outline-light:disabled { color: #f8f9fa; background-color: transparent; } .btn-outline-light:not(:disabled):not(.disabled).active, .btn-outline-light:not(:disabled):not(.disabled):active, .show > .btn-outline-light.dropdown-toggle { color: #212529; background-color: #f8f9fa; border-color: #f8f9fa; } .btn-outline-light:not(:disabled):not(.disabled).active:focus, .btn-outline-light:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-light.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5); } .btn-outline-dark { color: #343a40; border-color: #343a40; } .btn-outline-dark:hover { color: #fff; background-color: #343a40; border-color: #343a40; } .btn-outline-dark.focus, .btn-outline-dark:focus { box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5); } .btn-outline-dark.disabled, .btn-outline-dark:disabled { color: #343a40; background-color: transparent; } .btn-outline-dark:not(:disabled):not(.disabled).active, .btn-outline-dark:not(:disabled):not(.disabled):active, .show > .btn-outline-dark.dropdown-toggle { color: #fff; background-color: #343a40; border-color: #343a40; } .btn-outline-dark:not(:disabled):not(.disabled).active:focus, .btn-outline-dark:not(:disabled):not(.disabled):active:focus, .show > .btn-outline-dark.dropdown-toggle:focus { box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5); } .btn-link { font-weight: 400; color: #007bff; text-decoration: none; } .btn-link:hover { color: #0056b3; text-decoration: underline; } .btn-link.focus, .btn-link:focus { text-decoration: underline; } .btn-link.disabled, .btn-link:disabled { color: #6c757d; pointer-events: none; } .btn-group-lg > .btn, .btn-lg { padding: 0.5rem 1rem; font-size: 1.25rem; line-height: 1.5; border-radius: 0.3rem; } .btn-group-sm > .btn, .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; line-height: 1.5; border-radius: 0.2rem; } .btn-block { display: block; width: 100%; } .btn-block + .btn-block { margin-top: 0.5rem; } input[type='button'].btn-block, input[type='reset'].btn-block, input[type='submit'].btn-block { width: 100%; } .react-tags { position: relative; padding: 0.25rem 0 0 0.25rem; border: 2px solid #afb8c1; border-radius: 6px; background: #ffffff; /* shared font styles */ font-size: 1rem; line-height: 1.2; /* clicking anywhere will focus the input */ cursor: text; } .react-tags.is-active { border-color: #4f46e5; } .react-tags.is-disabled { opacity: 0.75; background-color: #eaeef2; /* Prevent any clicking on the component */ pointer-events: none; cursor: not-allowed; } .react-tags.is-invalid { border-color: #fd5956; box-shadow: 0 0 0 2px rgba(253, 86, 83, 0.25); } .react-tags__label { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; } .react-tags__list { /* Do not use display: contents, it's too buggy */ display: inline; padding: 0; } /* .react-tags__list-item { display: inline; list-style: none; } */ .react-tags__tag { /* match the font styles */ font-size: inherit; line-height: inherit; } .react-tags__tag:hover { color: #ffffff; background-color: #4f46e5; } .react-tags__tag::after { content: ''; display: inline-block; margin-left: 0.5rem; font-size: 0.875rem; background-color: #7c7d86; } .react-tags__tag:hover::after { background-color: #ffffff; } .react-tags__combobox { /* prevents autoresize overflowing the container */ max-width: 100%; } .react-tags__combobox-input { /* prevent autoresize overflowing the container */ max-width: 100%; /* remove styles and layout from this element */ outline: none; background: none; /* match the font styles */ font-size: inherit; line-height: inherit; } .react-tags__combobox-input::placeholder { color: #7c7d86; opacity: 1; } .react-tags__listbox { position: absolute; z-index: 1; top: calc(100% + 5px); /* Negate the border width on the container */ left: -2px; right: -2px; max-height: 12.5rem; overflow-y: auto; box-shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -4px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; } .react-tags__listbox-option { padding: 0.375rem 0.5rem; } .react-tags__listbox-option:hover { cursor: pointer; background: #bb4420; } .react-tags__listbox-option:not([aria-disabled='true']).is-active { background: #bb4420; color: #ffffff; } .react-tags__listbox-option[aria-disabled='true'] { color: #7c7d86; cursor: not-allowed; pointer-events: none; } .react-tags__listbox-option[aria-selected='true']::after { content: '✓'; margin-left: 0.5rem; } .react-tags__listbox-option[aria-selected='true']:not(.is-active)::after { color: #4f46e5; } .react-tags__listbox-option-highlight { background-color: #ffdd00; } .tag-group { display: flex; flex-direction: column; flex-wrap: wrap; align-content: flex-start; padding: 8px; gap: 8px; background-color: #00000003; margin: 0.25rem 0.5rem 0.5rem 0.25rem; justify-content: flex-start; border: 1px solid #e2e2e2; align-items: flex-start; border-radius: 4px; } .tag-group ul { margin: 0; } .tag-group > p { margin: 0; font-size: 0.8125rem; line-height: 1.5rem; color: #00000080; } /* The react-tailwindcss-datepicker we're using doesn't do a great job exposing ways to configure the theme, so this is a hack job to do that. */ .tailwind-datepicker > div { @apply -translate-y-3; } .tailwind-datepicker > div * { @apply border-card text-foreground-lighter hover:text-white; } .tailwind-datepicker > div > div { @apply bg-background-higher; } .tailwind-datepicker > div > div > div > div > div > div { @apply bg-card rounded-lg; } /* Hacky way of overriding the color theme. */ .tailwind-datepicker .bg-red-500 { @apply text-white bg-primary; } /* Hacky way of overriding the current date text style. */ .tailwind-datepicker .text-red-500 { @apply text-primary font-semibold; } /* Hacky way of overriding the style of dates outwith the month. Avoid changing the calendar icon which shares the same class. */ .tailwind-datepicker .text-gray-400:not(.absolute) { @apply text-secondary; } /* Hacky way of targetting the cancel button. */ .tailwind-datepicker .w-full.transition-all.duration-300.bg-white.dark\:text-gray-700.font-medium.border.border-gray-300.px-4.py-2.text-sm.rounded-md.focus\:ring-2.focus\:ring-offset-2.hover\:bg-gray-50.focus\:ring-red-500 { @apply border-none bg-transparent text-secondary-foreground hover:bg-secondary/80 h-[40px] mx-2; } /* Hacky way of targetting the apply button. */ .tailwind-datepicker .w-full.transition-all.duration-300.bg-red-500.border-red-500.font-medium.border.px-4.py-2.text-sm.rounded-md.focus\:ring-2.focus\:ring-offset-2.hover\:bg-red-600.focus\:ring-red-500 { @apply border-none disabled:pointer-events-none disabled:opacity-50 bg-primary h-[40px] mx-2 hover:bg-primary/90; } /* Fixes scrollbars that are not ScrollAreas */ .scrollbar-thin::-webkit-scrollbar { width: 6px; } .scrollbar-thin::-webkit-scrollbar-thumb { background-color: #555; /* thumb color */ border-radius: 5px; } ================================================ FILE: src/renderer/App.tsx ================================================ import { MemoryRouter as Router, Routes, Route } from 'react-router-dom'; import { useEffect, useMemo, useRef, useState } from 'react'; import { ErrorReport, MicStatus, Pages, RecStatus, SaveStatus, AppState, RendererVideo, CloudStatus, DiskStatus, StorageFilter, ActivityStatus, AdvancedLoggingStatus, KillVideoStatus, } from 'main/types'; import Box from '@mui/material/Box'; import { getLocalePhrase, Language } from 'localisation/translations'; import Layout from './Layout'; import RendererTitleBar from './RendererTitleBar'; import './App.css'; import { useSettings } from './useSettings'; import { getCategoryFromConfig, videoMatch, videoMatchName, } from './rendererutils'; import { TooltipProvider } from './components/Tooltip/Tooltip'; import Toaster from './components/Toast/Toaster'; import SideMenu from './SideMenu'; import { useToast } from './components/Toast/useToast'; import { Button } from './components/Button/Button'; import { ErrorBoundary } from 'react-error-boundary'; import { RefreshCcw } from 'lucide-react'; import { VideoCategory } from 'types/VideoCategory'; import { Phrase } from 'localisation/phrases'; import _ from 'lodash'; import { playAudio } from './sounds'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import KillVideoProgress from './KillVideoProgress'; const ipc = window.electron.ipcRenderer; const queryClient = new QueryClient(); const WarcraftRecorder = () => { const [config, setConfig] = useSettings(); const [error, setError] = useState(''); const [micStatus, setMicStatus] = useState(MicStatus.NONE); const [errorReports, setErrorReports] = useState([]); const updateNotified = useRef(false); const { toast } = useToast(); const [advancedLoggingStatus, setAdvancedLoggingStatus] = useState({ retail: true, classic: true, era: true, retailPtr: true, classicPtr: true, }); const [previewEnabled, setPreviewEnabled] = useState(true); const [recorderStatus, setRecorderStatus] = useState( RecStatus.WaitingForWoW, ); const [activityStatus, setActivityStatus] = useState( null, ); const [savingStatus, setSavingStatus] = useState( SaveStatus.NotSaving, ); const [killVideoStatus, setKillVideoStatus] = useState({ inProgress: false, perc: 0, }); const [updateAvailable, setUpdateAvailable] = useState(false); const [appState, setAppState] = useState({ // Navigation. page: Pages.None, category: getCategoryFromConfig(config), selectedVideos: [], multiPlayerMode: false, viewpointSelectionOpen: false, // Any text applied in the filter bar gets translated into a filter here. videoFilterTags: [], // Date range filter. dateRangeFilter: { startDate: null, endDate: null, }, // The storage filter. storageFilter: StorageFilter.BOTH, // We use this to conditionally hide the recording preview. videoFullScreen: false, // This allows us to retain the playing state of the video when switching viewpoints. playing: false, // The language the client is in. language: config.language as Language, // The cloud storage status. cloudStatus: { enabled: false, authenticated: false, authorized: false, guild: '', available: [], read: false, write: false, del: false, usage: 0, limit: 0, }, // The disk storage status. diskStatus: { usage: 0, limit: 0 }, // The chat panel state. chatOpen: false, // The preferred viewpoint for activities with multiple viewpoints. // This is updated when a user switches viewpoint to in the viewpoint // selector to remember their preference when changing rows in the table. preferredViewpoint: '', }); // The video state contains most of the frontend state. const [videoState, setVideoState] = useState([]); // The counters for display on the side menu. It's convient to keep these // seperate to the video state so we can apply filtering without changing the // counters. const videoCounters = useMemo>(() => { const counts = { [VideoCategory.TwoVTwo]: 0, [VideoCategory.ThreeVThree]: 0, [VideoCategory.FiveVFive]: 0, [VideoCategory.Skirmish]: 0, [VideoCategory.SoloShuffle]: 0, [VideoCategory.MythicPlus]: 0, [VideoCategory.Raids]: 0, [VideoCategory.Battlegrounds]: 0, [VideoCategory.Manual]: 0, [VideoCategory.Clips]: 0, }; // Don't count the same video with different storage types twice. Still // count different points of view of the same activity multiple times. const seen: string[] = []; videoState.forEach((rv) => { if (seen.includes(rv.videoName)) return; counts[rv.category]++; seen.push(rv.videoName); }); return counts; }, [videoState]); // Used to allow for hot switching of video players when moving between POVs. const persistentProgress = useRef(0); // Used to remember the player height when switching categories. const playerHeight = useRef(500); const updateRecStatus = (status: unknown, err: unknown) => { setRecorderStatus(status as RecStatus); if (status === RecStatus.InvalidConfig || status === RecStatus.FatalError) { setError(err as string); } }; const updateActivityStatus = (status: unknown) => { setActivityStatus(status as ActivityStatus); }; const updateSaveStatus = (status: unknown) => { setSavingStatus(status as SaveStatus); }; const updateMicStatus = (status: unknown) => { setMicStatus(status as MicStatus); }; const updateErrorReports = (report: unknown) => { setErrorReports((prevArray) => [...prevArray, report as ErrorReport]); }; const updateDiskStatus = (status: unknown) => { setAppState((prevState) => { return { ...prevState, diskStatus: status as DiskStatus, }; }); }; const updateCloudStatus = (status: unknown) => { setAppState((prevState) => { return { ...prevState, cloudStatus: status as CloudStatus, }; }); }; const onUpdateAvailable = () => { setUpdateAvailable(true); if (updateNotified.current) { // We already told the user. Don't bother them again. return; } const title = getLocalePhrase( appState.language, Phrase.UpdateAvailableTitle, ); const description = getLocalePhrase( appState.language, Phrase.UpdateAvailableText, ); const installButtonText = getLocalePhrase( appState.language, Phrase.UpdateAvailableInstallButtonText, ); const remindButtonText = getLocalePhrase( appState.language, Phrase.UpdateAvailableRemindButtonText, ); const updateToast = toast({ title, description, duration: 60000, action: (
), }); // Don't show this prompt again. updateNotified.current = true; }; const updateAdvancedLogging = (status: unknown) => { setAdvancedLoggingStatus(status as AdvancedLoggingStatus); }; const setCloudVideos = (videos: unknown) => { setVideoState((prev) => { const disk = prev.filter((video) => !video.cloud); return [...disk, ...(videos as RendererVideo[])]; }); setAppState((prevState) => { return { ...prevState, // Fixes issue 410 which caused the preview not to re-appear if // refreshState triggered when full screen. videoFullScreen: false, }; }); }; const setDiskVideos = (videos: unknown) => { setVideoState((prev) => { const cloud = prev.filter((video) => video.cloud); return [...cloud, ...(videos as RendererVideo[])]; }); setAppState((prevState) => { return { ...prevState, // Fixes issue 410 which caused the preview not to re-appear if // refreshState triggered when full screen. videoFullScreen: false, }; }); }; // Incrementally add a new cloud video to the frontend, or update // it if it exists already. const displayAddCloudVideo = (video: unknown) => { const rv = video as RendererVideo; const match = videoState.find((v) => videoMatch(v, rv)); if (match && _.isEqual(match, rv)) { // Video already exact match, no need to re-render. return; } setVideoState((prev) => [...prev.filter((v) => !videoMatch(v, rv)), rv]); setAppState((prevState) => { return { ...prevState, // Fixes issue 410 which caused the preview not to re-appear if // refreshState triggered when full screen. videoFullScreen: false, }; }); }; const displayRemoveCloudVideos = (videoNames: unknown) => { const names = videoNames as string[]; setVideoState((prev) => { const updated = prev.filter( (video) => !video.cloud || !names.includes(video.videoName), ); return updated; }); setAppState((prevState) => { return { ...prevState, // Fixes issue 410 which caused the preview not to re-appear if // refreshState triggered when full screen. videoFullScreen: false, }; }); }; const displayProtectCloudVideos = (videoNames: unknown) => { const names = videoNames as string[]; setVideoState((prev) => { const matches = prev.filter( (rv) => rv.cloud && names.includes(rv.videoName), ); matches.forEach((match) => { // Pretty sure only one of these matters. match.protected = true; match.isProtected = true; }); return prev; }); setAppState((prevState) => { return { ...prevState, // Fixes issue 410 which caused the preview not to re-appear if // refreshState triggered when full screen. videoFullScreen: false, }; }); }; const displayUnprotectCloudVideos = (videoNames: unknown) => { const names = videoNames as string[]; setVideoState((prev) => { const matches = prev.filter( (rv) => rv.cloud && names.includes(rv.videoName), ); matches.forEach((match) => { // Pretty sure only one of these matters. match.protected = false; match.isProtected = false; }); return prev; }); setAppState((prevState) => { return { ...prevState, // Fixes issue 410 which caused the preview not to re-appear if // refreshState triggered when full screen. videoFullScreen: false, }; }); }; const displayTagCloudVideo = (videoName: unknown, tag: unknown) => { const name = videoName as string; setVideoState((prev) => { const match = prev.find((rv) => rv.cloud && videoMatchName(rv, name)); if (match) { match.tag = tag as string; } return prev; }); setAppState((prevState) => { return { ...prevState, // Fixes issue 410 which caused the preview not to re-appear if // refreshState triggered when full screen. videoFullScreen: false, }; }); }; useEffect(() => { ipc.on('updateRecStatus', updateRecStatus); ipc.on('updateActivityStatus', updateActivityStatus); ipc.on('updateSaveStatus', updateSaveStatus); ipc.on('updateMicStatus', updateMicStatus); ipc.on('updateErrorReport', updateErrorReports); ipc.on('updateDiskStatus', updateDiskStatus); ipc.on('updateCloudStatus', updateCloudStatus); ipc.on('updateAvailable', onUpdateAvailable); ipc.on('playAudio', playAudio); ipc.on('setCloudVideos', setCloudVideos); ipc.on('setDiskVideos', setDiskVideos); ipc.on('displayAddCloudVideo', displayAddCloudVideo); ipc.on('displayRemoveCloudVideos', displayRemoveCloudVideos); ipc.on('displayProtectCloudVideos', displayProtectCloudVideos); ipc.on('displayUnprotectCloudVideos', displayUnprotectCloudVideos); ipc.on('displayTagCloudVideo', displayTagCloudVideo); ipc.on('updateAdvancedLoggingStatus', updateAdvancedLogging); return () => { ipc.removeAllListeners('updateRecStatus'); ipc.removeAllListeners('updateActivityStatus'); ipc.removeAllListeners('updateSaveStatus'); ipc.removeAllListeners('updateMicStatus'); ipc.removeAllListeners('updateErrorReport'); ipc.removeAllListeners('updateDiskStatus'); ipc.removeAllListeners('updateCloudStatus'); ipc.removeAllListeners('updateAvailable'); ipc.removeAllListeners('playAudio'); ipc.removeAllListeners('setCloudVideos'); ipc.removeAllListeners('setDiskVideos'); ipc.removeAllListeners('displayAddCloudVideo'); ipc.removeAllListeners('displayRemoveCloudVideos'); ipc.removeAllListeners('displayProtectCloudVideos'); ipc.removeAllListeners('displayUnprotectCloudVideos'); ipc.removeAllListeners('displayTagCloudVideo'); ipc.removeAllListeners('updateAdvancedLoggingStatus'); }; }, []); return (
); }; const renderErrorPage = () => { return (

It's a wipe, blame the healer programmer.

You hit a bug in the code. Please try refreshing.

); }; export default function App() { return ( } /> ); } ================================================ FILE: src/renderer/AudioSourceControls.tsx ================================================ import { AppState, AudioSource, AudioSourceType } from 'main/types'; import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'; import { configSchema } from 'config/configSchema'; import { AppWindow, AudioLines, Info, MicVocal, PlusIcon, Speaker, Volume1, Volume2, VolumeX, X, } from 'lucide-react'; import { getLocalePhrase } from 'localisation/translations'; import { useSettings, setConfigValues } from './useSettings'; import { getAudioSourceChoices, getKeyByValue, getKeyModifiersString, getNextKeyOrMouseEvent, getPTTKeyPressEventFromConfig, } from './rendererutils'; import { PTTKeyPressEvent, UiohookKeyMap } from '../types/KeyTypesUIOHook'; import Label from './components/Label/Label'; import { Tooltip } from './components/Tooltip/Tooltip'; import Slider from './components/Slider/Slider'; import Switch from './components/Switch/Switch'; import { Input } from './components/Input/Input'; import Progress from './components/Progress/Progress'; import { Button } from './components/Button/Button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from './components/Select/Select'; import { ObsListItem } from 'noobs'; import { Popover, PopoverContent, PopoverTrigger, } from './components/Popover/Popover'; import { Phrase } from 'localisation/phrases'; const ipc = window.electron.ipcRenderer; let debounceTimer: NodeJS.Timeout | undefined; interface IProps { appState: AppState; setPreviewEnabled: Dispatch>; } const AudioSourceControls = (props: IProps) => { const { appState, setPreviewEnabled } = props; const { language } = appState; const [config, setConfig] = useSettings(); const initialRender = useRef(true); const audioChoicesLoaded = useRef(false); const pttInputRef = useRef(null); // Available choices per source. const [sourceChoices, setSourceChoices] = useState< Record >({}); // Volmeter data. const [sourceMagnitude, setSourceMagnitude] = useState< Record >({}); // Volume popover state. We only allow one to be open at a time. const [volumePopoverSourceId, setVolumePopoverSourceId] = useState(''); // We will block adding new sources if we have a partially // configured process source (speakers and mics can both // default to "default" so this only effects process sources). let sourcesAreFullyDefined = true; config.audioSources.forEach((src) => { if (!src.device) { sourcesAreFullyDefined = false; } }); const [pttHotKeyFieldFocused, setPttHotKeyFieldFocused] = useState(false); const [pttHotKey, setPttHotKey] = useState( getPTTKeyPressEventFromConfig(config), ); const [localReleaseDelay, setLocalReleaseDelay] = useState( config.pushToTalkReleaseDelay, ); useEffect(() => { if (initialRender.current) return; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { setConfigValues({ pushToTalk: config.pushToTalk, pushToTalkKey: config.pushToTalkKey, pushToTalkMouseButton: config.pushToTalkMouseButton, pushToTalkModifiers: config.pushToTalkModifiers, pushToTalkReleaseDelay: config.pushToTalkReleaseDelay, }); // These parameters require a full audio reconfigure once // the config is applied. ipc.reconfigureAudio(); }, 500); }, [ config.pushToTalk, config.pushToTalkKey, config.pushToTalkMouseButton, config.pushToTalkModifiers, config.pushToTalkReleaseDelay, ]); useEffect(() => { // No reconfigure required for these parameters, we // do it via IPC call. if (initialRender.current) return; setConfigValues({ audioSources: config.audioSources, obsAudioSuppression: config.obsAudioSuppression, obsForceMono: config.obsForceMono, }); }, [config.audioSources, config.obsAudioSuppression, config.obsForceMono]); useEffect(() => { if (initialRender.current) return; const setPushToTalkKey = (event: PTTKeyPressEvent) => { setConfig((prevState) => { return { ...prevState, pushToTalkKey: event.keyCode, pushToTalkMouseButton: event.mouseButton, pushToTalkModifiers: getKeyModifiersString(event), }; }); }; const listenNextKeyPress = async () => { if (pttHotKeyFieldFocused) { const keyPressEvent = await getNextKeyOrMouseEvent(); setPttHotKeyFieldFocused(false); setPttHotKey(keyPressEvent); setPushToTalkKey(keyPressEvent); pttInputRef.current?.blur(); } }; listenNextKeyPress(); }, [pttHotKeyFieldFocused, setConfig]); useEffect(() => { initialRender.current = false; }, []); const volmeterRefresh = (id: string, magnitude: number) => { setSourceMagnitude((prev) => { prev[id] = magnitude; return { ...prev }; }); }; // On initial load we don't know the available choices for existing // sources in the config, so retrieve it here. const initAudioSourceChoices = async () => { const promises = config.audioSources.map(async (s) => ({ id: s.id, choices: await getAudioSourceChoices(s), })); const choices = await Promise.all(promises); const updated: Record = {}; choices.forEach((choice) => { updated[choice.id] = choice.choices; }); setSourceChoices(updated); audioChoicesLoaded.current = true; }; useEffect(() => { ipc.on('volmeter', (id: unknown, magnitude: unknown) => volmeterRefresh(id as string, magnitude as number), ); const initAudioSettings = async () => { await ipc.audioSettingsOpen(); await initAudioSourceChoices(); }; // Attach the audio devices so volmeter bars show // even if WoW is closed. initAudioSettings(); return () => { ipc.removeAllListeners('volmeter'); // Remove the audio devices so Windows can still sleep on unmounting. ipc.audioSettingsClosed(); }; }, []); const setForceMono = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, obsForceMono: checked, }; }); ipc.setForceMono(checked); }; const setPushToTalk = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, pushToTalk: checked, }; }); }; const setAudioSuppression = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, obsAudioSuppression: checked, }; }); ipc.setAudioSuppression(checked); }; const getMonoSwitch = () => { return (
); }; const getPushToTalkSwitch = () => { return (
); }; const getKeyPressEventString = (event: PTTKeyPressEvent) => { const keys: string[] = []; if (event.altKey) keys.push('Alt'); if (event.ctrlKey) keys.push('Ctrl'); if (event.shiftKey) keys.push('Shift'); if (event.metaKey) keys.push('Win'); const { keyCode, mouseButton } = event; if (keyCode > 0) { const key = getKeyByValue(UiohookKeyMap, keyCode); if (key !== undefined) keys.push(key); } else if (mouseButton > 0) { keys.push( `${getLocalePhrase(appState.language, Phrase.Mouse)} ${ event.mouseButton }`, ); } return keys.join('+'); }; const getHotkeyString = () => { if (pttHotKeyFieldFocused) { return getLocalePhrase(appState.language, Phrase.PressAnyKeyCombination); } if (pttHotKey !== null) { return `${getKeyPressEventString(pttHotKey)} (${getLocalePhrase( appState.language, Phrase.ClickToRebind, )})`; } return getLocalePhrase(appState.language, Phrase.ClickToBind); }; const getPushToTalkSelect = () => { return (
setPttHotKeyFieldFocused(true)} onBlur={() => setPttHotKeyFieldFocused(false)} readOnly />
); }; const getAudioSuppressionSwitch = () => { return (
); }; useEffect(() => { setLocalReleaseDelay(config.pushToTalkReleaseDelay); }, [config.pushToTalkReleaseDelay]); const commitReleaseDelay = (newValue: number[]) => { const ms = newValue[0]; if (typeof ms !== 'number') return; setConfig((prev) => ({ ...prev, pushToTalkReleaseDelay: ms })); }; const getPushToTalkReleaseDelaySlider = () => (
setLocalReleaseDelay(vals[0])} onValueCommit={commitReleaseDelay} min={0} max={2000} step={1} withTooltip={false} className="w-[80px]" /> {localReleaseDelay > 999 ? `${(localReleaseDelay / 1000).toFixed(2)}s` : `${localReleaseDelay} ms`}
); const setSourceDevice = (src: AudioSource, device: string) => { const idx = config.audioSources.indexOf(src); if (idx === -1) return; const choice = sourceChoices[src.id].find((item) => item.value === device); if (!choice) return; const clone = [...config.audioSources]; clone[idx] = { ...src, device, friendly: choice.name }; setConfig((prev) => ({ ...prev, audioSources: clone })); if (src.type === AudioSourceType.PROCESS) { ipc.setAudioSourceWindow(src.id, device); } else { ipc.setAudioSourceDevice(src.id, device); } }; const renderSourceType = (src: AudioSource) => { if (src.type === AudioSourceType.OUTPUT) { return (
); } if (src.type === AudioSourceType.INPUT) { return (
); } if (src.type === AudioSourceType.PROCESS) { return (
); } }; const renderSourceDeviceSelect = (src: AudioSource) => { if (typeof src.device === 'number') { // Stupid typeguard. This can't happen. return <>; } const choices: ObsListItem[] = []; if (sourceChoices[src.id]) { // Deduplicate on the name field. sourceChoices[src.id].forEach((choice) => { if (!choices.find((item) => item.name === choice.name)) { choices.push(choice); } }); } const renderSelectItems = () => { if (!audioChoicesLoaded.current) { return <>; } if (typeof src.device === 'number') { // Stupid typeguard. This can't happen. return <>; } const found = choices.find((tt) => tt.value === src.device); const items = choices.map((tt) => ( {tt.name} )); // If we don't find the device currently selected add it so it // renders with a warning. Don't do the warning this for process // as it's common for the windows to change and we do exe // matching anyway. if (!found && src.device) { const text = src.type !== AudioSourceType.PROCESS ? `⚠ Unknown device: ${src.friendly}` // TODO: Localise : src.device; items.push( {text} , ); } return items; }; return (
); }; const removeSource = (src: AudioSource) => { const idx = config.audioSources.indexOf(src); if (idx === -1) return; setConfig((prev) => { const clone = [...prev.audioSources]; clone.splice(idx, 1); return { ...prev, audioSources: clone }; }); setSourceChoices((prev) => { const clone = { ...prev }; delete clone[src.id]; return clone; }); setSourceMagnitude((prev) => { const clone = { ...prev }; delete clone[src.id]; return clone; }); setVolumePopoverSourceId(''); ipc.deleteAudioSource(src.id); }; const renderDeleteSourceButton = (src: AudioSource) => { return ( ); }; const toggleVolumePopover = (src: AudioSource, open: boolean) => { setVolumePopoverSourceId(open ? src.id : ''); }; const renderSourceRow = (src: AudioSource) => { const idx = config.audioSources.indexOf(src); const val = Math.round(src.volume * 100); let icon; if (val === 0) { icon = ; } else if (val < 50) { icon = ; } else { icon = ; } const magnitude = sourceMagnitude[src.id] ?? 0; return ( {renderSourceType(src)} {renderSourceDeviceSelect(src)}
toggleVolumePopover(src, open)} > { setConfig(() => { const idx = config.audioSources.indexOf(src); if (idx === -1) return config; const newSources = [...config.audioSources]; newSources[idx] = { ...newSources[idx], volume: newValue[0] / 100, }; return { ...config, audioSources: newSources }; }); ipc.setAudioSourceVolume(src.id, newValue[0] / 100); }} /> {renderDeleteSourceButton(src)} ); }; const addSource = async (type: AudioSourceType) => { const ids = config.audioSources.map((src) => src.id); let idx = 1; // Find the next available name. while (ids.includes(`WCR Audio Source ${idx}`)) { idx++; } const id = `WCR Audio Source ${idx}`; const name = await ipc.createAudioSource(id, type); const device = type === AudioSourceType.PROCESS ? undefined : 'default'; const friendly = device; const src: AudioSource = { id: name, // Careful to not assume we got the name we asked for. type, friendly, device, volume: 1, }; const choices = await getAudioSourceChoices(src); setConfig((prev) => ({ ...prev, audioSources: [...prev.audioSources, src], })); setSourceChoices((prev) => ({ ...prev, [src.id]: choices, })); }; const renderSourceTable = () => { return ( {config.audioSources.map(renderSourceRow)}
); }; const renderHelpText = () => { return (
{getLocalePhrase(language, Phrase.NoAudioSourcesText)}
); }; const getSourcesSection = () => { return (
{config.audioSources.length > 0 && renderSourceTable()} {config.audioSources.length < 1 && renderHelpText()}
); }; const getSettingsSection = () => { return (
{getAudioSuppressionSwitch()} {getMonoSwitch()} {getPushToTalkSwitch()} {config.pushToTalk && ( <> {getPushToTalkSelect()} {getPushToTalkReleaseDelaySlider()} )}
); }; return (
{getSourcesSection()} {getSettingsSection()}
); }; export default AudioSourceControls; ================================================ FILE: src/renderer/BattlegroundInfo.tsx ================================================ import React from 'react'; import Box from '@mui/material/Box'; import { RendererVideo } from 'main/types'; interface IProps { video: RendererVideo; } const BattlegroundInfo: React.FC = (props: IProps) => { const { video } = props; const { zoneName } = video; return ( {zoneName} ); }; export default BattlegroundInfo; ================================================ FILE: src/renderer/BulkTransferDialog.tsx ================================================ import { AppState, RendererVideo } from 'main/types'; import { getLocalePhrase } from 'localisation/translations'; import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from './components/Dialog/Dialog'; import { Button } from './components/Button/Button'; import { Phrase } from 'localisation/phrases'; type BulkTransferDialogProps = { children: React.ReactNode; inScope: RendererVideo[]; appState: AppState; upload: boolean; }; const ipc = window.electron.ipcRenderer; const BulkTransferDialog = ({ children, inScope, appState, upload, }: BulkTransferDialogProps) => { const { language } = appState; const getMessage = () => { const text = upload ? getLocalePhrase(language, Phrase.BulkUploadDialogText) : getLocalePhrase(language, Phrase.BulkDownloadDialogText); const warning = getLocalePhrase(language, Phrase.BulkTransferWarningText); return (

{text}

{warning}

); }; const doTransfer = () => { inScope.forEach((rv) => { if (upload) { ipc.sendMessage('videoButtonCloud', ['upload', rv.videoSource]); } else { ipc.sendMessage('videoButtonCloud', ['download', rv]); } }); }; return ( {children} {getLocalePhrase(appState.language, Phrase.AreYouSure)} {getMessage()} ); }; export default BulkTransferDialog; ================================================ FILE: src/renderer/CategoryPage.tsx ================================================ import * as React from 'react'; import { AppState, RendererVideo } from 'main/types'; import { Dispatch, MutableRefObject, SetStateAction, useEffect, useMemo, useRef, } from 'react'; import { GripHorizontal, LockKeyhole, Trash, LockOpen, CloudUpload, CloudDownload, ArrowLeftFromLine, ArrowRightToLine, Cloud, } from 'lucide-react'; import { getLocalePhrase } from 'localisation/translations'; import { VideoCategory } from '../types/VideoCategory'; import SearchBar from './SearchBar'; import VideoMarkerToggles from './VideoMarkerToggles'; import { useSettings } from './useSettings'; import { getVideoCategoryFilter, getVideoStorageFilter, povDiskFirstNameSort, } from './rendererutils'; import Separator from './components/Separator/Separator'; import { Button } from './components/Button/Button'; import VideoSelectionTable from './components/Tables/VideoSelectionTable'; import DeleteDialog from './DeleteDialog'; import MultiPovPlaybackToggles from './MultiPovPlaybackToggles'; import VideoFilter from './VideoFilter'; import { Resizable, ResizeCallback } from 're-resizable'; import { Direction } from 're-resizable/lib/resizer'; import VideoPlayer, { VideoPlayerRef } from './VideoPlayer'; import Label from './components/Label/Label'; import ViewpointSelection from './components/Viewpoints/ViewpointSelection'; import useTable from './components/Tables/TableData'; import { Tooltip } from './components/Tooltip/Tooltip'; import DateRangePicker from './DateRangePicker'; import StorageFilterToggle from './StorageFilterToggle'; import VideoCorrelator from './VideoCorrelator'; import { Phrase } from 'localisation/phrases'; import BulkTransferDialog from './BulkTransferDialog'; import VideoChat from './VideoChat'; import ConfirmChatNamePrompt from './ConfirmChatNamePrompt'; interface IProps { category: VideoCategory; videoState: RendererVideo[]; setVideoState: Dispatch>; appState: AppState; setAppState: Dispatch>; persistentProgress: MutableRefObject; playerHeight: MutableRefObject; } /** * A page representing a video category. */ const CategoryPage = (props: IProps) => { const { category, videoState, setVideoState, appState, setAppState, persistentProgress, playerHeight, } = props; const { selectedVideos, videoFilterTags, language, dateRangeFilter, cloudStatus, storageFilter, chatOpen, } = appState; const { write, del } = cloudStatus; const [config, setConfig] = useSettings(); // The category state, recalculated only when required. const categoryState = useMemo(() => { const categoryFilter = getVideoCategoryFilter(category); return videoState.filter(categoryFilter); }, [videoState, category]); // Filter by storage type before we apply grouping. const correlatedState = useMemo(() => { const storageFilterFn = getVideoStorageFilter(storageFilter); const storageFilteredState = categoryState.filter(storageFilterFn); return VideoCorrelator.correlate(storageFilteredState); }, [categoryState, storageFilter]); // Now apply filtering based on search tags and date range. const filteredState = useMemo(() => { const queryFilter = (rv: RendererVideo) => new VideoFilter(rv, videoFilterTags, dateRangeFilter, language).filter(); return correlatedState.filter(queryFilter); }, [correlatedState, dateRangeFilter, videoFilterTags, language]); // The data backing the video selection table. const table = useTable(filteredState, appState, setVideoState); const haveVideos = categoryState.length > 0; const isClips = category === VideoCategory.Clips; // Handle to reset the video player height. const resizableRef = useRef(null); const videoPlayerRef = useRef(null); useEffect(() => { const handleWindowResize = () => { if (!resizableRef.current) { // Could on only happen if we're resizing in the // middle of the initial mount. return; } // 96px = 32 (top bar) + 40 (video controls) + 24 (grip) if (playerHeight.current + 96 > window.innerHeight) { // The video is bigger than the window. Reset it // to the original size. Could probably check that // 500 is smaller than the window but who resizes // their window to be smaller than 500px? resizableRef.current.updateSize({ height: 500 }); playerHeight.current = 500; } }; window.addEventListener('resize', handleWindowResize); return () => { window.removeEventListener('resize', handleWindowResize); }; }, [playerHeight]); const renderChat = (video: RendererVideo | undefined) => { if (!video) { return (
{getLocalePhrase(language, Phrase.ChatUploadToCloudText)}
); } if (config.cloudAccountName !== config.chatUserNameAgreed) { return ( ); } return ( ); }; /** * Handle a resize event. */ const onResize: ResizeCallback = ( event: MouseEvent | TouchEvent, direction: Direction, element: HTMLElement, ) => { const height = element.clientHeight; playerHeight.current = height; }; const renderDrawerOpen = (appVersion: string | undefined) => { // Only the first row in the selection is relevant for the drawer display. const selectedRows = table.getSelectedRowModel().rows; const selectedRow = selectedRows[0]; const activeParentVideo = selectedRow ? selectedRow.original : filteredState[0]; // Only try to find a chat video if we have a video with cloud storage, // a start time and a hash, else we cannot find the chat correlator. const chatVideo = [activeParentVideo, ...activeParentVideo.multiPov].find( (rv) => rv.cloud && rv.uniqueHash && rv.start, ); return (
{appVersion && (
{getLocalePhrase(language, Phrase.RecordedAt)} v{appVersion}
)}
{renderChat(chatVideo)}
); }; const renderDrawerClosed = () => { return (
); }; /** * Render the video player. Safe to assume we have videos at this point * as we don't call this if haveVideos isn't true. * * If there is no selected videos (because we've just launched, or just * changed category) then just play the first video in the table. */ const getVideoPlayer = () => { const toShow = filteredState[0] ? filteredState[0] : categoryState[0]; const povs = [toShow, ...toShow.multiPov].sort(povDiskFirstNameSort); const videosToPlay = selectedVideos.length > 0 ? selectedVideos : povs.slice(0, 1); const selectedVideoAppVersion = videosToPlay.length > 1 ? undefined : videosToPlay[0].appVersion; return ( , }} >
rv.videoName + rv.cloud).join(', ')} videos={videosToPlay} categoryState={categoryState} persistentProgress={persistentProgress} config={config} appState={appState} setAppState={setAppState} /> {chatOpen && renderDrawerOpen(selectedVideoAppVersion)} {!chatOpen && renderDrawerClosed()}
); }; const getAllSelectedViewpoints = () => { const { rows } = table.getSelectedRowModel(); if (rows.length > 0) { const parents = rows.map((r) => r.original); const children = parents.flatMap((v) => v.multiPov); return parents.concat(children); } const first = filteredState[0] ? filteredState[0] : categoryState[0]; return [first, ...first.multiPov]; }; const getVideoSelection = () => { const selectedRows = table.getSelectedRowModel().rows; const selectedViewpoints = getAllSelectedViewpoints(); // We don't want multi player mode to be accessible if there isn't // multiple viewpoints, so check for that. Important to filter by // unique name here so we don't allow multi player mode for two // identical videos with different storage (i.e. disk/cloud). // // Handle the case where no row is selected yet but we do have atleast // an entry in the video selection table so default to the first to // decide if we can do multi player mode or not. // // The dedup function here removes videos with matching names, but // possibly alternative storage. We don't want to load a disk and cloud // pov of the same video. const dedup = (rv: RendererVideo, idx: number, arr: RendererVideo[]) => arr.findIndex((i) => i.videoName === rv.videoName) === idx; const selectedRow = selectedRows[0]; const multiPlayerOpts = ( selectedRow ? [selectedRow.original, ...selectedRow.original.multiPov] : filteredState[0] ? [filteredState[0], ...filteredState[0].multiPov] : [categoryState[0], ...categoryState[0].multiPov] ) .sort(povDiskFirstNameSort) .filter(dedup); const names = multiPlayerOpts.map((rv) => rv.videoName); const unique = [...new Set(names)]; const allowMultiPlayer = unique.length > 1; const protectVideo = ( _event: React.SyntheticEvent, protect: boolean, videos: RendererVideo[], ) => { const toProtectDisk = videos.filter((v) => !v.cloud); const toProtectCloud = videos.filter((v) => v.cloud); window.electron.ipcRenderer.sendMessage('videoButtonDisk', [ 'protect', protect, toProtectDisk, ]); window.electron.ipcRenderer.sendMessage('videoButtonCloud', [ 'protect', protect, toProtectCloud, ]); setVideoState((prev) => { const state = [...prev]; state.forEach((rv) => { // A video is uniquely identified by its name and storage type. const match = videos.find( (v) => v.videoName === rv.videoName && v.cloud === rv.cloud, ); if (match) { rv.isProtected = protect; } }); return state; }); }; const renderProtectButton = () => { const toProtect = selectedViewpoints; // If any videos in our selection are not protected, then the button's // action is to protect. const lock = !toProtect.every((v) => v.isProtected); // Disable the protect button if there are no selected viewpoints, if we // don't have write permissions, or if the action is to unprotect and we // don't have delete permissions. const noPermission = (!write && toProtect.some((v) => v.cloud)) || // Some in the selection are cloud videos and no write permission. (!del && !lock && toProtect.some((v) => v.cloud)); // Some in the selection are locked cloud videos no delete permission. const disabled = noPermission || toProtect.length < 1; const icon = lock ? : ; let tooltip = ''; if (noPermission) { tooltip = getLocalePhrase(language, Phrase.GuildNoPermission); } else if (lock) { tooltip = getLocalePhrase(language, Phrase.StarSelected); } else { tooltip = getLocalePhrase(language, Phrase.UnstarSelected); } return (
); }; const renderDeleteButton = () => { const toDelete = selectedViewpoints; const noPermission = !del && toDelete.some((v) => v.cloud); const disabled = toDelete.length < 1 || noPermission; const tooltip = noPermission ? getLocalePhrase(language, Phrase.GuildNoPermission) : getLocalePhrase(language, Phrase.BulkDeleteButtonTooltip); return (
v.videoName).join(',')} // Forces a remount on selection change. inScope={toDelete} appState={appState} setVideoState={setVideoState} selectedRowCount={selectedRows.length} >
); }; const renderBulkTransferButton = (upload: boolean) => { const toTransfer = selectedViewpoints .filter((rv) => rv.cloud === !upload) .filter( (rv) => selectedViewpoints.filter((v) => v.videoName === rv.videoName) .length < 2, // If we have more 2 viewpoints with the same name then one must be disk and one cloud. ); const noPermission = upload && !write; const disabled = toTransfer.length < 1 || noPermission || !cloudStatus.authorized; let tooltip = upload ? getLocalePhrase(language, Phrase.BulkUploadButtonTooltip) : getLocalePhrase(language, Phrase.BulkDownloadButtonTooltip); if (noPermission) { tooltip = getLocalePhrase(language, Phrase.GuildNoPermission); } const icon = upload ? ( ) : ( ); return (
v.videoName).join(',')} // Forces a remount on selection change. inScope={toTransfer} appState={appState} upload={upload} >
); }; const renderSelectionLabel = () => { const { language } = appState; let text = getLocalePhrase(language, Phrase.Selection); if (selectedRows.length > 1) { text += ` (${selectedRows.length})`; } if (!config.cloudStorage || (write && del)) { // Cloud storage if off, or we have full permission. No need // to render the disk only switch. return ; } return ; }; return ( <>
{!isClips && ( )}
{renderSelectionLabel()}
{config.cloudUpload && renderBulkTransferButton(true)} {config.cloudStorage && renderBulkTransferButton(false)} {renderProtectButton()} {renderDeleteButton()}
); }; const openSetupInstructions = () => { window.electron.ipcRenderer.sendMessage('openURL', [ 'https://www.warcraftrecorder.com/setup', ]); }; const renderFirstTimeUserPrompt = () => { return (

{getLocalePhrase(language, Phrase.NoVideosSaved)}

{getLocalePhrase(language, Phrase.FirstTimeHere)}

); }; const renderFirstTimeClipPrompt = () => { return (

{getLocalePhrase(language, Phrase.NoClipsSaved)}

{getLocalePhrase(language, Phrase.ClipsDisplayedHere)}

); }; const renderVideoPage = () => { return (
{getVideoPlayer()} {getVideoSelection()}
); }; if (haveVideos) { return renderVideoPage(); } return (
{isClips && renderFirstTimeClipPrompt()} {!isClips && renderFirstTimeUserPrompt()}
); }; export default CategoryPage; ================================================ FILE: src/renderer/ChatOverlayControls.tsx ================================================ import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'; import { configSchema, ConfigurationSchema } from 'config/configSchema'; import { Info, Lock } from 'lucide-react'; import { AppState, SceneItem } from 'main/types'; import { getLocalePhrase } from 'localisation/translations'; import { setConfigValues } from './useSettings'; import { imageSelect } from './rendererutils'; import Label from './components/Label/Label'; import { Tooltip } from './components/Tooltip/Tooltip'; import Switch from './components/Switch/Switch'; import { Input } from './components/Input/Input'; import { Phrase } from 'localisation/phrases'; import Slider from './components/Slider/Slider'; const ipc = window.electron.ipcRenderer; interface IProps { appState: AppState; config: ConfigurationSchema; setConfig: Dispatch>; } const ChatOverlayControls = (props: IProps) => { const { appState, config, setConfig } = props; const { cloudStatus } = appState; const initialRender = useRef(true); const [cropMaxX, setCropMaxX] = useState(0); const [cropMaxY, setCropMaxY] = useState(0); const initCropSliders = async () => { if (!config.chatOverlayEnabled) return; const pos = await ipc.getSourcePosition(SceneItem.OVERLAY); // Don't let them scale to less than 80% of the dimension. // That seems reasonable to avoid weird issues. setCropMaxX(0.8 * Math.round(pos.width / 2)); setCropMaxY(0.8 * Math.round(pos.height / 2)); }; useEffect(() => { if (initialRender.current) return; setConfigValues({ chatOverlayEnabled: config.chatOverlayEnabled, chatOverlayOwnImage: config.chatOverlayOwnImage, chatOverlayOwnImagePath: config.chatOverlayOwnImagePath, }); ipc.reconfigureOverlay(); }, [ config.chatOverlayEnabled, config.chatOverlayOwnImage, config.chatOverlayOwnImagePath, ]); useEffect(() => { // If the user changes an overlay source, it will fire the source // callback, which we react to to ensure the sliders are sensible. ipc.on('initCropSliders', initCropSliders); return () => { ipc.removeAllListeners('initCropSliders'); }; }, [initCropSliders]); useEffect(() => { initCropSliders(); initialRender.current = false; }, []); const setOverlayEnabled = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, chatOverlayEnabled: checked, }; }); }; const setOwnImage = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, chatOverlayOwnImage: checked, }; }); }; const getChatOverlayEnabledSwitch = () => { return (
); }; const getChatOverlayOwnImageSwitch = () => { return (
); }; const setOverlayPath = async () => { const newPath = await imageSelect(); if (newPath === '') { return; } setConfig((prevState) => { return { ...prevState, chatOverlayOwnImagePath: newPath, }; }); }; const getOwnImagePathField = () => { return (
<>
); }; const setCropX = async (array: number[]) => { const value = array[0]; setConfig((prev) => ({ ...prev, chatOverlayCropX: value })); const p = await ipc.getSourcePosition(SceneItem.OVERLAY); p.cropLeft = value; p.cropRight = value; await ipc.setSourcePosition(SceneItem.OVERLAY, p); }; const setCropY = async (array: number[]) => { const value = array[0]; setConfig((prev) => ({ ...prev, chatOverlayCropY: value })); const p = await ipc.getSourcePosition(SceneItem.OVERLAY); p.cropTop = value; p.cropBottom = value; await ipc.setSourcePosition(SceneItem.OVERLAY, p); }; const getChatOverlayCropSliders = () => { const { chatOverlayCropX, chatOverlayCropY } = config; console.log({ chatOverlayCropX, chatOverlayCropY }); return (
); }; const showPathWarning = config.chatOverlayOwnImage && !config.chatOverlayOwnImagePath.endsWith('.png') && !config.chatOverlayOwnImagePath.endsWith('.gif'); return (
{getChatOverlayEnabledSwitch()} {config.chatOverlayEnabled && getChatOverlayOwnImageSwitch()} {config.chatOverlayEnabled && config.chatOverlayOwnImage && getOwnImagePathField()}
{showPathWarning && (

{getLocalePhrase(appState.language, Phrase.ErrorCustomImageFileType)}

)} {config.chatOverlayEnabled && getChatOverlayCropSliders()}
); }; export default ChatOverlayControls; ================================================ FILE: src/renderer/CloudSettings.tsx ================================================ import * as React from 'react'; import { configSchema, ConfigurationSchema } from 'config/configSchema'; import { AppState } from 'main/types'; import { Check, Cloud, Info, MonitorPlay, Pencil, RefreshCcw, Trash, X, } from 'lucide-react'; import { getLocalePhrase } from 'localisation/translations'; import { setConfigValue, setConfigValues } from './useSettings'; import Switch from './components/Switch/Switch'; import Label from './components/Label/Label'; import { Tooltip } from './components/Tooltip/Tooltip'; import { Input } from './components/Input/Input'; import Progress from './components/Progress/Progress'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from './components/Select/Select'; import Separator from './components/Separator/Separator'; import { Phrase } from 'localisation/phrases'; import { Dispatch, SetStateAction, useEffect, useRef } from 'react'; import { Button } from './components/Button/Button'; const ipc = window.electron.ipcRenderer; const raidDifficultyOptions = [ { name: 'LFR', phrase: Phrase.LFR }, { name: 'Normal', phrase: Phrase.Normal }, { name: 'Heroic', phrase: Phrase.Heroic }, { name: 'Mythic', phrase: Phrase.Mythic }, ]; let debounceTimer: NodeJS.Timeout | undefined; interface IProps { appState: AppState; config: ConfigurationSchema; setConfig: Dispatch>; } const CloudSettings = (props: IProps) => { const { appState, config, setConfig } = props; const { language } = appState; const initialRender = useRef(true); useEffect(() => { if (initialRender.current) return; if (debounceTimer) { clearTimeout(debounceTimer); } debounceTimer = setTimeout(() => { setConfigValues({ cloudStorage: config.cloudStorage, cloudAccountName: config.cloudAccountName, cloudAccountPassword: config.cloudAccountPassword, cloudGuildName: config.cloudGuildName, cloudUpload: config.cloudUpload, }); ipc.reconfigureCloud(); if (!config.cloudStorage) { // If the user has disabled cloud storage, also // disable custom image overlays and reconfigure it. setConfig((prev) => ({ ...prev, chatOverlayOwnImage: false })); setConfigValues({ chatOverlayOwnImage: false }); ipc.reconfigureOverlay(); } }, 2000); // Want to be long enough that it doesn't trigger mid-typing. }, [ config.cloudStorage, config.cloudAccountName, config.cloudAccountPassword, config.cloudGuildName, config.cloudUpload, ]); useEffect(() => { initialRender.current = false; }, []); const getSwitch = ( preference: keyof ConfigurationSchema, changeFn: (checked: boolean) => void, ) => ( ); const getSwitchForm = ( preference: keyof ConfigurationSchema, label: Phrase, ) => { const changeFn = (checked: boolean) => { setConfigValue(preference, checked); setConfig((prevState) => { return { ...prevState, [preference]: checked, }; }); }; return (
{getSwitch(preference, changeFn)}
); }; const setMinRaidThreshold = (value: string) => { setConfigValue('cloudUploadRaidMinDifficulty', value); setConfig((prevState) => { return { ...prevState, cloudUploadRaidMinDifficulty: value, }; }); }; const getMinRaidDifficultySelect = () => { if (!config.cloudUploadRaids) { return <>; } return (
); }; const setMinKeystoneLevel = (event: React.ChangeEvent) => { if (!event.target.value) { // Allow setting empty as midpoint. setConfig((prev) => ({ ...prev, cloudUploadDungeonMinLevel: -1 })); return; } const cloudUploadDungeonMinLevel = parseInt(event.target.value, 10); if (Number.isNaN(cloudUploadDungeonMinLevel)) { // Block invalid config. return; } setConfigValue('cloudUploadDungeonMinLevel', cloudUploadDungeonMinLevel); setConfig((prevState) => { return { ...prevState, cloudUploadDungeonMinLevel, }; }); }; const getMinKeystoneLevelField = () => { if (!config.cloudUploadDungeons) { return <>; } return (
= 0 ? config.cloudUploadDungeonMinLevel : '' } name="cloudUploadDungeonMinLevel" disabled={!config.cloudUploadDungeons} onChange={setMinKeystoneLevel} type="numeric" min={2} />
); }; const setCloudStorage = (checked: boolean) => { setConfig((prevState) => { const cloudStorage = checked; const newState = { ...prevState, cloudStorage, }; if (!cloudStorage) { // Can't have upload on if cloud storage is off so also set that // to false if we're disabling cloud storage. newState.cloudUpload = false; } return newState; }); }; const setCloudUpload = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, cloudUpload: checked, }; }); }; const setCloudUploadRetail = (checked: boolean) => { setConfigValue('cloudUploadRetail', checked); setConfig((prevState) => { return { ...prevState, cloudUploadRetail: checked, }; }); }; const setCloudUploadClassic = (checked: boolean) => { setConfigValue('cloudUploadClassic', checked); setConfig((prevState) => { return { ...prevState, cloudUploadClassic: checked, }; }); }; const getCloudSwitch = () => { return (
{getSwitch('cloudStorage', setCloudStorage)}
); }; const getCloudUploadSwitch = () => { if (!config.cloudStorage) { return <>; } return (
{getSwitch('cloudUpload', setCloudUpload)}
); }; const getRetailUploadSwitch = () => { if (!config.cloudUpload) { return <>; } return (
{getSwitch('cloudUploadRetail', setCloudUploadRetail)}
); }; const getClassicUploadSwitch = () => { if (!config.cloudUpload) { return <>; } return (
{getSwitch('cloudUploadClassic', setCloudUploadClassic)}
); }; const setCloudUploadRateLimit = (checked: boolean) => { setConfigValue('cloudUploadRateLimit', checked); setConfig((prevState) => { return { ...prevState, cloudUploadRateLimit: checked, }; }); }; const getCloudUploadRateLimitSwitch = () => { if (!config.cloudUpload) { return <>; } return (
{getSwitch('cloudUploadRateLimit', setCloudUploadRateLimit)}
); }; const setCloudAccountName = async ( event: React.ChangeEvent, ) => { setConfig((prevState) => { return { ...prevState, cloudAccountName: event.target.value.toLowerCase(), }; }); }; const getCloudAccountNameField = () => { if (!config.cloudStorage) { return <>; } return (
{config.cloudAccountName === '' && ( {getLocalePhrase(language, Phrase.CannotBeEmpty)} )}
); }; const setCloudPassword = async ( event: React.ChangeEvent, ) => { setConfig((prevState) => { return { ...prevState, cloudAccountPassword: event.target.value, }; }); }; const getCloudAccountPasswordField = () => { if (!config.cloudStorage) { return <>; } return (
{config.cloudAccountPassword === '' && ( {getLocalePhrase(language, Phrase.CannotBeEmpty)} )}
); }; const setCloudGuild = (value: string) => { setConfig((prevState) => { return { ...prevState, cloudGuildName: value, }; }); }; const refreshGuildList = () => { ipc.refreshCloudGuilds(); }; const getCloudGuildField = () => { const { available, authenticated } = appState.cloudStatus; if (!config.cloudStorage || !authenticated) { return <>; } return (
); }; const renderPermissionIcon = (enabled: boolean) => enabled ? ( ) : ( ); const getPermissionDetails = (phrase: Phrase, enabled: boolean) => { let icon = ( ); if (phrase === Phrase.PermissionWriteLabel) { icon = ( ); } else if (phrase === Phrase.PermissionDeleteLabel) { icon = ( ); } return (
{icon} {getLocalePhrase(language, phrase)} {renderPermissionIcon(enabled)}
); }; const getCloudPermissions = () => { const { read, write, del } = appState.cloudStatus; return (
{getPermissionDetails(Phrase.PermissionReadLabel, read)} {getPermissionDetails(Phrase.PermissionWriteLabel, write)} {getPermissionDetails(Phrase.PermissionDeleteLabel, del)}
); }; const getCloudUsageBar = () => { const { usage, limit } = appState.cloudStatus; const usageGB = usage / 1024 ** 3; const limitGB = limit / 1024 ** 3; const perc = Math.round((100 * usage) / limit); return (
{Math.round(usageGB)}GB / {Math.round(limitGB)}GB
); }; const getCloudUploadCategorySettings = () => { return ( <>
{getRetailUploadSwitch()} {getClassicUploadSwitch()}
{getSwitchForm('cloudUploadRaids', Phrase.UploadRaidsLabel)} {config.cloudUploadRaids && getSwitchForm( 'uploadCurrentRaidEncountersOnly', Phrase.UploadCurrentRaidsOnlyLabel, )} {getMinRaidDifficultySelect()}
{getSwitchForm('cloudUploadDungeons', Phrase.UploadMythicPlusLabel)} {getMinKeystoneLevelField()}
{getSwitchForm('cloudUpload2v2', Phrase.Upload2v2Label)} {getSwitchForm('cloudUpload3v3', Phrase.Upload3v3Label)} {getSwitchForm('cloudUpload5v5', Phrase.Upload5v5Label)} {getSwitchForm('cloudUploadSkirmish', Phrase.UploadSkirmishLabel)} {getSwitchForm( 'cloudUploadSoloShuffle', Phrase.UploadSoloShuffleLabel, )} {getSwitchForm( 'cloudUploadBattlegrounds', Phrase.UploadBattlgroundsLabel, )}
{getSwitchForm('manualRecordUpload', Phrase.ManualRecordUploadLabel)} {getSwitchForm('cloudUploadClips', Phrase.UploadClipsLabel)}
); }; const setUploadRateLimit = (event: React.ChangeEvent) => { const cloudUploadRateLimitMbps = parseInt(event.target.value, 10); if (Number.isNaN(cloudUploadRateLimitMbps)) { // Block invalid config. return; } setConfigValue('cloudUploadRateLimitMbps', cloudUploadRateLimitMbps); setConfig((prevState) => { return { ...prevState, cloudUploadRateLimitMbps, }; }); }; const getRateLimitField = () => { if (!config.cloudUpload || !config.cloudUploadRateLimit) { return <>; } return (
{config.cloudUploadRateLimitMbps < 1 && ( {getLocalePhrase(language, Phrase.OneOrGreater)} )}
); }; const getPossiblyHiddenFields = () => { return ( <>
{getCloudSwitch()}
{getCloudAccountNameField()} {getCloudAccountPasswordField()} {getCloudGuildField()}
); }; return (
{getPossiblyHiddenFields()} {config.cloudStorage && ( <> {getCloudUsageBar()} {getCloudPermissions()} )}
{getCloudUploadSwitch()} {getCloudUploadRateLimitSwitch()} {getRateLimitField()}
{config.cloudUpload && getCloudUploadCategorySettings()}
); }; export default CloudSettings; ================================================ FILE: src/renderer/ConfirmChatNamePrompt.tsx ================================================ import { ConfigurationSchema } from 'config/configSchema'; import { Dispatch, SetStateAction } from 'react'; import { setConfigValue } from './useSettings'; import { Button } from './components/Button/Button'; import { getLocalePhrase, Language } from 'localisation/translations'; import { Phrase } from 'localisation/phrases'; interface IProps { cloudAccountName: string; setConfig: Dispatch>; language: Language; } /** * This component prompts the user to accept the chat name they are logged in * as before sending any messages that might self-dox them. */ const ConfirmChatNamePrompt = (props: IProps) => { const { cloudAccountName, setConfig, language } = props; const onConfirmName = () => { setConfigValue('chatUserNameAgreed', cloudAccountName); setConfig((prev) => ({ ...prev, chatUserNameAgreed: props.cloudAccountName, })); }; return (

{getLocalePhrase(language, Phrase.ChatUserText1)}

{cloudAccountName.includes('@') && (

{getLocalePhrase(language, Phrase.ChatUserText2)} warcraftrecorder.com {getLocalePhrase(language, Phrase.ChatUserText3)}

)}
); }; export default ConfirmChatNamePrompt; ================================================ FILE: src/renderer/CrashStatus.tsx ================================================ import { ErrorReport, Crashes } from 'main/types'; import BugReportIcon from '@mui/icons-material/BugReport'; import { Tooltip, IconButton, Popover, Box, Typography } from '@mui/material'; import { useState } from 'react'; interface IProps { crashes: Crashes; } export default function CrashStatus(props: IProps) { const { crashes } = props; const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); const handleClick = (event: React.MouseEvent) => { setAnchorEl(anchorEl ? null : event.currentTarget); }; const handleClose = () => { setAnchorEl(null); }; if (crashes.length < 1) { return <>; } const getCrashHeading = () => { return ( An OBS crash has occured and has been recovered from. This should not happen in normal operation. You may wish to seek help by sharing your WCR and OBS logs in discord. ); }; const getCrashSummary = () => { return ( <> {crashes.map((ErrorReport: ErrorReport) => { const dateString = ErrorReport.date.toLocaleString(); const { reason } = ErrorReport; return ( {dateString}: {reason} ); })} ); }; return ( <> {getCrashHeading()} {getCrashSummary()} ); } ================================================ FILE: src/renderer/DateRangePicker.tsx ================================================ import { getLocalePhrase } from 'localisation/translations'; import { Phrase, Language } from 'localisation/phrases'; import { AppState } from 'main/types'; import { Dispatch, SetStateAction, useCallback } from 'react'; import Datepicker, { DateValueType } from 'react-tailwindcss-datepicker'; interface IProps { appState: AppState; setAppState: Dispatch>; } const DateRangePicker = (props: IProps) => { const { appState, setAppState } = props; const { dateRangeFilter } = appState; const format = appState.language === Language.KOREAN ? 'YY/MM/DD' : 'DD/MM/YY'; let i18n = 'en'; if (appState.language === Language.KOREAN) { i18n = 'ko'; } else if (appState.language === Language.GERMAN) { i18n = 'de'; } else if (appState.language === Language.CHINESE_SIMPLIFIED) { i18n = 'zh-CN'; } const configs = { shortcuts: { today: getLocalePhrase(appState.language, Phrase.Today), yesterday: getLocalePhrase(appState.language, Phrase.Yesterday), past: (period: number) => { if (period === 7) return getLocalePhrase(appState.language, Phrase.Last7Days); if (period === 30) return getLocalePhrase(appState.language, Phrase.Last30Days); return `Last ${period} days`; // Don't expect to see this. }, currentMonth: getLocalePhrase(appState.language, Phrase.ThisMonth), pastMonth: getLocalePhrase(appState.language, Phrase.LastMonth), }, footer: { cancel: getLocalePhrase(appState.language, Phrase.Cancel), apply: getLocalePhrase(appState.language, Phrase.Apply), }, }; const onChange = useCallback( (v: DateValueType) => { // This looks a bit verbose, but it seems the react library // used here will provide the same date object if the range // is a single day, as well as setting the time to the current // time. So make sure that we have separate date objects, set // to midnight and a minute to midnight to cover the full day. const drf: DateValueType = { startDate: null, endDate: null, }; if (v && v.startDate) { drf.startDate = new Date(v.startDate); drf.startDate.setHours(0, 0, 0, 0); } if (v && v.endDate) { drf.endDate = new Date(v.endDate); drf.endDate.setHours(23, 59, 59, 999); } setAppState((prev) => ({ ...prev, dateRangeFilter: drf, })); }, [setAppState], ); return ( ); }; export default DateRangePicker; ================================================ FILE: src/renderer/DeleteDialog.tsx ================================================ import { AppState, RendererVideo } from 'main/types'; import { getLocalePhrase } from 'localisation/translations'; import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from './components/Dialog/Dialog'; import { Button } from './components/Button/Button'; import { Dispatch, HTMLProps, SetStateAction, useEffect, useMemo, useState, } from 'react'; import { ColumnDef, flexRender, getCoreRowModel, useReactTable, } from '@tanstack/react-table'; import CloudIcon from '@mui/icons-material/Cloud'; import SaveIcon from '@mui/icons-material/Save'; import { Phrase } from 'localisation/phrases'; type DeleteDialogProps = { children: React.ReactNode; inScope: RendererVideo[]; appState: AppState; setVideoState: Dispatch>; selectedRowCount: number; }; const Checkbox = (props: HTMLProps) => { return ( ); }; const DeleteDialog = ({ children, inScope, appState, setVideoState, selectedRowCount, }: DeleteDialogProps) => { const { language } = appState; // Alphabetically sort the videos by their name. const [data] = useState(() => [ ...inScope.sort((a, b) => { return a.videoName.localeCompare(b.videoName); }), ]); const [rowSelection, setRowSelection] = useState({}); // Initialize all the rows to be selected by default. useEffect(() => { const allRowIds = Object.fromEntries(data.map((_, i) => [i, true])); setRowSelection(allRowIds); }, [data]); const columns = useMemo[]>( () => [ { id: 'Select', cell: ({ row }) => (
), }, { id: 'Storage', accessorKey: 'cloud', cell: (info) => (
{info.getValue() ? ( ) : ( )}
), }, { id: 'Name', accessorKey: 'videoName', // Strip date prefix from the video name. cell: (info) => (
{(info.getValue() as string).slice(22)}
), }, ], [], ); const table = useReactTable({ data, columns, state: { rowSelection }, enableRowSelection: true, onRowSelectionChange: setRowSelection, getCoreRowModel: getCoreRowModel(), }); const renderTable = () => { return ( {table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell, index) => { let className = 'px-[4px] '; if (index === 2) { className += 'text-left w-full'; // Take remaining space } return ( ); })} ))}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
); }; const getWarningMessage = () => { const warning = `${getLocalePhrase( language, Phrase.ThisWillPermanentlyDelete, )} ${table.getSelectedRowModel().rows.length} ${getLocalePhrase( language, Phrase.Recordings, )} ${getLocalePhrase( language, Phrase.From, )} ${Math.max(selectedRowCount, 1)} ${getLocalePhrase(language, Phrase.Rows)}.`; return
{warning}
; }; const doDelete = () => { const toDelete = table .getSelectedRowModel() .rows.map((row) => row.original); const toDeleteDisk = toDelete.filter((rv) => !rv.cloud); const toDeleteCloud = toDelete.filter((rv) => rv.cloud); window.electron.ipcRenderer.sendMessage('deleteVideosDisk', toDeleteDisk); window.electron.ipcRenderer.sendMessage('deleteVideosCloud', toDeleteCloud); setVideoState((prev) => { return [...prev].filter((rv) => { return !toDelete.find( // A video is uniquely identified by its name and storage type. (v) => v.videoName === rv.videoName && v.cloud === rv.cloud, ); }); }); }; return ( {children} {getLocalePhrase(appState.language, Phrase.AreYouSure)} {selectedRowCount < 2 && renderTable()} {getWarningMessage()} ); }; export default DeleteDialog; ================================================ FILE: src/renderer/DiscordButton.tsx ================================================ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faDiscord } from '@fortawesome/free-brands-svg-icons'; import { AppState } from 'main/types'; import { getLocalePhrase } from 'localisation/translations'; import { Button } from './components/Button/Button'; import { Tooltip } from './components/Tooltip/Tooltip'; import { Phrase } from 'localisation/phrases'; const ipc = window.electron.ipcRenderer; interface IProps { appState: AppState; } export default function DiscordButton(props: IProps) { const { appState } = props; const openDiscordURL = () => { ipc.sendMessage('openURL', ['https://discord.gg/NPha7KdjVk']); }; return ( ); } ================================================ FILE: src/renderer/FlavourSettings.tsx ================================================ import { ConfigurationSchema, configSchema } from 'config/configSchema'; import React, { Dispatch, SetStateAction } from 'react'; import { AdvancedLoggingStatus, AppState, RecStatus } from 'main/types'; import { Info } from 'lucide-react'; import { getLocalePhrase } from 'localisation/translations'; import { setConfigValues } from './useSettings'; import { pathSelect } from './rendererutils'; import Switch from './components/Switch/Switch'; import Label from './components/Label/Label'; import { Input } from './components/Input/Input'; import { Tooltip } from './components/Tooltip/Tooltip'; import TextBanner from './components/TextBanner/TextBanner'; import { Phrase } from 'localisation/phrases'; interface IProps { recorderStatus: RecStatus; config: ConfigurationSchema; setConfig: Dispatch>; appState: AppState; advancedLoggingStatus: AdvancedLoggingStatus; } const ipc = window.electron.ipcRenderer; const FlavourSettings: React.FC = (props: IProps) => { const { recorderStatus, config, setConfig, appState, advancedLoggingStatus } = props; const initialRender = React.useRef(true); React.useEffect(() => { // Don't fire on the initial render. if (initialRender.current) { initialRender.current = false; return; } setConfigValues({ recordRetail: config.recordRetail, retailLogPath: config.retailLogPath, recordRetailPtr: config.recordRetailPtr, retailPtrLogPath: config.retailPtrLogPath, recordClassic: config.recordClassic, classicLogPath: config.classicLogPath, recordClassicPtr: config.recordClassicPtr, classicPtrLogPath: config.classicPtrLogPath, recordEra: config.recordEra, eraLogPath: config.eraLogPath, validateLogPaths: config.validateLogPaths, }); ipc.reconfigureBase(); }, [ config.recordRetail, config.recordClassic, config.recordClassicPtr, config.retailLogPath, config.classicLogPath, config.classicPtrLogPath, config.recordEra, config.eraLogPath, config.recordRetailPtr, config.retailPtrLogPath, config.validateLogPaths, ]); const isComponentDisabled = () => { const isRecording = recorderStatus === RecStatus.Recording; const isOverrunning = recorderStatus === RecStatus.Overrunning; return isRecording || isOverrunning; }; const getDisabledText = () => { if (!isComponentDisabled()) { return <>; } return ( {getLocalePhrase(appState.language, Phrase.SettingsDisabledText)} ); }; const getSwitch = ( preference: keyof ConfigurationSchema, changeFn: (checked: boolean) => void, ) => ( ); const setRecordRetail = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordRetail: checked, }; }); }; const setRecordClassic = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordClassic: checked, }; }); }; const setRetailLogPath = async () => { if (isComponentDisabled()) { return; } const newPath = await pathSelect(); if (newPath === '') { return; } setConfig((prevState) => { return { ...prevState, retailLogPath: newPath, }; }); }; const getRetailSettings = () => { if (isComponentDisabled()) { return <>; } return (
{getSwitch('recordRetail', setRecordRetail)}
{config.recordRetail && (
{config.retailLogPath === '' && ( {getLocalePhrase( appState.language, Phrase.InvalidRetailLogPathText, )} )} {!advancedLoggingStatus.retail && ( {getLocalePhrase( appState.language, Phrase.AdvancedCombatLoggingDisabledWarning, )} )}
)}
); }; const setClassicLogPath = async () => { if (isComponentDisabled()) { return; } const newPath = await pathSelect(); if (newPath === '') { return; } setConfig((prevState) => { return { ...prevState, classicLogPath: newPath, }; }); }; const getClassicSettings = () => { if (isComponentDisabled()) { return <>; } return (
{getSwitch('recordClassic', setRecordClassic)}
{config.recordClassic && (
{config.classicLogPath === '' && ( {getLocalePhrase( appState.language, Phrase.InvalidClassicLogPathText, )} )} {!advancedLoggingStatus.classic && ( {getLocalePhrase( appState.language, Phrase.AdvancedCombatLoggingDisabledWarning, )} )}
)}
); }; const setRecordEra = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordEra: checked, }; }); }; const setEraLogPath = async () => { if (isComponentDisabled()) { return; } const newPath = await pathSelect(); if (newPath === '') { return; } setConfig((prevState) => { return { ...prevState, eraLogPath: newPath, }; }); }; const getEraSettings = () => { if (isComponentDisabled()) { return <>; } return (
{getSwitch('recordEra', setRecordEra)}
{config.recordEra && (
{config.eraLogPath === '' && ( {getLocalePhrase( appState.language, Phrase.InvalidClassicEraLogPathText, )} )} {!advancedLoggingStatus.era && ( {getLocalePhrase( appState.language, Phrase.AdvancedCombatLoggingDisabledWarning, )} )}
)}
); }; const setRecordRetailPtr = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordRetailPtr: checked, }; }); }; const setRetailPtrLogPath = async () => { if (isComponentDisabled()) { return; } const newPath = await pathSelect(); if (newPath === '') { return; } setConfig((prevState) => { return { ...prevState, retailPtrLogPath: newPath, }; }); }; const getRetailPtrSettings = () => { if (isComponentDisabled()) { return <>; } return (
{getSwitch('recordRetailPtr', setRecordRetailPtr)}
{config.recordRetailPtr && (
{config.retailPtrLogPath === '' && ( {getLocalePhrase( appState.language, Phrase.InvalidRetailPtrLogPathText, )} )} {!advancedLoggingStatus.retailPtr && ( {getLocalePhrase( appState.language, Phrase.AdvancedCombatLoggingDisabledWarning, )} )}
)}
); //classic ptr }; const setRecordClassicPtr = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordClassicPtr: checked, }; }); }; const setClassicPtrLogPath = async () => { if (isComponentDisabled()) { return; } const newPath = await pathSelect(); if (newPath === '') { return; } setConfig((prevState) => { return { ...prevState, classicPtrLogPath: newPath, }; }); }; const getClassicPtrSettings = () => { if (isComponentDisabled()) { return <>; } return (
{getSwitch('recordClassicPtr', setRecordClassicPtr)}
{config.recordClassicPtr && (
{config.classicPtrLogPath === '' && ( {getLocalePhrase( appState.language, Phrase.InvalidClassicPtrLogPathText, )} )} {!advancedLoggingStatus.classicPtr && ( {getLocalePhrase( appState.language, Phrase.AdvancedCombatLoggingDisabledWarning, )} )}
)}
); }; const setValidateLogPaths = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, validateLogPaths: checked, }; }); }; const getValidateLogPathSwitch = () => { if (isComponentDisabled()) { return <>; } return (
{getSwitch('validateLogPaths', setValidateLogPaths)}
); }; return (
{getDisabledText()} {getRetailSettings()} {getClassicSettings()} {getEraSettings()} {getRetailPtrSettings()} {getClassicPtrSettings()} {getValidateLogPathSwitch()}
); }; export default FlavourSettings; ================================================ FILE: src/renderer/GeneralSettings.tsx ================================================ import * as React from 'react'; import { configSchema } from 'config/configSchema'; import { AppState, RecStatus } from 'main/types'; import { useEffect, useRef } from 'react'; import { HardDrive, Info } from 'lucide-react'; import { getLocalePhrase } from 'localisation/translations'; import { setConfigValues, useSettings } from './useSettings'; import { pathSelect } from './rendererutils'; import { Input } from './components/Input/Input'; import Label from './components/Label/Label'; import Switch from './components/Switch/Switch'; import { Tooltip } from './components/Tooltip/Tooltip'; import Progress from './components/Progress/Progress'; import TextBanner from './components/TextBanner/TextBanner'; import { Phrase } from 'localisation/phrases'; interface IProps { recorderStatus: RecStatus; appState: AppState; } const ipc = window.electron.ipcRenderer; let debounceTimeout: NodeJS.Timeout | null; const GeneralSettings: React.FC = (props: IProps) => { const { recorderStatus, appState } = props; const { language } = appState; const [config, setConfig] = useSettings(); const initialRenderVideoConfig = useRef(true); useEffect(() => { if (initialRenderVideoConfig.current) { // Drop out if initial render, we don't care about settings // changes until the user has had a chance to make some. initialRenderVideoConfig.current = false; return; } const toSet: Record = { storagePath: config.storagePath, bufferStoragePath: config.bufferStoragePath, separateBufferPath: config.separateBufferPath, }; if (config.maxStorage >= 0) { toSet.maxStorage = config.maxStorage; } setConfigValues(toSet); if (debounceTimeout) { clearTimeout(debounceTimeout); } debounceTimeout = setTimeout(() => { ipc.reconfigureBase(); }, 500); }, [ config.separateBufferPath, config.storagePath, config.bufferStoragePath, config.maxStorage, ]); const setSeparateBufferPath = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, bufferStoragePath: '', separateBufferPath: checked, }; }); }; const isComponentDisabled = () => { const isRecording = recorderStatus === RecStatus.Recording; const isOverrunning = recorderStatus === RecStatus.Overrunning; return isRecording || isOverrunning; }; const getDisabledText = () => { if (!isComponentDisabled()) { return <>; } return ( {getLocalePhrase(language, Phrase.SettingsDisabledText)} ); }; const setStoragePath = async () => { const newPath = await pathSelect(); if (newPath === '') { return; } setConfig((prevState) => { return { ...prevState, storagePath: newPath, }; }); }; const getStoragePathField = () => { if (isComponentDisabled()) { return <>; } return (
{config.storagePath === '' && ( {getLocalePhrase(language, Phrase.MustNotBeEmpty)} )}
); }; const setBufferPath = async () => { if (isComponentDisabled()) { return; } const newPath = await pathSelect(); if (newPath === '') { return; } setConfig((prevState) => { return { ...prevState, bufferStoragePath: newPath, }; }); }; const getBufferSwitch = () => { if (isComponentDisabled()) { return <>; } return (
); }; const getBufferPathField = () => { if (isComponentDisabled()) { return <>; } if (!config.separateBufferPath) { return <>; } return (
); }; const setMaxStorage = (event: React.ChangeEvent) => { if (!event.target.value) { // Allow setting empty as midpoint. setConfig((prev) => ({ ...prev, maxStorage: -1 })); return; } const maxStorage = parseInt(event.target.value, 10); if (Number.isNaN(maxStorage) || maxStorage < 0) { // Block invalid config. return; } setConfig((prevState) => { return { ...prevState, maxStorage, }; }); }; const getMaxStorageField = () => { if (isComponentDisabled()) { return <>; } return (
= 0 ? config.maxStorage : ''} onChange={setMaxStorage} required type="numeric" disabled={isComponentDisabled()} />
); }; const getDiskUsageBar = () => { const usage = Math.round(appState.diskStatus.usage / 1024 ** 3); const max = Math.round(appState.diskStatus.limit / 1024 ** 3); let perc = max === 0 ? 100 : (100 * usage) / max; if (perc > 100) perc = 100; const text = max === 0 ? `${usage}GB / ∞` : `${usage}GB / ${max}GB`; return (
{text}
); }; return (
{getDisabledText()} {getStoragePathField()}
{getMaxStorageField()} {getDiskUsageBar()}
{getBufferSwitch()} {getBufferPathField()}
); }; export default GeneralSettings; ================================================ FILE: src/renderer/KillVideoDialog.tsx ================================================ import { KillVideoSegment, RendererVideo } from 'main/types'; import { getLocalePhrase } from 'localisation/translations'; import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from './components/Dialog/Dialog'; import { Button } from './components/Button/Button'; import { Language, Phrase } from 'localisation/phrases'; import { ReactNode, useState } from 'react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from './components/Select/Select'; import { Tooltip } from './components/Tooltip/Tooltip'; import Label from './components/Label/Label'; import { Info } from 'lucide-react'; import { obsResolutions } from 'main/constants'; import KillVideoSourceTimeline from './KillVideoSourceTimeline'; import Switch from './components/Switch/Switch'; const ipc = window.electron.ipcRenderer; interface IProps { sources: RendererVideo[]; language: Language; children: ReactNode; } const KillVideoDialog = (props: IProps) => { const [open, setOpen] = useState(false); const { children, language, sources } = props; // Our select component only accepts strings annoyingly. const [fps, setFps] = useState('60'); const [singleAudio, setSingleAudio] = useState(false); const [audioTrackPlayer, setAudioTrackPlayer] = useState( sources[0]?.player?._name || '', ); const [resolution, setResolution] = useState('1920x1080'); const [segments, setSegments] = useState(() => { // Calculate the length of the video as the shortest source. That // avoids weird conditions due to misclipped videos. Not perfect // but should be good enough for now. let videoDuration = Number.MAX_SAFE_INTEGER; sources.forEach((rv) => { videoDuration = Math.min(videoDuration, rv.duration); }); const segmentDuration = videoDuration / sources.length; return sources.map((rv, idx) => ({ video: rv, start: idx * segmentDuration, stop: (idx + 1) * segmentDuration, })); }); const createKillVideo = () => { const { width, height } = obsResolutions[resolution]; let audioTrackIndex = -1; if (singleAudio) { // If not found, findIndex returns -1 so if something goes wrong will // just fallback to splicing all the audio tracks. audioTrackIndex = segments.findIndex( (s) => s.video.player?._name === audioTrackPlayer, ); } ipc.createKillVideo( width, height, parseInt(fps, 10), segments, audioTrackIndex, ); }; const getFpsSelect = () => { // Our select component only accepts strings annoyingly. const options = ['10', '20', '30', '60']; return (
); }; const getAudioSwitch = () => { return (
); }; const getAudioTrackSelect = () => { const options = segments.map( (s) => s.video.player?._name || s.video.videoName, ); return (
); }; const getResolutionSelect = () => { const options = Object.keys(obsResolutions); return (
); }; const resetSettings = (event: React.MouseEvent) => { event.stopPropagation(); // Calculate the length of the video as the shortest source. That // avoids weird conditions due to misclipped videos. Not perfect // but should be good enough for now. let videoDuration = Number.MAX_SAFE_INTEGER; sources.forEach((rv) => { videoDuration = Math.min(videoDuration, rv.duration); }); const segmentDuration = videoDuration / sources.length; const resetSegments = sources.map((rv, idx) => ({ video: rv, start: idx * segmentDuration, stop: (idx + 1) * segmentDuration, })); setSegments(resetSegments); setFps('60'); setResolution('1920x1080'); }; if (!open) { // Lazy render the dialog for performance. return ( {children} ); } return ( {children} {getLocalePhrase(language, Phrase.KillVideoCreatorTitle)}
{getLocalePhrase(language, Phrase.KillVideoDescription)}
{getFpsSelect()} {getResolutionSelect()} {getAudioSwitch()} {singleAudio && getAudioTrackSelect()}
); }; export default KillVideoDialog; ================================================ FILE: src/renderer/KillVideoProgress.tsx ================================================ import { KillVideoStatus } from 'main/types'; import Progress from './components/Progress/Progress'; import { cn } from './components/utils'; import { useEffect, useState } from 'react'; import { getLocalePhrase, Language } from 'localisation/translations'; import { Phrase } from 'localisation/phrases'; const ipc = window.electron.ipcRenderer; interface IProps { language: Language; } const KillVideoProgress = (props: IProps) => { const { language } = props; const [killVideoStatus, setKillVideoStatus] = useState({ perc: 0, queued: 0, }); const updateKillVideoStatus = (status: unknown) => { setKillVideoStatus(status as KillVideoStatus); }; useEffect(() => { ipc.on('updateKillVideoStatus', updateKillVideoStatus); return () => { ipc.removeAllListeners('updateKillVideoStatus'); }; }, []); if (killVideoStatus.queued < 1) { return <>; } const descr = killVideoStatus.queued > 1 ? `${getLocalePhrase(language, Phrase.KillVideoCreating)} (+${killVideoStatus.queued - 1})` : getLocalePhrase(language, Phrase.KillVideoCreating); const progress = killVideoStatus.perc < 1 ? getLocalePhrase(language, Phrase.Preparing) + '...' : `${killVideoStatus.perc}%`; return (
{descr} {progress}
); }; export default KillVideoProgress; ================================================ FILE: src/renderer/KillVideoSourceTimeline.tsx ================================================ import { KillVideoSegment } from 'main/types'; import { getPlayerClass, getPlayerName, getPlayerSpecID, getWoWClassColor, secToMmSs, } from './rendererutils'; import React, { Dispatch, ReactNode, SetStateAction, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { specImages } from './images'; import { Trash2, Volume2, VolumeX } from 'lucide-react'; import { getLocalePhrase } from 'localisation/translations'; import { Language, Phrase } from 'localisation/phrases'; interface SourceTimelineProps { segments: KillVideoSegment[]; setSegments: Dispatch>; children?: ReactNode; language: Language; } /** * A draggable + resizable timeline for arranging video sources. * * The timeline represents the total fight duration (the shortest source). * Each segment is a slice of that fixed total — e.g. a 3 min fight with * 3 viewpoints starts as 1 min each. Dragging edges redistributes time * between neighbours while keeping the total constant. * * This was written in part by Copilot. Take it with a grain of salt. * * Features: * - Rectangles colored by WoW class, sized proportionally to duration * - Drag & drop to reorder viewpoints * - Drag left/right edges to resize (min 10s per segment) */ const KillVideoSourceTimeline = (props: SourceTimelineProps) => { const { segments, setSegments, language } = props; const videoDuration = segments.reduce( (sum, seg) => sum + (seg.stop - seg.start), 0, ); const [dragIdx, setDragIdx] = useState(null); const [overIdx, setOverIdx] = useState(null); const [overBin, setOverBin] = useState(false); const [playheadTime, setPlayheadTime] = useState(0); const [playing, setPlaying] = useState(false); const [muted, setMuted] = useState(true); const seekingRef = useRef(false); const videoPreviewRef = useRef(null); const timelineWrapperRef = useRef(null); const canRemove = segments.length > 2; const activeSegment = useMemo(() => { for (const seg of segments) { if (playheadTime >= seg.start && playheadTime < seg.stop) return seg; } return segments[segments.length - 1]; }, [segments, playheadTime]); const videoSrc = useMemo(() => { const src = activeSegment.video.videoSource; return src.startsWith('https://') ? src : `vod://wcr/${src}`; }, [activeSegment]); // Seek the preview video when the playhead is moved manually. useEffect(() => { const el = videoPreviewRef.current; if (el && el.readyState >= 2 && seekingRef.current) { el.currentTime = playheadTime; seekingRef.current = false; } }, [playheadTime]); // Seek the preview video when the source changes after load. const handleVideoLoaded = useCallback(() => { const el = videoPreviewRef.current; if (el) { el.currentTime = playheadTime; if (playing) el.play(); } }, [playheadTime, playing]); // Sync playhead from video playback. const handleTimeUpdate = useCallback(() => { const el = videoPreviewRef.current; if (el && !seekingRef.current) { setPlayheadTime(el.currentTime); } }, []); const togglePlayPause = useCallback(() => { const el = videoPreviewRef.current; if (!el) return; if (el.paused) { el.play(); setPlaying(true); } else { el.pause(); setPlaying(false); } }, []); // True when cursor is over a valid insertion gap (not a no-op position). const showDropIndicator = dragIdx !== null && overIdx !== null && overIdx !== dragIdx && overIdx !== dragIdx + 1; const resizeRef = useRef<{ segmentIdx: number; edge: 'left' | 'right'; startX: number; startDuration: number; neighbourDuration: number; totalWidth: number; totalDuration: number; } | null>(null); const handleDragStart = (idx: number) => { setDragIdx(idx); }; const handleDragOver = (e: React.DragEvent, idx: number) => { e.preventDefault(); const rect = e.currentTarget.getBoundingClientRect(); const midX = rect.left + rect.width / 2; setOverIdx(e.clientX < midX ? idx : idx + 1); }; const normalizeSegments = (segs: KillVideoSegment[]) => { let cursor = 0; return segs.map((seg) => { const duration = seg.stop - seg.start; const next = { ...seg, start: cursor, stop: cursor + duration }; cursor += duration; return next; }); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); if ( dragIdx === null || overIdx === null || overIdx === dragIdx || overIdx === dragIdx + 1 ) { setDragIdx(null); setOverIdx(null); return; } const next = [...segments]; const [moved] = next.splice(dragIdx, 1); const insertIdx = overIdx > dragIdx ? overIdx - 1 : overIdx; next.splice(insertIdx, 0, moved); setSegments(normalizeSegments(next)); setDragIdx(null); setOverIdx(null); }; const handleDragEnd = () => { setDragIdx(null); setOverIdx(null); setOverBin(false); }; const handleBinDrop = (e: React.DragEvent) => { e.preventDefault(); if (dragIdx !== null && canRemove) { removeSegment(dragIdx); } setDragIdx(null); setOverIdx(null); setOverBin(false); }; const removeSegment = (idx: number) => { if (segments.length <= 2) return; const removedDuration = segments[idx].stop - segments[idx].start; const remaining = segments.filter((_, i) => i !== idx); const extraEach = removedDuration / remaining.length; let cursor = 0; const updated = remaining.map((seg) => { const newDuration = seg.stop - seg.start + extraEach; const newSeg = { ...seg, start: cursor, stop: cursor + newDuration }; cursor += newDuration; return newSeg; }); setSegments(updated); }; const handleEdgeMouseDown = ( e: React.MouseEvent, segmentIdx: number, edge: 'left' | 'right', ) => { e.preventDefault(); e.stopPropagation(); const neighbourIdx = edge === 'left' ? segmentIdx - 1 : segmentIdx + 1; if (neighbourIdx < 0 || neighbourIdx >= segments.length) return; const container = (e.currentTarget as HTMLElement).closest( '[data-timeline-container]', ); if (!container) return; resizeRef.current = { segmentIdx, edge, startX: e.clientX, startDuration: segments[segmentIdx].stop - segments[segmentIdx].start, neighbourDuration: segments[neighbourIdx].stop - segments[neighbourIdx].start, totalWidth: container.getBoundingClientRect().width, totalDuration: videoDuration, }; const handleMouseMove = (me: MouseEvent) => { if (!resizeRef.current) return; const { segmentIdx: sIdx, edge: sEdge, startX, startDuration, neighbourDuration, totalWidth, totalDuration: td, } = resizeRef.current; const dx = me.clientX - startX; const durationDelta = (dx / totalWidth) * td; let newDuration: number; let newNeighbourDuration: number; if (sEdge === 'right') { // Dragging right edge: grow self, shrink right neighbour newDuration = startDuration + durationDelta; newNeighbourDuration = neighbourDuration - durationDelta; } else { // Dragging left edge: shrink self, grow left neighbour newDuration = startDuration - durationDelta; newNeighbourDuration = neighbourDuration + durationDelta; } const minSegmentDuration = 15; const combined = startDuration + neighbourDuration; newDuration = Math.max( minSegmentDuration, Math.min(newDuration, combined - minSegmentDuration), ); newNeighbourDuration = combined - newDuration; setSegments((prev) => { const next = [...prev]; if (sEdge === 'right') { const nIdx = sIdx + 1; const boundary = prev[sIdx].start + newDuration; next[sIdx] = { ...next[sIdx], stop: boundary }; next[nIdx] = { ...next[nIdx], start: boundary }; } else { const nIdx = sIdx - 1; const boundary = prev[nIdx].start + newNeighbourDuration; next[nIdx] = { ...next[nIdx], stop: boundary }; next[sIdx] = { ...next[sIdx], start: boundary }; } return next; }); }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); resizeRef.current = null; }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; const updatePlayheadFromMouse = useCallback( (clientX: number) => { const wrapper = timelineWrapperRef.current; if (!wrapper) return; const rect = wrapper.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); setPlayheadTime(pct * videoDuration); }, [videoDuration], ); const handlePlayheadMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); seekingRef.current = true; updatePlayheadFromMouse(e.clientX); const handleMove = (me: MouseEvent) => { seekingRef.current = true; updatePlayheadFromMouse(me.clientX); }; const handleUp = () => { document.removeEventListener('mousemove', handleMove); document.removeEventListener('mouseup', handleUp); }; document.addEventListener('mousemove', handleMove); document.addEventListener('mouseup', handleUp); }, [updatePlayheadFromMouse], ); // Build tick marks for the timeline ruler. const { majorTicks, minorTicks } = useMemo(() => { const major: number[] = []; const minor: number[] = []; // Pick a sensible major interval based on fight length. let majorInterval = 30; if (videoDuration > 600) majorInterval = 120; else if (videoDuration > 300) majorInterval = 60; else if (videoDuration > 120) majorInterval = 30; else majorInterval = 15; const minorInterval = majorInterval / 2; for (let t = minorInterval; t < videoDuration; t += minorInterval) { // Check if this is a major tick (within a small epsilon). if (Math.abs(t % majorInterval) < 0.01) { major.push(t); } else { minor.push(t); } } return { majorTicks: major, minorTicks: minor }; }, [videoDuration]); const playheadPct = videoDuration > 0 ? (playheadTime / videoDuration) * 100 : 0; return (
{/* Video preview + settings side by side */}
{props.children && (
{props.children}
)}
{/* Timeline + playhead wrapper */}
{showDropIndicator && overIdx === 0 && (
)} {segments.map((seg, idx) => { const widthPercent = ((seg.stop - seg.start) / videoDuration) * 100; const isDragging = dragIdx === idx; const playerClass = getPlayerClass(seg.video); const bgColor = playerClass === 'UNKNOWN' ? 'gray' : getWoWClassColor(playerClass); return (
handleDragStart(idx)} onDragOver={(e) => handleDragOver(e, idx)} onDrop={handleDrop} onDragEnd={handleDragEnd} > spec
{Array.from({ length: 9 }).map((_, i) => (
))}
{getPlayerName(seg.video) || seg.video.videoName} {secToMmSs(seg.stop - seg.start)}
{idx < segments.length - 1 && (
handleEdgeMouseDown(e, idx, 'right')} onDragOver={(e) => { e.preventDefault(); setOverIdx(idx + 1); }} onDrop={handleDrop} > {/* Vertical grip dots */} {Array.from({ length: 5 }).map((_, i) => (
))}
)} ); })} {showDropIndicator && overIdx === segments.length && (
)}
{/* Playhead */}
{secToMmSs(playheadTime)}
{/* Clickable ruler area to jump playhead */}
0:00
{minorTicks.map((t) => { const pct = (t / videoDuration) * 100; return (
); })} {majorTicks.map((t) => { const pct = (t / videoDuration) * 100; return (
{secToMmSs(t)}
); })}
{secToMmSs(videoDuration)}
{canRemove && (
{ e.preventDefault(); setOverBin(true); }} onDragLeave={() => setOverBin(false)} onDrop={handleBinDrop} > {getLocalePhrase(language, Phrase.KillVideoRemove)}
)}
); }; export default KillVideoSourceTimeline; ================================================ FILE: src/renderer/Layout.tsx ================================================ import * as React from 'react'; import { AdvancedLoggingStatus, Pages, RecStatus, AppState, RendererVideo, } from 'main/types'; import { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { ConfigurationSchema } from 'config/configSchema'; import SceneEditor from './SceneEditor'; import SettingsPage from './SettingsPage'; import CategoryPage from './CategoryPage'; interface IProps { recorderStatus: RecStatus; videoState: RendererVideo[]; setVideoState: Dispatch>; appState: AppState; setAppState: Dispatch>; persistentProgress: MutableRefObject; playerHeight: MutableRefObject; config: ConfigurationSchema; setConfig: Dispatch>; advancedLoggingStatus: AdvancedLoggingStatus; previewEnabled: boolean; setPreviewEnabled: Dispatch>; } /** * The main window, minus the top and bottom bars. */ const Layout = (props: IProps) => { const { recorderStatus, videoState, setVideoState, appState, setAppState, persistentProgress, playerHeight, config, setConfig, advancedLoggingStatus, previewEnabled, setPreviewEnabled, } = props; const { page, category } = appState; const renderCategoryPage = () => { return ( ); }; const renderSettingsPage = () => { return ( ); }; const renderSceneEditor = () => { return ( ); }; return ( <> {page === Pages.Settings && renderSettingsPage()} {page === Pages.SceneEditor && renderSceneEditor()} {page === Pages.None && renderCategoryPage()} ); }; export default Layout; ================================================ FILE: src/renderer/LocaleSettings.tsx ================================================ import * as React from 'react'; import { configSchema, ConfigurationSchema } from 'config/configSchema'; import { Info } from 'lucide-react'; import { Dispatch, SetStateAction } from 'react'; import { getLocalePhrase, Language } from 'localisation/translations'; import { AppState } from 'main/types'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from './components/Select/Select'; import { setConfigValues } from './useSettings'; import Label from './components/Label/Label'; import { Tooltip } from './components/Tooltip/Tooltip'; import Switch from './components/Switch/Switch'; import { Phrase } from 'localisation/phrases'; interface IProps { config: ConfigurationSchema; setConfig: Dispatch>; appState: AppState; setAppState: React.Dispatch>; } const WindowsSettings = (props: IProps) => { const { config, setConfig, appState, setAppState } = props; const initialRender = React.useRef(true); React.useEffect(() => { // Don't fire on the initial render. if (initialRender.current) { initialRender.current = false; return; } setConfigValues({ language: config.language, }); }, [config.language]); const getSwitch = ( preference: keyof ConfigurationSchema, changeFn: (checked: boolean) => void, ) => ( ); const getSwitchForm = ( preference: keyof ConfigurationSchema, label: Phrase, changeFn: (checked: boolean) => void, ) => { return (
{getSwitch(preference, changeFn)}
); }; const mapLanguageToSelectItem = (lang: string) => { return ( {lang} ); }; const setLanguage = (value: Language) => { setAppState((prevState) => { return { ...prevState, language: value, }; }); setConfig((prevState) => { return { ...prevState, language: value, }; }); }; const setHideEmptyCategories = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, hideEmptyCategories: checked, }; }); }; const getLangaugeSelect = () => { return (
{getSwitchForm( 'hideEmptyCategories', Phrase.HideEmptyCategoriesLabel, setHideEmptyCategories, )}
); }; return (
{getLangaugeSelect()}
); }; export default WindowsSettings; ================================================ FILE: src/renderer/LogButton.tsx ================================================ import { FileText } from 'lucide-react'; import { AppState } from 'main/types'; import { getLocalePhrase } from 'localisation/translations'; import { Button } from './components/Button/Button'; import { Tooltip } from './components/Tooltip/Tooltip'; import { Phrase } from 'localisation/phrases'; const ipc = window.electron.ipcRenderer; interface IProps { appState: AppState; } export default function LogsButton(props: IProps) { const { appState } = props; const openLogPath = () => { ipc.sendMessage('logPath', ['open']); }; return ( ); } ================================================ FILE: src/renderer/ManualSettings.tsx ================================================ import { configSchema, ConfigurationSchema } from 'config/configSchema'; import React, { Dispatch, SetStateAction, useEffect, useRef, useState, } from 'react'; import { AppState } from 'main/types'; import { getLocalePhrase } from 'localisation/translations'; import { setConfigValues } from './useSettings'; import Switch from './components/Switch/Switch'; import Label from './components/Label/Label'; import { Phrase } from 'localisation/phrases'; import { Tooltip } from './components/Tooltip/Tooltip'; import { Info } from 'lucide-react'; import { Input } from './components/Input/Input'; import { getKeyModifiersString, getKeyPressEventString, getManualRecordHotKeyFromConfig, getNextKeyOrMouseEvent, } from './rendererutils'; import { PTTEventType, PTTKeyPressEvent } from 'types/KeyTypesUIOHook'; interface IProps { appState: AppState; config: ConfigurationSchema; setConfig: Dispatch>; } const ManualSettings = (props: IProps) => { const { appState, config, setConfig } = props; const initialRender = useRef(true); const manualHotKeyInputRef = useRef(null); const [manualHotKeyFieldFocused, setManualHotKeyFieldFocused] = useState(false); const [manualHotKey, setManualHotKey] = useState( getManualRecordHotKeyFromConfig(config), ); useEffect(() => { const setManualKeyConfig = (event: PTTKeyPressEvent) => { setConfig((prevState) => { return { ...prevState, manualRecordHotKey: event.keyCode, manualRecordHotKeyModifiers: getKeyModifiersString(event), }; }); }; const listenNextKeyPress = async () => { if (manualHotKeyFieldFocused) { let keyPressEvent = await getNextKeyOrMouseEvent(); while ( keyPressEvent.type === PTTEventType.EVENT_MOUSE_PRESSED || keyPressEvent.type === PTTEventType.EVENT_MOUSE_RELEASED ) { // Don't accept mouse events keyPressEvent = await getNextKeyOrMouseEvent(); } setManualHotKeyFieldFocused(false); setManualHotKey(keyPressEvent); setManualKeyConfig(keyPressEvent); manualHotKeyInputRef.current?.blur(); } }; listenNextKeyPress(); }, [manualHotKeyFieldFocused, setConfig]); useEffect(() => { // Don't fire on the initial render. if (initialRender.current) { initialRender.current = false; return; } setConfigValues({ manualRecord: config.manualRecord, manualRecordHotKey: config.manualRecordHotKey, manualRecordHotKeyModifiers: config.manualRecordHotKeyModifiers, manualRecordSoundAlert: config.manualRecordSoundAlert, }); }, [ config.manualRecord, config.manualRecordHotKey, config.manualRecordHotKeyModifiers, config.manualRecordSoundAlert, ]); const getSwitch = ( preference: keyof ConfigurationSchema, changeFn: (checked: boolean) => void, ) => ( ); const getSwitchForm = ( preference: keyof ConfigurationSchema, label: Phrase, changeFn: (checked: boolean) => void, ) => { return (
{getSwitch(preference, changeFn)}
); }; const setRecordManual = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, manualRecord: checked, }; }); }; const setSoundAlert = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, manualRecordSoundAlert: checked, }; }); }; const getHotkeyString = () => { if (manualHotKeyFieldFocused) { return getLocalePhrase(appState.language, Phrase.PressAnyKeyCombination); } if (manualHotKey !== null) { return `${getKeyPressEventString(manualHotKey, appState)} (${getLocalePhrase( appState.language, Phrase.ClickToRebind, )})`; } return getLocalePhrase(appState.language, Phrase.ClickToBind); }; const getManualHotKeySelect = () => { return (
setManualHotKeyFieldFocused(true)} onBlur={() => setManualHotKeyFieldFocused(false)} readOnly />
); }; return (
{getSwitchForm( 'manualRecord', Phrase.ManualRecordSwitchLabel, setRecordManual, )} {config.manualRecord && getSwitchForm( 'manualRecordSoundAlert', Phrase.ManualRecordSoundAlertLabel, setSoundAlert, )} {config.manualRecord && getManualHotKeySelect()}
); }; export default ManualSettings; ================================================ FILE: src/renderer/MicrophoneStatus.tsx ================================================ import { MicStatus } from 'main/types'; import MicIcon from '@mui/icons-material/Mic'; import MicOffIcon from '@mui/icons-material/MicOff'; import { Tooltip } from '@mui/material'; interface IProps { micStatus: MicStatus; } export default function MicrophoneStatus(props: IProps) { const { micStatus } = props; if (micStatus === MicStatus.LISTENING) { return ( ); } if (micStatus === MicStatus.MUTED) { return ( ); } return <>; } ================================================ FILE: src/renderer/MultiPovPlaybackToggles.tsx ================================================ import { AppState, RendererVideo } from 'main/types'; import { ToggleGroup, ToggleGroupItem, } from './components/ToggleGroup/ToggleGroup'; import Label from './components/Label/Label'; import { LayoutGrid, TvMinimal } from 'lucide-react'; import { getLocalePhrase } from 'localisation/translations'; import { Phrase } from 'localisation/phrases'; interface IProps { appState: AppState; setAppState: React.Dispatch>; allowMultiPlayer: boolean; opts: RendererVideo[]; } const MultiPovPlaybackToggles = (props: IProps) => { const { appState, setAppState, allowMultiPlayer, opts } = props; const { selectedVideos, multiPlayerMode, language } = appState; const onValueChange = (value: string) => { let s = [...selectedVideos]; if (value === 'true') { // User has selected multi player mode. Fill up to 2 slots s = opts.slice(0, 2); } else { // Remove all but the first selected video now that we're switching out // of multiPlayerMode. s = s.slice(0, 1); } setAppState((prevState) => { return { ...prevState, multiPlayerMode: value === 'true', viewpointSelectionOpen: value === 'true', selectedVideos: s, }; }); }; return (
); }; export default MultiPovPlaybackToggles; ================================================ FILE: src/renderer/PVESettings.tsx ================================================ import { configSchema, ConfigurationSchema } from 'config/configSchema'; import React from 'react'; import { Info } from 'lucide-react'; import { getLocalePhrase } from 'localisation/translations'; import { AppState } from 'main/types'; import { setConfigValues, useSettings } from './useSettings'; import Switch from './components/Switch/Switch'; import Label from './components/Label/Label'; import { Tooltip } from './components/Tooltip/Tooltip'; import { Input } from './components/Input/Input'; import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue, } from './components/Select/Select'; import { Phrase } from 'localisation/phrases'; const raidDifficultyOptions = [ { name: 'LFR', phrase: Phrase.LFR }, { name: 'Normal', phrase: Phrase.Normal }, { name: 'Heroic', phrase: Phrase.Heroic }, { name: 'Mythic', phrase: Phrase.Mythic }, ]; interface IProps { appState: AppState; } const PVESettings = (props: IProps) => { const { appState } = props; const [config, setConfig] = useSettings(); const initialRender = React.useRef(true); React.useEffect(() => { // Don't fire on the initial render. if (initialRender.current) { initialRender.current = false; return; } const toSet: Record = { recordRaids: config.recordRaids, minEncounterDuration: config.minEncounterDuration, minRaidDifficulty: config.minRaidDifficulty, recordDungeons: config.recordDungeons, recordChallengeModes: config.recordChallengeModes, raidOverrun: config.raidOverrun, dungeonOverrun: config.dungeonOverrun, recordCurrentRaidEncountersOnly: config.recordCurrentRaidEncountersOnly, }; // Only set these if they are valid values. We allow -1 set in the // frontend to represent temporarily unset values in the Input fields. if (config.minEncounterDuration >= 0) { toSet.minEncounterDuration = config.minEncounterDuration; } if (config.raidOverrun >= 0) { toSet.raidOverrun = config.raidOverrun; } if (config.minKeystoneLevel >= 0) { toSet.minKeystoneLevel = config.minKeystoneLevel; } if (config.dungeonOverrun >= 0) { toSet.dungeonOverrun = config.dungeonOverrun; } setConfigValues(toSet); }, [ config.dungeonOverrun, config.minEncounterDuration, config.minKeystoneLevel, config.minRaidDifficulty, config.raidOverrun, config.recordDungeons, config.recordChallengeModes, config.recordRaids, config.recordCurrentRaidEncountersOnly, ]); const getSwitch = ( preference: keyof ConfigurationSchema, changeFn: (checked: boolean) => void, ) => ( ); const setRecordRaids = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordRaids: checked, }; }); }; const getRecordRaidSwitch = () => { return (
{getSwitch('recordRaids', setRecordRaids)}
); }; const setRecordCurrentRaidEncountersOnly = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordCurrentRaidEncountersOnly: checked, }; }); }; const getRecordCurrentEncountersOnlySwitch = () => { if (!config.recordRaids) { return <>; } return (
{getSwitch( 'recordCurrentRaidEncountersOnly', setRecordCurrentRaidEncountersOnly, )}
); }; const setMinEncounterDuration = ( event: React.ChangeEvent, ) => { if (!event.target.value) { // Allow setting empty as midpoint. setConfig((prev) => ({ ...prev, minEncounterDuration: -1 })); return; } const minEncounterDuration = parseInt(event.target.value, 10); if (Number.isNaN(minEncounterDuration)) { return; } setConfig((prevState) => { return { ...prevState, minEncounterDuration, }; }); }; const getMinEncounterDurationField = () => { if (!config.recordRaids) { return <>; } return (
= 0 ? config.minEncounterDuration : '' } name="minEncounterDuration" disabled={!config.recordRaids} onChange={setMinEncounterDuration} type="numeric" />
); }; const setMinRaidDifficulty = (value: string) => { setConfig((prevState) => { return { ...prevState, minRaidDifficulty: value, }; }); }; const getMinRaidDifficultySelect = () => { if (!config.recordRaids) { return <>; } return (
); }; const setRaidOverrun = (event: React.ChangeEvent) => { if (!event.target.value) { // Allow setting empty as midpoint. setConfig((prev) => ({ ...prev, raidOverrun: -1 })); return; } const raidOverrun = parseInt(event.target.value, 10); if (Number.isNaN(raidOverrun) || raidOverrun < 0 || raidOverrun > 60) { // Don't allow invalid config to go further. return; } setConfig((prevState) => { return { ...prevState, raidOverrun, }; }); }; const getRaidOverrunField = () => { if (!config.recordRaids) { return <>; } return (
= 0 ? config.raidOverrun : ''} name="raidOverrun" disabled={!config.recordRaids} onChange={setRaidOverrun} type="numeric" />
); }; const setDungeonOverrun = (event: React.ChangeEvent) => { if (!event.target.value) { // Allow setting empty as midpoint. setConfig((prev) => ({ ...prev, dungeonOverrun: -1 })); return; } const dungeonOverrun = parseInt(event.target.value, 10); if ( Number.isNaN(dungeonOverrun) || dungeonOverrun < 0 || dungeonOverrun > 60 ) { // Don't allow invalid config to go further. return; } setConfig((prevState) => { return { ...prevState, dungeonOverrun, }; }); }; const getDungeonOverrunField = () => { if (!config.recordDungeons) { return <>; } return (
= 0 ? config.dungeonOverrun : ''} name="dungeonOverrun" disabled={!config.recordDungeons} onChange={setDungeonOverrun} type="numeric" />
); }; const setRecordDungeons = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordDungeons: checked, }; }); }; const setRecordChallengeModes = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordChallengeModes: checked, }; }); }; const getRecordDungeonSwitch = () => { return (
{getSwitch('recordDungeons', setRecordDungeons)}
); }; const setMinKeystoneLevel = (event: React.ChangeEvent) => { if (!event.target.value) { // Allow setting empty as midpoint. setConfig((prev) => ({ ...prev, minKeystoneLevel: -1 })); return; } const minKeystoneLevel = parseInt(event.target.value, 10); if (Number.isNaN(minKeystoneLevel)) { return; } setConfig((prevState) => { return { ...prevState, minKeystoneLevel, }; }); }; const getMinKeystoneLevelField = () => { if (!config.recordDungeons) { return <>; } return (
= 0 ? config.minKeystoneLevel : ''} name="minKeystoneLevel" disabled={!config.recordDungeons} onChange={setMinKeystoneLevel} type="numeric" min={2} />
); }; const getChallengeModeField = () => { if (!config.recordClassic) { return <>; } return (
{getSwitch('recordChallengeModes', setRecordChallengeModes)}
); }; return (
{getRecordRaidSwitch()} {getRecordCurrentEncountersOnlySwitch()} {getMinEncounterDurationField()} {getRaidOverrunField()} {getMinRaidDifficultySelect()}
{getRecordDungeonSwitch()} {getChallengeModeField()} {getMinKeystoneLevelField()} {getDungeonOverrunField()}
); }; export default PVESettings; ================================================ FILE: src/renderer/PVPSettings.tsx ================================================ import { ConfigurationSchema } from 'config/configSchema'; import React from 'react'; import { AppState } from 'main/types'; import { getLocalePhrase } from 'localisation/translations'; import { setConfigValues, useSettings } from './useSettings'; import Switch from './components/Switch/Switch'; import Label from './components/Label/Label'; import { Phrase } from 'localisation/phrases'; interface IProps { appState: AppState; } const PVPSettings = (props: IProps) => { const { appState } = props; const [config, setConfig] = useSettings(); const initialRender = React.useRef(true); React.useEffect(() => { // Don't fire on the initial render. if (initialRender.current) { initialRender.current = false; return; } setConfigValues({ recordTwoVTwo: config.recordTwoVTwo, recordThreeVThree: config.recordThreeVThree, recordFiveVFive: config.recordFiveVFive, recordSkirmish: config.recordSkirmish, recordSoloShuffle: config.recordSoloShuffle, recordBattlegrounds: config.recordBattlegrounds, }); }, [ config.recordBattlegrounds, config.recordFiveVFive, config.recordSkirmish, config.recordSoloShuffle, config.recordThreeVThree, config.recordTwoVTwo, ]); const getSwitch = ( preference: keyof ConfigurationSchema, changeFn: (checked: boolean) => void, ) => ( ); const getSwitchForm = ( preference: keyof ConfigurationSchema, label: Phrase, changeFn: (checked: boolean) => void, ) => { return (
{getSwitch(preference, changeFn)}
); }; const setRecord2v2 = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordTwoVTwo: checked, }; }); }; const setRecord3v3 = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordThreeVThree: checked, }; }); }; const setRecord5v5 = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordFiveVFive: checked, }; }); }; const setRecordSkirmish = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordSkirmish: checked, }; }); }; const setRecordSolo = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordSoloShuffle: checked, }; }); }; const setRecordBgs = (checked: boolean) => { setConfig((prevState) => { return { ...prevState, recordBattlegrounds: checked, }; }); }; return (
{getSwitchForm('recordTwoVTwo', Phrase.Record2v2Label, setRecord2v2)} {getSwitchForm('recordThreeVThree', Phrase.Record3v3Label, setRecord3v3)} {getSwitchForm('recordFiveVFive', Phrase.Record5v5Label, setRecord5v5)} {getSwitchForm( 'recordSkirmish', Phrase.RecordSkirmishLabel, setRecordSkirmish, )} {getSwitchForm( 'recordSoloShuffle', Phrase.RecordSoloShuffleLabel, setRecordSolo, )} {getSwitchForm( 'recordBattlegrounds', Phrase.RecordBattlegroundsLabel, setRecordBgs, )}
); }; export default PVPSettings; ================================================ FILE: src/renderer/PatreonButton.tsx ================================================ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPatreon } from '@fortawesome/free-brands-svg-icons'; import { AppState } from 'main/types'; import { getLocalePhrase } from 'localisation/translations'; import { Button } from './components/Button/Button'; import { Tooltip } from './components/Tooltip/Tooltip'; import { Phrase } from 'localisation/phrases'; const ipc = window.electron.ipcRenderer; interface IProps { appState: AppState; } export default function PatreonButton(props: IProps) { const { appState } = props; const openPatreonURL = () => { ipc.sendMessage('openURL', ['https://www.patreon.com/WarcraftRecorder']); }; return ( ); } ================================================ FILE: src/renderer/RaidComp.tsx ================================================ import { Box } from '@mui/material'; import React from 'react'; import { RawCombatant, RendererVideo } from 'main/types'; import { specializationById } from 'main/constants'; import { roleImages } from './images'; import DeathIcon from '../../assets/icon/death.png'; interface IProps { video: RendererVideo; } type RoleCount = { tank: number; healer: number; damage: number; }; const RaidCompAndResult: React.FC = (props: IProps) => { const { video } = props; const { combatants, deaths } = video; const deathCount = deaths ? deaths.length : 0; const roleCount: RoleCount = { tank: 0, healer: 0, damage: 0, }; combatants.forEach((combant: RawCombatant) => { const specID = combant._specID; if (specID === undefined) { return; } const spec = specializationById[specID]; if (spec === undefined) { return; } const { role } = spec; roleCount[role]++; }); const renderCounter = (role: string) => { return ( {roleCount[role as keyof RoleCount]} ); }; const renderRaidComp = () => { if (combatants.length < 1) { return <>; } return ( {Object.keys(roleCount).map(renderCounter)} ); }; const renderDeaths = () => { return ( {deathCount} ); }; return ( {renderDeaths()} {renderRaidComp()} ); }; export default RaidCompAndResult; ================================================ FILE: src/renderer/RecorderPreview.tsx ================================================ import { Box } from '@mui/material'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { stopPropagation } from './rendererutils'; import { AppState, BoxDimensions, SceneInteraction, SceneItem, } from 'main/types'; import { ConfigurationSchema } from 'config/configSchema'; import { getLocalePhrase } from 'localisation/translations'; import { Phrase } from 'localisation/phrases'; const ipc = window.electron.ipcRenderer; const showPreview = ipc.showPreview; const hidePreview = ipc.hidePreview; const disablePreview = ipc.disablePreview; const cornerSize = 25; // Size in pixels for the corner box const snapDistance = 15; // Number of pixels to snap to. enum Snap { TOP = 'Top', BOTTOM = 'Bottom', LEFT = 'Left', RIGHT = 'Right', NONE = 'None', } const RecorderPreview = (props: { appState: AppState; previewEnabled: boolean; config: ConfigurationSchema; snapEnabled: boolean; }) => { const { appState, previewEnabled, config, snapEnabled } = props; const { language } = appState; const initialRender = useRef(true); const previewDivRef = useRef(null); const draggingOverlay = useRef(SceneInteraction.NONE); const draggingGame = useRef(SceneInteraction.NONE); let zIndex = 1; let resizeObserver: ResizeObserver | undefined; const [previewInfo, setPreviewInfo] = useState<{ canvasWidth: number; canvasHeight: number; previewWidth: number; previewHeight: number; }>({ canvasWidth: 0, canvasHeight: 0, previewWidth: 0, previewHeight: 0, }); const snapOverlay = { x: Snap.NONE, y: Snap.NONE }; const snapGame = { x: Snap.NONE, y: Snap.NONE }; const [overlayBoxDimensions, setOverlayBoxDimensions] = useState({ x: 0, y: 0, width: 200, height: 100, cropLeft: 0, cropRight: 0, cropTop: 0, cropBottom: 0, }); const [gameBoxDimensions, setGameBoxDimensions] = useState({ x: 0, y: 0, width: 1000, height: 500, cropLeft: 0, cropRight: 0, cropTop: 0, cropBottom: 0, }); useEffect(() => { // On component mount, get the source dimensions from the backend // to initialize the draggable boxes. configureDraggableBoxes(); configurePreview(); showPreview(); setupResizeObserver(); return () => { // Disconnect the resize observer. cleanupResizeObserver(); // Disable the preview, we're tabbing away. This means it's switched // off and not consuming GPU resources rather than just hidden from view. disablePreview(); }; }, []); useEffect(() => { // Just save calling the stuff below on initial render, we setup // everything in the other use effect. This isn't really important // but it helps to avoid unnecessary function calls. if (initialRender.current) return; if (previewEnabled) { showPreview(); } else { hidePreview(); } }, [previewEnabled]); useEffect(() => { configureDraggableBoxes(); }, [snapEnabled, config.chatOverlayEnabled]); // The display maintains the canvas ratio, so it is either X limited, // Y limited, or a perfect fit. We need to calculate that to offset the // draggable boxes on the preview box, and also to account for snapping. const sfx = previewInfo.previewWidth / previewInfo.canvasWidth; const sfy = previewInfo.previewHeight / previewInfo.canvasHeight; const xLimited = sfx < sfy; let xCorr = 0; let yCorr = 0; if (xLimited) { yCorr = (previewInfo.previewHeight - sfx * previewInfo.canvasHeight) / 2; } else { xCorr = (previewInfo.previewWidth - sfy * previewInfo.canvasWidth) / 2; } if (snapEnabled) { // Decide if we should snap the chat overlay. if (Math.abs(overlayBoxDimensions.y) < snapDistance) { snapOverlay.y = Snap.TOP; } else if ( Math.abs( overlayBoxDimensions.y + overlayBoxDimensions.height - overlayBoxDimensions.cropTop - overlayBoxDimensions.cropBottom - previewInfo.previewHeight + 2 * yCorr, ) < snapDistance ) { snapOverlay.y = Snap.BOTTOM; } if (Math.abs(overlayBoxDimensions.x) < snapDistance) { snapOverlay.x = Snap.LEFT; } else if ( Math.abs( overlayBoxDimensions.x + overlayBoxDimensions.width - overlayBoxDimensions.cropLeft - overlayBoxDimensions.cropRight - previewInfo.previewWidth + 2 * xCorr, ) < snapDistance ) { snapOverlay.x = Snap.RIGHT; } // Decide if we should snap the game overlay. if (Math.abs(gameBoxDimensions.y) < snapDistance) { snapGame.y = Snap.TOP; } else if ( Math.abs( gameBoxDimensions.y + gameBoxDimensions.height - previewInfo.previewHeight + 2 * yCorr, ) < snapDistance ) { snapGame.y = Snap.BOTTOM; } if (Math.abs(gameBoxDimensions.x) < snapDistance) { snapGame.x = Snap.LEFT; } else if ( Math.abs( gameBoxDimensions.x + gameBoxDimensions.width - previewInfo.previewWidth + 2 * xCorr, ) < snapDistance ) { snapGame.x = Snap.RIGHT; } } const configureDraggableBoxes = async () => { const display = await ipc.getDisplayInfo(); setPreviewInfo(display); if (config.chatOverlayEnabled) { const pos = await ipc.getSourcePosition(SceneItem.OVERLAY); setOverlayBoxDimensions(pos); } const pos = await ipc.getSourcePosition(SceneItem.GAME); setGameBoxDimensions(pos); }; const configurePreview = async () => { const zoomFactor = window.devicePixelRatio; // Windows display scaling. if (previewDivRef.current) { const { width, height, x, y } = previewDivRef.current.getBoundingClientRect(); ipc.configurePreview( x * zoomFactor, y * zoomFactor, width * zoomFactor, height * zoomFactor, ); } }; useEffect(() => { ipc.on('redrawPreview', configureDraggableBoxes); return () => { ipc.removeAllListeners('redrawPreview'); }; }, [configureDraggableBoxes]); useEffect(() => { if (initialRender.current) return; // If the crop sliders change we need to redraw. configureDraggableBoxes(); }, [config.chatOverlayCropX, config.chatOverlayCropY]); useEffect(() => { initialRender.current = false; }, []); const onSourceMove = (event: MouseEvent, src: SceneItem) => { const zoomFactor = window.devicePixelRatio; const fn = src === SceneItem.OVERLAY ? setOverlayBoxDimensions : setGameBoxDimensions; const snap = src === SceneItem.OVERLAY ? snapOverlay : snapGame; fn((prev) => { const updated = { ...prev, x: prev.x + event.movementX * zoomFactor, y: prev.y + event.movementY * zoomFactor, }; const snapped = { ...updated }; if (snap.x === Snap.LEFT) { snapped.x = 0; } else if (snap.x === Snap.RIGHT) { snapped.x = previewInfo.previewWidth - snapped.width - 2 * xCorr + snapped.cropRight + snapped.cropLeft; } if (snap.y === Snap.TOP) { snapped.y = 0; } else if (snap.y === Snap.BOTTOM) { snapped.y = previewInfo.previewHeight - snapped.height - 2 * yCorr + snapped.cropBottom + snapped.cropTop; } ipc.setSourcePosition(src, snapped); return updated; }); }; const onSourceScale = (event: MouseEvent, src: SceneItem) => { const zoomFactor = window.devicePixelRatio; const fn = src === SceneItem.OVERLAY ? setOverlayBoxDimensions : setGameBoxDimensions; fn((prev) => { const aspectRatio = prev.width / prev.height; let newWidth = prev.width + event.movementX * zoomFactor; newWidth = Math.max(20, newWidth); // Prevent negative or too small sizes const newHeight = newWidth / aspectRatio; const updated = { ...prev, width: newWidth, height: newHeight, }; ipc.setSourcePosition(src, updated); return updated; }); }; const onMouseMove = useCallback( (event: MouseEvent) => { if (draggingOverlay.current === SceneInteraction.MOVE) { onSourceMove(event, SceneItem.OVERLAY); } else if (draggingGame.current === SceneInteraction.MOVE) { onSourceMove(event, SceneItem.GAME); } else if (draggingGame.current === SceneInteraction.SCALE) { onSourceScale(event, SceneItem.GAME); } else if (draggingOverlay.current === SceneInteraction.SCALE) { onSourceScale(event, SceneItem.OVERLAY); } }, [onSourceMove], ); const onMouseUp = () => { draggingGame.current = SceneInteraction.NONE; draggingOverlay.current = SceneInteraction.NONE; }; const onMouseDown = useCallback( ( event: React.MouseEvent, src: SceneItem, action: SceneInteraction, ) => { if (src === SceneItem.OVERLAY) { draggingOverlay.current = action; } else { draggingGame.current = action; } stopPropagation(event); }, [], ); useEffect(() => { // Listen on the document for mouse events other than mousedown, // so that if the cursor goes outwith the draggable area, we can // still capture the events. document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); return () => { // Remove the mouse event listeners. document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; }, [onMouseMove, onMouseUp]); const cleanupResizeObserver = () => { if (resizeObserver !== undefined) { resizeObserver.disconnect(); resizeObserver = undefined; } }; const setupResizeObserver = () => { if (resizeObserver === undefined) { resizeObserver = new ResizeObserver(() => configurePreview()); } if (previewDivRef.current) { resizeObserver.observe(previewDivRef.current); } }; const renderDraggableSceneBox = (src: SceneItem) => { if (src === SceneItem.OVERLAY && !config.chatOverlayEnabled) { return <>; } const { x, y, width, height, cropLeft, cropRight, cropTop, cropBottom } = src === SceneItem.OVERLAY ? overlayBoxDimensions : gameBoxDimensions; const snap = src === SceneItem.OVERLAY ? snapOverlay : snapGame; if (width < 1 && height < 1) { return <>; } const text = src === SceneItem.OVERLAY ? getLocalePhrase(language, Phrase.ChatOverlayLabel) : getLocalePhrase(language, Phrase.GameWindowLabel); const position: { left: number; top: number; } = { left: 0, top: 0, }; if (snap.x === Snap.LEFT) { position.left = xCorr; } else if (snap.x === Snap.RIGHT) { position.left = previewInfo.previewWidth - width - xCorr + cropLeft + cropRight; } else { position.left = x + xCorr; } if (snap.y === Snap.TOP) { position.top = yCorr; } else if (snap.y === Snap.BOTTOM) { position.top = previewInfo.previewHeight - height - yCorr + cropTop + cropBottom; } else { position.top = y + yCorr; } // Handle windows display scaling. const zoomFactor = window.devicePixelRatio; position.left = position.left / zoomFactor; position.top = position.top / zoomFactor; return ( onMouseDown(e, src, SceneInteraction.MOVE)} sx={{ position: 'absolute', ...position, height: (height - cropTop - cropBottom) / zoomFactor, width: (width - cropLeft - cropRight) / zoomFactor, outline: '2px solid #bb4420', outlineOffset: '-4px', // Slight offset to save it showing up on the edges. zIndex: ++zIndex, cursor: 'move', }} >
{text}
onMouseDown(e, src, SceneInteraction.SCALE)} sx={{ position: 'absolute', right: 2, // Slight offset to save it showing up on the edges. bottom: 2, // Slight offset to save it showing up on the edges. width: cornerSize, height: cornerSize, backgroundColor: '#bb4420', zIndex, cursor: 'se-resize', }} /> ); }; return (
{renderDraggableSceneBox(SceneItem.GAME)} {renderDraggableSceneBox(SceneItem.OVERLAY)}
); }; export default RecorderPreview; ================================================ FILE: src/renderer/RendererTitleBar.tsx ================================================ import { ComponentProps } from 'react'; import { cn } from './components/utils'; import icon from '../../assets/icon.png'; const ipc = window.electron.ipcRenderer; export default function RendererTitleBar() { const clickedHide = () => { ipc.sendMessage('window', ['minimize']); }; const clickedResize = () => { ipc.sendMessage('window', ['resize']); }; const clickedQuit = () => { ipc.sendMessage('window', ['quit']); }; const TitleBarButton = ({ children, className, ...props }: ComponentProps<'button'>) => { return ( ); }; return (
Warcraft Recorder
🗕 🗗
); } ================================================ FILE: src/renderer/SavingStatus.tsx ================================================ import { IconButton } from '@mui/material'; import SaveAsIcon from '@mui/icons-material/SaveAs'; import { SaveStatus } from 'main/types'; interface IProps { savingStatus: SaveStatus; } export default function SavingStatus(props: IProps) { const { savingStatus } = props; if (savingStatus === SaveStatus.NotSaving) { return <>; } return ( ); } ================================================ FILE: src/renderer/SceneEditor.tsx ================================================ import { Box } from '@mui/material'; import React, { Dispatch, SetStateAction, useState } from 'react'; import { AppState, RecStatus, SceneItem } from 'main/types'; import { Phrase } from 'localisation/phrases'; import { getLocalePhrase } from 'localisation/translations'; import RecorderPreview from './RecorderPreview'; import ChatOverlayControls from './ChatOverlayControls'; import VideoSourceControls from './VideoSourceControls'; import AudioSourceControls from './AudioSourceControls'; import VideoBaseControls from './VideoBaseControls'; import { ScrollArea } from './components/ScrollArea/ScrollArea'; import { Tabs, TabsContent, TabsList, TabsTrigger, } from './components/Tabs/Tabs'; import { Button } from './components/Button/Button'; import Switch from './components/Switch/Switch'; import { Tooltip } from './components/Tooltip/Tooltip'; import { ConfigurationSchema } from 'config/configSchema'; const ipc = window.electron.ipcRenderer; const devMode = process.env.NODE_ENV === 'development'; interface IProps { appState: AppState; recorderStatus: RecStatus; config: ConfigurationSchema; setConfig: Dispatch>; previewEnabled: boolean; setPreviewEnabled: Dispatch>; } const SceneEditor: React.FC = (props: IProps) => { const { recorderStatus, appState, config, setConfig, previewEnabled, setPreviewEnabled, } = props; const [snapEnabled, setSnapEnabled] = useState(true); const renderResetGameButton = () => { return ( ); }; const renderResetOverlayButton = () => { return ( ); }; const renderToggleSnappingSwitch = () => { return (
{getLocalePhrase(appState.language, Phrase.SourceSnappingSwitchText)}
); }; const renderShowPreviewSwitch = () => { // This is a dev mode only thing so don't worry about translations. return (
Preview
); }; return ( {getLocalePhrase(appState.language, Phrase.SourceHeading)} {getLocalePhrase(appState.language, Phrase.VideoHeading)} {getLocalePhrase(appState.language, Phrase.AudioHeading)} {getLocalePhrase(appState.language, Phrase.OverlayHeading)}
{renderResetGameButton()} {config.chatOverlayEnabled && renderResetOverlayButton()} {renderToggleSnappingSwitch()} {devMode && renderShowPreviewSwitch()}
); }; export default SceneEditor; ================================================ FILE: src/renderer/SearchBar.tsx ================================================ import { AppState, RendererVideo } from 'main/types'; import { KeyboardEventHandler, useEffect, useRef, useState } from 'react'; import { getLocalePhrase } from 'localisation/translations'; import { Phrase } from 'localisation/phrases'; import VideoFilter from './VideoFilter'; import Label from './components/Label/Label'; import { InputRendererProps, OptionRendererProps, ReactTags, Tag, TagRendererProps, ReactTagsAPI, } from 'react-tag-autocomplete'; import { Box } from '@mui/material'; import { LockKeyhole, LockOpen, Search, ThumbsDown, ThumbsUp, X, } from 'lucide-react'; import React from 'react'; import ShieldIcon from '@mui/icons-material/Shield'; import { MapPinned } from 'lucide-react'; import { faDragon, faDungeon, faMessage, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import VideoTag from './VideoTag'; import { LocalPolice } from '@mui/icons-material'; import HomeRepairServiceIcon from '@mui/icons-material/HomeRepairService'; import HourglassDisabledIcon from '@mui/icons-material/HourglassDisabled'; interface IProps { appState: AppState; setAppState: React.Dispatch>; filteredState: RendererVideo[]; } const SearchBar = (props: IProps) => { const { appState, setAppState, filteredState } = props; const { language, videoFilterTags } = appState; const api = useRef(null); const alphabeticalValueSort = (a: Tag, b: Tag) => { return String(a.value).localeCompare(String(b.value)); }; // We filter this by current query so that we don't suggest things that are // no longer valid based on the already applied filter. const [suggestions, setSuggestions] = useState( VideoFilter.getCategorySuggestions(filteredState, language).map((t) => t.getAsTag(), ), ); useEffect(() => { const s = VideoFilter.getCategorySuggestions(filteredState, language) .map((t) => t.getAsTag()) .filter((t) => !videoFilterTags.map((i) => i.value).includes(t.value)); setSuggestions(s); }, [filteredState, videoFilterTags, language]); const onAdd = (newTag: Tag) => { setAppState((prevState) => { return { ...prevState, videoFilterTags: [...appState.videoFilterTags, newTag], }; }); setSuggestions((prevSuggestions) => { return prevSuggestions.filter( (suggestion) => suggestion.value !== newTag.value, ); }); }; const onDelete = (tagIndex: number) => { const deletedTag = appState.videoFilterTags[tagIndex]; setAppState((prevState) => { return { ...prevState, videoFilterTags: appState.videoFilterTags.filter( (_, i) => i !== tagIndex, ), }; }); setSuggestions((prevSuggestions) => { return [...prevSuggestions, deletedTag]; }); }; const renderIcon = (icon: string) => { const muiIconPropsSx = { height: '15px', width: '25px', color: 'white', }; if (icon === '') { return ; } if (icon === '') { return ; } if (icon === '') { return ; } if (icon === '') { return ; } if (icon === '') { return ; } if (icon === '') { return ; } if (icon === '') { return ( ); } if (icon === '') { return ( ); } if (icon === '') { return ( ); } if (icon === '') { return ; } if (icon === '') { return ( ); } if (icon === '') { return ; } return ( ); }; const renderOption = ({ classNames, option, ...optionProps }: OptionRendererProps) => { // Technically tags can have value of a few types but we only ever use strings. if (typeof option.value !== 'string') { return <>; } const classes = [ classNames.option, option.active ? 'is-active' : '', option.selected ? 'is-selected' : '', ]; const tag = VideoTag.decode(option.value); return (
{renderIcon(tag.icon)} {tag.label}
); }; /** * Custom tag rendering, the default is just a div with the tag label in * it, this function includes all that but also an icon, background coloring, * and appropriate styling. */ const renderTag = ({ classNames, tag, ...tagProps }: TagRendererProps) => { if (typeof tag.value !== 'string') { // Technically tags can have value of a few types but we only ever use strings. return <>; } // A limitation of the react-tag-autocomplete library is that it doesn't allow // for custom tag types. The workaround used here is to use the VideoTag class to // encapsulate all the required information into a string. const decoded = VideoTag.decode(tag.value); // If they are a priest we don't want to have white text on a white bakground. const closeIconColor = decoded.color === '#FFFFFF' ? 'black' : 'white'; const textClass = decoded.color === '#FFFFFF' ? 'text-black' : 'text-white'; const twTagClass = [ 'flex', 'items-center', 'font-sans', 'font-bold', 'text-[12px]', 'truncate', 'gap-1', textClass, ].join(' '); return ( ); }; const renderInput = ({ classNames, inputWidth, ...inputProps }: InputRendererProps) => { const onKeyDown: KeyboardEventHandler = (event) => { if (event.key === 'Tab') { event.preventDefault(); if (api.current) api.current.select(); } inputProps?.onKeyDown?.(event); }; return (
); }; const classNames = { root: 'relative flex items-center cursor-text w-full h-[38px] rounded-md border border-background bg-card text-sm text-foreground-lighter', rootIsActive: 'is-active', rootIsDisabled: 'is-disabled', rootIsInvalid: 'is-invalid', label: 'react-tags__label', tagList: 'flex items-center', tagListItem: 'react-tags__list-item inline-flex items-center', tag: 'h-8 p-1 px-2 mx-1 rounded-md text-white text-sm', tagName: 'react-tags__tag-name', comboBox: 'react-tags__combobox', input: 'react-tags__combobox-input mx-2 placeholder:text-foreground', listBox: 'react-tags__listbox bg-card rounded-md border border-background scrollbar-thin', option: 'react-tags__listbox-option', optionIsActive: 'is-active', highlight: 'react-tags__listbox-option-highlight', }; return (
event.stopPropagation()}> <>} renderTag={renderTag} renderOption={renderOption} renderInput={renderInput} selected={appState.videoFilterTags} suggestions={suggestions.sort(alphabeticalValueSort)} onAdd={onAdd} onDelete={onDelete} placeholderText={getLocalePhrase(language, Phrase.StartTyping)} activateFirstOption collapseOnSelect // Contains a placeholder that is replaced by the library. // https://github.com/i-like-robots/react-tag-autocomplete?tab=readme-ov-file#deletebuttontext-optional deleteButtonText={getLocalePhrase(language, Phrase.RemoveTagFromList)} />
); }; export default SearchBar; ================================================ FILE: src/renderer/SettingsPage.tsx ================================================ import React, { Dispatch, SetStateAction } from 'react'; import { AdvancedLoggingStatus, AppState, RecStatus } from 'main/types'; import { ConfigurationSchema } from 'config/configSchema'; import { getLocalePhrase } from 'localisation/translations'; import GeneralSettings from './GeneralSettings'; import FlavourSettings from './FlavourSettings'; import PVESettings from './PVESettings'; import PVPSettings from './PVPSettings'; import CloudSettings from './CloudSettings'; import { Tabs, TabsList, TabsContent, TabsTrigger, } from './components/Tabs/Tabs'; import Separator from './components/Separator/Separator'; import { ScrollArea } from './components/ScrollArea/ScrollArea'; import LocaleSettings from './LocaleSettings'; import WindowsSettings from './WindowsSettings'; import { Phrase } from 'localisation/phrases'; import ManualSettings from './ManualSettings'; interface IProps { recorderStatus: RecStatus; config: ConfigurationSchema; setConfig: Dispatch>; appState: AppState; setAppState: React.Dispatch>; advancedLoggingStatus: AdvancedLoggingStatus; } const CategoryHeading = ({ children }: { children: React.ReactNode }) => (

{children}

); const SettingsPage: React.FC = (props: IProps) => { const { recorderStatus, config, setConfig, appState, setAppState, advancedLoggingStatus, } = props; return (
{getLocalePhrase( appState.language, Phrase.SettingsPageApplicationHeader, )} {getLocalePhrase(appState.language, Phrase.SettingsPageGameHeader)} {getLocalePhrase(appState.language, Phrase.SettingsPageProHeader)}
{getLocalePhrase( appState.language, Phrase.GeneralSettingsLabel, )}
{getLocalePhrase( appState.language, Phrase.WindowsSettingsLabel, )}
{getLocalePhrase( appState.language, Phrase.LocaleSettingsLabel, )}
{getLocalePhrase(appState.language, Phrase.GameSettingsLabel)}
{getLocalePhrase(appState.language, Phrase.PVESettingsLabel)}
{getLocalePhrase(appState.language, Phrase.PVPSettingsLabel)}
{getLocalePhrase( appState.language, Phrase.ManualRecordSettingsLabel, )}
{getLocalePhrase( appState.language, Phrase.CloudSettingsLabel, )}
); }; export default SettingsPage; ================================================ FILE: src/renderer/SideMenu.tsx ================================================ import { Clapperboard, Cog, Dice2, Dice3, Dice5, Goal, HardHat, MonitorCog, Play, Square, Sword, Swords, } from 'lucide-react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faDungeon, faDragon } from '@fortawesome/free-solid-svg-icons'; import { ActivityStatus, AdvancedLoggingStatus, AppState, ErrorReport, MicStatus, Pages, RecStatus, SaveStatus, } from 'main/types'; import { Dispatch, MutableRefObject, SetStateAction, useEffect, useRef, useState, } from 'react'; import { ConfigurationSchema } from 'config/configSchema'; import { getLocaleCategoryLabel, getLocalePhrase, } from 'localisation/translations'; import { VideoCategory } from '../types/VideoCategory'; import { setConfigValue } from './useSettings'; import { getCategoryIndex } from './rendererutils'; import Menu from './components/Menu'; import Separator from './components/Separator/Separator'; import LogsButton from './LogButton'; import TestButton from './TestButton'; import DiscordButton from './DiscordButton'; import ApplicationStatusCard from './containers/ApplicationStatusCard/ApplicationStatusCard'; import { ScrollArea } from './components/ScrollArea/ScrollArea'; import UpdateNotifier from './containers/UpdateNotifier/UpdateNotifier'; import CloudStatusCard from './containers/ApplicationStatusCard/CloudStatusCard'; import { Phrase } from 'localisation/phrases'; import PatreonButton from './PatreonButton'; import { Button } from './components/Button/Button'; import { Tooltip } from './components/Tooltip/Tooltip'; const ipc = window.electron.ipcRenderer; interface IProps { recorderStatus: RecStatus; videoCounters: Record; appState: AppState; setAppState: React.Dispatch>; persistentProgress: MutableRefObject; error: string; micStatus: MicStatus; errorReports: ErrorReport[]; savingStatus: SaveStatus; config: ConfigurationSchema; updateAvailable: boolean; recorderCategory: VideoCategory | undefined; activityStatus: ActivityStatus | null; advancedLoggingStatus: AdvancedLoggingStatus; setPreviewEnabled: Dispatch>; } const SideMenu = (props: IProps) => { const { recorderStatus, recorderCategory, videoCounters, appState, setAppState, persistentProgress, error, micStatus, errorReports, savingStatus, config, updateAvailable, activityStatus, advancedLoggingStatus, setPreviewEnabled, } = props; const [appVersion, setAppVersion] = useState(); const { category, language } = appState; const lastManualStartStopClickRef = useRef(0); useEffect(() => { window.electron.ipcRenderer.on('updateVersionDisplay', (t: unknown) => { if (typeof t === 'string') { setAppVersion(t.split('v')[1] as string); } }); }, []); useEffect(() => { // If the recording status changes, reset the last manual start/stop click time. lastManualStartStopClickRef.current = 0; }, [recorderStatus]); const renderManualStopStartButton = () => { const recordingOrReady = recorderStatus !== RecStatus.Recording && recorderStatus !== RecStatus.ReadyToRecord; const recordingNonManual = recorderCategory && recorderCategory !== VideoCategory.Manual; const disabled = !config.manualRecord || // Disable the buttons if manual recording is disabled. recordingOrReady || // Disable if not recording or ready to record. recordingNonManual; // If recording something else don't show the stop button. if (disabled) { return <>; } let icon = ; let tooltip = getLocalePhrase(language, Phrase.StartManualRecordingTooltip); if (recorderStatus === RecStatus.Recording) { icon = ; tooltip = getLocalePhrase(language, Phrase.StopManualRecordingTooltip); } return ( ); }; const renderCategoryTab = ( tabCategory: VideoCategory, tabIcon: string | React.ReactNode, ) => { const numTotalVideos = Object.values(videoCounters).reduce((t, v) => t + v); const numCategoryVideos = videoCounters[tabCategory]; const forceShowManual = tabCategory === VideoCategory.Manual && config.manualRecord; if ( config.hideEmptyCategories && // Hide empty categories is enabled. numTotalVideos > 0 && // Only hide categories if there are atleast some videos. !forceShowManual && // Always show manual if manual recording is enabled, it has buttons on it. numCategoryVideos < 1 // If this category has no videos, so hide it. ) { return <>; } return ( {typeof tabIcon === 'string' ? ( {tabCategory} ) : ( tabIcon )} {getLocaleCategoryLabel(language, tabCategory)} {tabCategory === VideoCategory.Manual && renderManualStopStartButton()} ); }; const renderSettingsTab = () => { return ( {getLocalePhrase(language, Phrase.GeneralButtonText)} ); }; const renderSceneTab = () => { return ( {getLocalePhrase(language, Phrase.SceneButtonText)} ); }; const handleChangeCategory = (newCategory: VideoCategory) => { const index = getCategoryIndex(newCategory); setConfigValue('selectedCategory', index); persistentProgress.current = 0; setAppState((prevState) => { return { ...prevState, videoFilterTags: [], page: Pages.None, category: newCategory, selectedVideos: [], multiPlayerMode: false, playing: false, }; }); }; const handleChangePage = (newPage: Pages) => { setAppState((prevState) => { return { ...prevState, page: newPage, }; }); }; const mythicPlusIcon = ; const raidsIcon = ; return (
{getLocalePhrase(language, Phrase.RecordingsHeading)} {renderCategoryTab(VideoCategory.TwoVTwo, )} {renderCategoryTab(VideoCategory.ThreeVThree, )} {renderCategoryTab(VideoCategory.FiveVFive, )} {renderCategoryTab(VideoCategory.Skirmish, )} {renderCategoryTab(VideoCategory.SoloShuffle, )} {renderCategoryTab(VideoCategory.MythicPlus, mythicPlusIcon)} {renderCategoryTab(VideoCategory.Raids, raidsIcon)} {renderCategoryTab(VideoCategory.Battlegrounds, )} {renderCategoryTab(VideoCategory.Manual, )} {renderCategoryTab(VideoCategory.Clips, )} {getLocalePhrase(language, Phrase.SettingsHeading)} {renderSettingsTab()} {renderSceneTab()}
{!!appVersion && (
{getLocalePhrase(language, Phrase.Version)} {appVersion}
)}
); }; export default SideMenu; ================================================ FILE: src/renderer/SnackBar.tsx ================================================ import * as React from 'react'; import Snackbar from '@mui/material/Snackbar'; import IconButton from '@mui/material/IconButton'; import CloseIcon from '@mui/icons-material/Close'; import { SnackbarContent } from '@mui/material'; interface IProps { message: string; timeout: number; open: boolean; color: string; setOpen: React.Dispatch>; } export default function SnackBar(props: IProps) { const { message, timeout, open, setOpen, color } = props; const handleClose = ( _event: React.SyntheticEvent | Event, reason?: string, ) => { if (reason === 'clickaway') { return; } setOpen(false); }; const action = ( <> ); return ( ); } ================================================ FILE: src/renderer/StorageFilterToggle.tsx ================================================ import { AppState, RendererVideo, StorageFilter } from 'main/types'; import { Dispatch, SetStateAction } from 'react'; import { ToggleGroup, ToggleGroupItem, } from './components/ToggleGroup/ToggleGroup'; import CloudIcon from '@mui/icons-material/Cloud'; import SaveIcon from '@mui/icons-material/Save'; import { Workflow } from 'lucide-react'; import { Tooltip } from './components/Tooltip/Tooltip'; import { getLocalePhrase } from 'localisation/translations'; import { Table } from '@tanstack/react-table'; import { Phrase } from 'localisation/phrases'; interface IProps { appState: AppState; setAppState: Dispatch>; table: Table; categoryState: RendererVideo[]; } const StorageFilterToggle = (props: IProps) => { const { appState, setAppState, table, categoryState } = props; const { storageFilter, language } = appState; const hasDisk = categoryState.filter((rv) => !rv.cloud).length > 0; const hasCloud = categoryState.filter((rv) => rv.cloud).length > 0; const setStorageFilter = (storageFilter: StorageFilter) => { if (!storageFilter) { // Don't allow the user to toggle this off. return; } table.toggleAllRowsSelected(false); setAppState((prevState) => ({ ...prevState, selectedVideos: [], storageFilter, })); }; return ( ); }; export default StorageFilterToggle; ================================================ FILE: src/renderer/TagDialog.tsx ================================================ import { RendererVideo } from 'main/types'; import { Dispatch, SetStateAction, useState } from 'react'; import { getLocalePhrase } from 'localisation/translations'; import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from './components/Dialog/Dialog'; import { Button } from './components/Button/Button'; import { Language, Phrase } from 'localisation/phrases'; import { Textarea } from './components/TextArea/textarea'; interface IProps { tag: string; videos: RendererVideo[]; setVideoState: Dispatch>; children: React.ReactNode; language: Language; } export default function TagDialog(props: IProps) { const { videos, setVideoState, children, language, tag } = props; const [open, setOpen] = useState(false); const [innerTag, setInnerTag] = useState(tag); const handleOpenChange = (isOpen: boolean) => { setOpen(isOpen); setInnerTag(tag); }; const saveTag = (newTag: string) => { const toProtectDisk = videos.filter((v) => !v.cloud); const toProtectCloud = videos.filter((v) => v.cloud); window.electron.ipcRenderer.sendMessage('videoButtonDisk', [ 'tag', newTag, toProtectDisk, ]); window.electron.ipcRenderer.sendMessage('videoButtonCloud', [ 'tag', newTag, toProtectCloud, ]); setVideoState((prev) => { const state = [...prev]; state.forEach((rv) => { // A video is uniquely identified by its name and storage type. const match = videos.find( (v) => v.videoName === rv.videoName && v.cloud === rv.cloud, ); if (match) { rv.tag = newTag; } }); return state; }); }; const clearTag = (event: React.MouseEvent) => { event.stopPropagation(); saveTag(''); }; const onSave = (event: React.MouseEvent) => { event.stopPropagation(); saveTag(innerTag ?? ''); }; return ( {children} {getLocalePhrase(language, Phrase.AddADescription)}