├── docs ├── assets │ ├── setting.json │ ├── icons │ │ ├── flect.png │ │ ├── home.svg │ │ ├── linkedin.svg │ │ ├── twitter.svg │ │ ├── file-text.svg │ │ └── github.svg │ └── img │ │ └── coffee.png ├── 168807ed5cb27992bea8.js.LICENSE.txt ├── index.html ├── index.js.LICENSE.txt └── 168807ed5cb27992bea8.js ├── frontend ├── public │ ├── assets │ │ ├── setting.json │ │ ├── icons │ │ │ ├── flect.png │ │ │ ├── home.svg │ │ │ ├── linkedin.svg │ │ │ ├── twitter.svg │ │ │ ├── file-text.svg │ │ │ └── github.svg │ │ └── img │ │ │ └── coffee.png │ ├── index.html │ └── coi-serviceworker.js └── src │ ├── const.tsx │ ├── 100_components │ ├── 001_css │ │ ├── 041_RightSidebarItems.css │ │ ├── 030_Body.css │ │ ├── 020_Header.css │ │ ├── 101_RotatedButton.css │ │ ├── 010_Frame.css │ │ ├── 001_App.css │ │ └── 040_RightSidebar.css │ ├── 002_parts │ │ ├── 200_Body.tsx │ │ ├── 102_RightSidebarButton.tsx │ │ ├── 330_Links.tsx │ │ ├── 100_Header.tsx │ │ ├── 101_HeaderButton.tsx │ │ ├── 321_DeviceSelector.tsx │ │ ├── 320_MixController.tsx │ │ ├── 300_RightSidebar.tsx │ │ └── 310_ScreenRecorderController.tsx │ ├── 100_Frame.tsx │ └── 003_hooks │ │ ├── useFileInput.tsx │ │ └── useStateControlCheckbox.tsx │ ├── 001_clients_and_managers │ ├── 000_ApplicationSettingLoader.ts │ └── 001_DeviceManager.ts │ ├── 002_hooks │ ├── 010_useAudioRoot.ts │ ├── 000_useApplicationSettingManager.ts │ ├── 001_useDeviceManager.ts │ └── 100_useFrontendManager.ts │ ├── App.tsx │ ├── 003_provider │ ├── 003_AppStateProvider.tsx │ ├── 002_AppRootStateProvider.tsx │ └── 001_AppSettingProvider.tsx │ └── index.tsx ├── .gitignore ├── .prettierrc ├── postcss.config.js ├── .vscode └── settings.json ├── .eslintrc.js ├── Readme.md ├── tsconfig.frontend.json ├── package.json └── webpack.frontend.config.js /docs/assets/setting.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_title": "screen-recorder" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/public/assets/setting.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_title": "screen-recorder" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | *# 4 | *~ 5 | sync.sh 6 | mod_build.sh 7 | 8 | frontend_ -------------------------------------------------------------------------------- /docs/168807ed5cb27992bea8.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! coi-serviceworker v0.1.6 - Guido Zuidhof, licensed under MIT */ 2 | -------------------------------------------------------------------------------- /docs/assets/icons/flect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w-okada/screen-recorder-ts/HEAD/docs/assets/icons/flect.png -------------------------------------------------------------------------------- /docs/assets/img/coffee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w-okada/screen-recorder-ts/HEAD/docs/assets/img/coffee.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false, 4 | "semi": true, 5 | "printWidth": 360 6 | } 7 | -------------------------------------------------------------------------------- /frontend/public/assets/icons/flect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w-okada/screen-recorder-ts/HEAD/frontend/public/assets/icons/flect.png -------------------------------------------------------------------------------- /frontend/public/assets/img/coffee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w-okada/screen-recorder-ts/HEAD/frontend/public/assets/img/coffee.png -------------------------------------------------------------------------------- /frontend/src/const.tsx: -------------------------------------------------------------------------------- 1 | export const TARGET_SCREEN_VIDEO_ID = "target-screen-video" 2 | export const RECORDING_CANVAS_ID = "target-screen-canvas" -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | "postcss-nested": {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/src/100_components/001_css/041_RightSidebarItems.css: -------------------------------------------------------------------------------- 1 | .sidebar-content-item { 2 | .sidebar-content-item-video { 3 | width: 100%; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.css": "postcss" 4 | }, 5 | "workbench.colorCustomizations": { 6 | "tab.activeBackground": "#65952acc" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/assets/icons/home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | Screen Capture with ffmpeg-wasm
-------------------------------------------------------------------------------- /frontend/public/assets/icons/home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/icons/linkedin.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/icons/twitter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/assets/icons/linkedin.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/assets/icons/twitter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/100_components/001_css/030_Body.css: -------------------------------------------------------------------------------- 1 | .body-content { 2 | background: var(--company-color1); 3 | width: 100%; 4 | height: 100%; 5 | } 6 | .body { 7 | background: var(--company-color1); 8 | width: 100%; 9 | height: 100%; 10 | } 11 | .body-main-canvas { 12 | width: 100%; 13 | height: 100%; 14 | object-fit: contain; 15 | } 16 | .body-main-video { 17 | width: 100%; 18 | height: 100%; 19 | object-fit: contain; 20 | } 21 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Screen Capture with ffmpeg-wasm 6 | 7 | 8 | 9 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/001_clients_and_managers/000_ApplicationSettingLoader.ts: -------------------------------------------------------------------------------- 1 | 2 | export type ApplicationSetting = 3 | { 4 | "app_title": string, 5 | } 6 | 7 | 8 | export const fetchApplicationSetting = async (): Promise => { 9 | const url = `./assets/setting.json` 10 | const res = await fetch(url, { 11 | method: "GET" 12 | }); 13 | const setting = await res.json() as ApplicationSetting 14 | return setting; 15 | } 16 | -------------------------------------------------------------------------------- /docs/assets/icons/file-text.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/assets/icons/file-text.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/icons/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended"], 8 | parser: "@typescript-eslint/parser", 9 | parserOptions: { 10 | ecmaFeatures: { 11 | jsx: true, 12 | }, 13 | ecmaVersion: 13, 14 | sourceType: "module", 15 | }, 16 | plugins: ["react", "@typescript-eslint"], 17 | rules: {}, 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/public/assets/icons/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/002_hooks/010_useAudioRoot.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react" 2 | 3 | export type AudioRootState = { 4 | audioContext: AudioContext 5 | } 6 | export type AudioRootStateAndMethod = AudioRootState & { 7 | dummy: () => void 8 | } 9 | 10 | export const useAudioRoot = (): AudioRootStateAndMethod => { 11 | const audioContext = useMemo(() => { 12 | const ctx = new AudioContext() 13 | return ctx 14 | }, []) 15 | 16 | 17 | return { 18 | audioContext, 19 | dummy: () => { } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import "./100_components/001_css/001_App.css"; 3 | import { Frame } from "./100_components/100_Frame"; 4 | 5 | import { library } from "@fortawesome/fontawesome-svg-core"; 6 | import { fas } from "@fortawesome/free-solid-svg-icons"; 7 | import { far } from "@fortawesome/free-regular-svg-icons"; 8 | import { fab } from "@fortawesome/free-brands-svg-icons"; 9 | 10 | library.add(fas, far, fab); 11 | 12 | const App = () => { 13 | const frame = useMemo(() => { 14 | return ; 15 | }, []); 16 | 17 | return
{frame}
; 18 | }; 19 | 20 | export default App; 21 | -------------------------------------------------------------------------------- /docs/index.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /** 2 | * @license React 3 | * react-dom.production.min.js 4 | * 5 | * Copyright (c) Facebook, Inc. and its affiliates. 6 | * 7 | * This source code is licensed under the MIT license found in the 8 | * LICENSE file in the root directory of this source tree. 9 | */ 10 | 11 | /** 12 | * @license React 13 | * react.production.min.js 14 | * 15 | * Copyright (c) Facebook, Inc. and its affiliates. 16 | * 17 | * This source code is licensed under the MIT license found in the 18 | * LICENSE file in the root directory of this source tree. 19 | */ 20 | 21 | /** 22 | * @license React 23 | * scheduler.production.min.js 24 | * 25 | * Copyright (c) Facebook, Inc. and its affiliates. 26 | * 27 | * This source code is licensed under the MIT license found in the 28 | * LICENSE file in the root directory of this source tree. 29 | */ 30 | -------------------------------------------------------------------------------- /frontend/src/100_components/002_parts/200_Body.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | // import { RECORDING_CANVAS_ID } from "../../const"; 3 | import { TARGET_SCREEN_VIDEO_ID } from "../../const"; 4 | // import { useAppSetting } from "../../003_provider/001_AppSettingProvider"; 5 | // import { useAppState } from "../../003_provider/003_AppStateProvider"; 6 | 7 | export const Body = () => { 8 | // const mainCanvas = useMemo(() => { 9 | // return 10 | // }, []) 11 | 12 | const mainVideo = useMemo(() => { 13 | return 14 | }, []) 15 | 16 | return ( 17 |
18 | {/* {mainCanvas} */} 19 | {mainVideo} 20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /frontend/src/100_components/002_parts/102_RightSidebarButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { useAppState } from "../../003_provider/003_AppStateProvider"; 3 | import { AnimationTypes, HeaderButton, HeaderButtonProps } from "./101_HeaderButton"; 4 | 5 | export const RightSidebarButton = () => { 6 | const { frontendManagerState } = useAppState(); 7 | const rightSidebarButtonProps: HeaderButtonProps = { 8 | stateControlCheckbox: frontendManagerState.stateControls.openRightSidebarCheckbox, 9 | tooltip: "open/close", 10 | onIcon: ["fas", "angles-right"], 11 | offIcon: ["fas", "angles-right"], 12 | animation: AnimationTypes.spinner, 13 | }; 14 | const rightSidebarButton = useMemo(() => { 15 | return ; 16 | }, []); 17 | return rightSidebarButton; 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/src/100_components/001_css/020_Header.css: -------------------------------------------------------------------------------- 1 | /* Header */ 2 | .header { 3 | height: 100%; 4 | width: 100vw; 5 | background: #ffe; 6 | display: flex; 7 | justify-content: space-between; 8 | } 9 | .header-application-title-container { 10 | display: flex; 11 | } 12 | .header-application-title-logo { 13 | width: var(--header-height); 14 | height: var(--header-height); 15 | padding: 2px 2px 2px 2px; 16 | margin: 0px 2px 0px 5px; 17 | } 18 | .header-application-title-text { 19 | font-weight: 600; 20 | margin: 0px 2px 0px 2px; 21 | } 22 | 23 | .header-device-selector-container { 24 | margin: 0 10px 0 0; 25 | display: flex; 26 | } 27 | .header-device-selector-text { 28 | margin: 0px 2px 0px 10px; 29 | } 30 | 31 | .header-button-container { 32 | display: flex; 33 | } 34 | 35 | .header-button-link { 36 | } 37 | .header-button-spacer { 38 | width: 1rem; 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/100_components/002_parts/330_Links.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const Links = () => { 4 | 5 | return ( 6 |
7 |
8 | 9 |
Simple Movie Editor
10 | 11 |
12 |
13 | 14 |
Realtime Voice Conversion
15 | 16 |
17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Screen Recorder 2 | 3 | This software records the PC's screen only with your browser. 4 | 5 | No addtional application is required! 6 | 7 | Chrome is supported in both Windows and MacOS. Safari is not supported. 8 | 9 | ![ezgif-1-e406039666](https://user-images.githubusercontent.com/48346627/195676741-faffc321-1cc2-4391-81d3-cfae38d26045.gif) 10 | 11 | 12 | # Demo 13 | 14 | https://w-okada.github.io/screen-recorder-ts/ 15 | 16 | # Usage 17 | ## Main Usage 18 | (1) push choose window button and select windows 19 | 20 | (2) push start to rec. 21 | 22 | (3) when you want to stop rec, push the button. 23 | 24 | ## Other Usage 25 | You can merge your voice from your microphone. 26 | 27 | (1) check UseMic checkbox. 28 | 29 | (2) select your microphone device. 30 | 31 | (3) then you can record your screen by the same operation as Main Usage. 32 | 33 | (4) If you want to change volume, set the gain with Audio Gain and Mic Gain slidebar. 34 | -------------------------------------------------------------------------------- /frontend/src/100_components/002_parts/100_Header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { RightSidebarButton } from "./102_RightSidebarButton"; 3 | 4 | export const Header = () => { 5 | return ( 6 |
7 |
8 | 9 |
Screen Recorder
10 |
11 | 12 |
13 | 14 | 15 | 16 |
17 | 18 |
19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/src/003_provider/003_AppStateProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { ReactNode } from "react"; 3 | import { FrontendManagerStateAndMethod, useFrontendManager } from "../002_hooks/100_useFrontendManager"; 4 | 5 | type Props = { 6 | children: ReactNode; 7 | }; 8 | 9 | interface AppStateValue { 10 | frontendManagerState: FrontendManagerStateAndMethod; 11 | } 12 | 13 | const AppStateContext = React.createContext(null); 14 | export const useAppState = (): AppStateValue => { 15 | const state = useContext(AppStateContext); 16 | if (!state) { 17 | throw new Error("useAppState must be used within AppStateProvider"); 18 | } 19 | return state; 20 | }; 21 | 22 | export const AppStateProvider = ({ children }: Props) => { 23 | const frontendManagerState = useFrontendManager(); 24 | const providerValue = { 25 | frontendManagerState 26 | }; 27 | 28 | return {children}; 29 | }; 30 | -------------------------------------------------------------------------------- /frontend/src/003_provider/002_AppRootStateProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { ReactNode } from "react"; 3 | import { AudioRootStateAndMethod, useAudioRoot } from "../002_hooks/010_useAudioRoot"; 4 | 5 | type Props = { 6 | children: ReactNode; 7 | }; 8 | 9 | type AppRootStateValue = { 10 | audioRootState: AudioRootStateAndMethod; 11 | }; 12 | 13 | const AppRootStateContext = React.createContext(null); 14 | export const useAppRootState = (): AppRootStateValue => { 15 | const state = useContext(AppRootStateContext); 16 | if (!state) { 17 | throw new Error("useAppRootState must be used within AppRootStateProvider"); 18 | } 19 | return state; 20 | }; 21 | 22 | export const AppRootStateProvider = ({ children }: Props) => { 23 | const audioRootState = useAudioRoot(); 24 | const providerValue = { 25 | audioRootState, 26 | }; 27 | 28 | return {children} ; 29 | }; 30 | -------------------------------------------------------------------------------- /tsconfig.frontend.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "jsx": "react", 5 | 6 | /* ファイル名の大文字小文字を区別 */ 7 | "forceConsistentCasingInFileNames": true, 8 | 9 | /* 型チェック関係のオプション */ 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | 17 | /* Module解決方法 */ 18 | "moduleResolution": "node", 19 | "esModuleInterop": true, 20 | "isolatedModules": true, 21 | "allowSyntheticDefaultImports": true, 22 | 23 | // /* 型チェックだけさせたいので出力なし */ 24 | // "noEmit": true, 25 | /* For avoid WebGL2 error */ 26 | /* https://stackoverflow.com/questions/52846622/error-ts2430-interface-webglrenderingcontext-incorrectly-extends-interface-w */ 27 | "skipLibCheck": true 28 | }, 29 | /* tscコマンドで読み込むファイルを指定 */ 30 | "include": ["frontend/src/**/*.ts", "frontend/src/**/*.tsx"], 31 | "exclude": ["node_modules"] 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/100_components/100_Frame.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useAppState } from "../003_provider/003_AppStateProvider"; 3 | import { Header } from "./002_parts/100_Header"; 4 | import { Body } from "./002_parts/200_Body"; 5 | import { RightSidebar } from "./002_parts/300_RightSidebar"; 6 | 7 | export const Frame = () => { 8 | const { frontendManagerState } = useAppState(); 9 | useEffect(() => { 10 | frontendManagerState.stateControls.openRightSidebarCheckbox.updateState(true); 11 | }, []); 12 | return ( 13 | <> 14 |
15 |
16 |
17 | {frontendManagerState.stateControls.openRightSidebarCheckbox.trigger} 18 |
19 | 20 |
21 | {frontendManagerState.stateControls.openRightSidebarCheckbox.trigger} 22 |
23 | 24 |
25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /frontend/src/003_provider/001_AppSettingProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { ReactNode } from "react"; 3 | import { ApplicationSettingManagerStateAndMethod, useApplicationSettingManager } from "../002_hooks/000_useApplicationSettingManager"; 4 | import { DeviceManagerStateAndMethod, useDeviceManager } from "../002_hooks/001_useDeviceManager"; 5 | 6 | type Props = { 7 | children: ReactNode; 8 | }; 9 | 10 | type AppSettingValue = { 11 | applicationSettingState: ApplicationSettingManagerStateAndMethod; 12 | deviceManagerState: DeviceManagerStateAndMethod; 13 | }; 14 | 15 | const AppSettingContext = React.createContext(null); 16 | export const useAppSetting = (): AppSettingValue => { 17 | const state = useContext(AppSettingContext); 18 | if (!state) { 19 | throw new Error("useAppSetting must be used within AppSettingProvider"); 20 | } 21 | return state; 22 | }; 23 | 24 | export const AppSettingProvider = ({ children }: Props) => { 25 | const applicationSettingState = useApplicationSettingManager(); 26 | const deviceManagerState = useDeviceManager(); 27 | 28 | const providerValue = { 29 | applicationSettingState, 30 | deviceManagerState, 31 | }; 32 | 33 | return {children} ; 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/src/002_hooks/000_useApplicationSettingManager.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import { ApplicationSetting, fetchApplicationSetting } from "../001_clients_and_managers/000_ApplicationSettingLoader" 3 | 4 | export type ApplicationSettingManagerStateAndMethod = { 5 | applicationSetting: ApplicationSetting | null 6 | } 7 | const LOCAL_STORAGE_PREFIX = location.pathname 8 | const LOCAL_STORAGE_APPLICATION_SETTING = `${LOCAL_STORAGE_PREFIX}_applicationSetting` 9 | 10 | export const useApplicationSettingManager = (): ApplicationSettingManagerStateAndMethod => { 11 | const [applicationSetting, setApplicationSetting] = useState(null) 12 | 13 | /** (1) Initialize Setting */ 14 | /** (1-1) Load from localstorage */ 15 | const loadApplicationSetting = async () => { 16 | if (localStorage[LOCAL_STORAGE_APPLICATION_SETTING]) { 17 | const applicationSetting = JSON.parse(localStorage[LOCAL_STORAGE_APPLICATION_SETTING]) as ApplicationSetting 18 | console.log("Load AppStteing from Local Storage", applicationSetting) 19 | setApplicationSetting({ ...applicationSetting }) 20 | } else { 21 | const applicationSetting = await fetchApplicationSetting() 22 | console.log("Load AppStteing from Server", applicationSetting) 23 | setApplicationSetting({ ...applicationSetting }) 24 | } 25 | } 26 | useEffect(() => { 27 | loadApplicationSetting() 28 | }, []) 29 | 30 | return { 31 | applicationSetting, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/100_components/002_parts/101_HeaderButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconName, IconPrefix } from "@fortawesome/free-regular-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import React, { useMemo } from "react"; 4 | import { StateControlCheckbox } from "../003_hooks/useStateControlCheckbox"; 5 | 6 | export const AnimationTypes = { 7 | colored: "colored", 8 | spinner: "spinner", 9 | } as const; 10 | export type AnimationTypes = typeof AnimationTypes[keyof typeof AnimationTypes]; 11 | 12 | export type HeaderButtonProps = { 13 | stateControlCheckbox: StateControlCheckbox; 14 | tooltip: string; 15 | onIcon: [IconPrefix, IconName]; 16 | offIcon: [IconPrefix, IconName]; 17 | animation: AnimationTypes; 18 | tooltipClass?: string; 19 | }; 20 | 21 | export const HeaderButton = (props: HeaderButtonProps) => { 22 | const headerButton = useMemo(() => { 23 | const tooltipClass = props.tooltipClass || "tooltip-bottom"; 24 | return ( 25 |
26 | {props.stateControlCheckbox.trigger} 27 | 33 |
34 | ); 35 | }, []); 36 | return headerButton; 37 | }; 38 | -------------------------------------------------------------------------------- /frontend/src/100_components/003_hooks/useFileInput.tsx: -------------------------------------------------------------------------------- 1 | // Ver: 2022/10/10 2 | 3 | export type FileInputState = { 4 | dataURL: string; 5 | error: boolean; 6 | message: string; 7 | }; 8 | export type FileInputStateAndMethod = FileInputState & { 9 | // click: () => void; 10 | click: () => Promise; 11 | }; 12 | 13 | export const useFileInput = () => { 14 | const click = async (regex: string) => { 15 | const fileInput = document.createElement("input"); 16 | fileInput.type = "file"; 17 | const p = new Promise((resolve, reject) => { 18 | fileInput.onchange = (e) => { 19 | // @ts-ignore 20 | console.log("file select", e.target.files[0].type); 21 | // @ts-ignore 22 | const type = e.target.files[0].type 23 | const reader = new FileReader(); 24 | reader.onload = () => { 25 | // @ts-ignore 26 | if (regex != "" && !type.match(regex)) { 27 | //@ts-ignore 28 | reject(`not target file type ${type}`); 29 | } 30 | console.log("load data", reader.result as string); 31 | resolve(reader.result as string); 32 | }; 33 | // @ts-ignore 34 | reader.readAsDataURL(e.target.files[0]); 35 | }; 36 | fileInput.click(); 37 | }); 38 | 39 | try { 40 | const url = await p; 41 | return url; 42 | } catch (exception) { 43 | throw exception 44 | } 45 | }; 46 | return { click }; 47 | }; 48 | -------------------------------------------------------------------------------- /frontend/src/100_components/001_css/101_RotatedButton.css: -------------------------------------------------------------------------------- 1 | /* 前提条件 */ 2 | 3 | .rotate-button-container { 4 | height: var(--header-height); 5 | width: var(--header-height); 6 | position: relative; 7 | } 8 | .rotate-button { 9 | display: none; 10 | } 11 | .rotate-button ~ .rotate-lable { 12 | padding: 2px; 13 | position: absolute; 14 | transition: all 0.3s; 15 | cursor: pointer; 16 | height: var(--header-height); 17 | width: var(--header-height); 18 | } 19 | .rotate-button ~ .rotate-lable > * { 20 | width: 100%; 21 | height: 100%; 22 | float: left; 23 | transition: all 0.3s; 24 | .spin-on { 25 | width: 100%; 26 | height: 100%; 27 | display: none; 28 | } 29 | .spin-off { 30 | width: 100%; 31 | height: 100%; 32 | display: blcok; 33 | } 34 | } 35 | .rotate-button ~ .rotate-lable > .colored { 36 | color: rgba(200, 200, 200, 0.8); 37 | background: rgba(0, 0, 0, 1); 38 | transition: all 0.3s; 39 | .spin-on { 40 | display: none; 41 | } 42 | .spin-off { 43 | display: block; 44 | } 45 | } 46 | .rotate-button:checked ~ .rotate-lable > .colored { 47 | color: rgba(50, 240, 50, 0.8); 48 | background: rgba(60, 60, 60, 1); 49 | transition: all 0.3s; 50 | .spin-on { 51 | display: block; 52 | } 53 | .spin-off { 54 | display: none; 55 | } 56 | } 57 | 58 | .rotate-button:checked ~ .rotate-lable > .spinner { 59 | width: 100%; 60 | height: 100%; 61 | transform: rotate(-180deg); 62 | transition: all 0.3s; 63 | box-sizing: border-box; 64 | .spin-on { 65 | display: block; 66 | } 67 | .spin-off { 68 | display: none; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /frontend/src/001_clients_and_managers/001_DeviceManager.ts: -------------------------------------------------------------------------------- 1 | export type DeviceInfo = { 2 | label: string, 3 | deviceId: string, 4 | } 5 | export type UpdateListener = { 6 | update: () => void 7 | } 8 | 9 | ////////////////////////////// 10 | // Class 11 | ////////////////////////////// 12 | export class DeviceManager { 13 | realAudioInputDevices: DeviceInfo[] = [] 14 | realVideoInputDevices: DeviceInfo[] = [] 15 | realAudioOutputDevices: DeviceInfo[] = [] 16 | updateListener: UpdateListener = { 17 | update: () => { console.log("update devices") } 18 | } 19 | setUpdateListener = (updateListener: UpdateListener) => { 20 | this.updateListener = updateListener 21 | } 22 | 23 | 24 | // (A) Device List生成 25 | reloadDevices = async () => { 26 | try { 27 | await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); 28 | } catch (e) { 29 | console.warn("Enumerate device error::", e) 30 | } 31 | const mediaDeviceInfos = await navigator.mediaDevices.enumerateDevices(); 32 | 33 | this.realAudioInputDevices = mediaDeviceInfos.filter(x => { return x.kind === "audioinput" }).map(x => { return { label: x.label, deviceId: x.deviceId } }) 34 | this.realVideoInputDevices = mediaDeviceInfos.filter(x => { return x.kind === "videoinput" }).map(x => { return { label: x.label, deviceId: x.deviceId } }) 35 | this.realAudioOutputDevices = mediaDeviceInfos.filter(x => { return x.kind === "audiooutput" }).map(x => { return { label: x.label, deviceId: x.deviceId } }) 36 | 37 | this.realAudioInputDevices.push({ label: "none", deviceId: "none" }) 38 | this.realVideoInputDevices.push({ label: "none", deviceId: "none" }) 39 | 40 | 41 | this.updateListener.update() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /docs/168807ed5cb27992bea8.js: -------------------------------------------------------------------------------- 1 | /*! For license information please see 168807ed5cb27992bea8.js.LICENSE.txt */ 2 | let coepCredentialless=!1;"undefined"==typeof window?(self.addEventListener("install",(()=>self.skipWaiting())),self.addEventListener("activate",(e=>e.waitUntil(self.clients.claim()))),self.addEventListener("message",(e=>{e.data&&("deregister"===e.data.type?self.registration.unregister().then((()=>self.clients.matchAll())).then((e=>{e.forEach((e=>e.navigate(e.url)))})):"coepCredentialless"===e.data.type&&(coepCredentialless=e.data.value))})),self.addEventListener("fetch",(function(e){const r=e.request;if("only-if-cached"===r.cache&&"same-origin"!==r.mode)return;const t=coepCredentialless&&"no-cors"===r.mode?new Request(r,{credentials:"omit"}):r;e.respondWith(fetch(t).then((e=>{if(0===e.status)return e;const r=new Headers(e.headers);return r.set("Cross-Origin-Embedder-Policy",coepCredentialless?"credentialless":"require-corp"),r.set("Cross-Origin-Opener-Policy","same-origin"),new Response(e.body,{status:e.status,statusText:e.statusText,headers:r})})).catch((e=>console.error(e))))}))):(()=>{const e={shouldRegister:()=>!0,shouldDeregister:()=>!1,coepCredentialless:()=>!1,doReload:()=>window.location.reload(),quiet:!1,...window.coi},r=navigator;r.serviceWorker&&r.serviceWorker.controller&&(r.serviceWorker.controller.postMessage({type:"coepCredentialless",value:e.coepCredentialless()}),e.shouldDeregister()&&r.serviceWorker.controller.postMessage({type:"deregister"})),!1===window.crossOriginIsolated&&e.shouldRegister()&&(window.isSecureContext?r.serviceWorker&&r.serviceWorker.register(window.document.currentScript.src).then((t=>{!e.quiet&&console.log("COOP/COEP Service Worker registered",t.scope),t.addEventListener("updatefound",(()=>{!e.quiet&&console.log("Reloading page to make use of updated COOP/COEP Service Worker."),e.doReload()})),t.active&&!r.serviceWorker.controller&&(!e.quiet&&console.log("Reloading page to make use of COOP/COEP Service Worker."),e.doReload())}),(r=>{!e.quiet&&console.error("COOP/COEP Service Worker failed to register:",r)})):!e.quiet&&console.log("COOP/COEP Service Worker not registered, a secure context is required."))})(); -------------------------------------------------------------------------------- /frontend/src/100_components/001_css/010_Frame.css: -------------------------------------------------------------------------------- 1 | /* Header */ 2 | .header-container { 3 | position: fixed; 4 | top: 0px; 5 | left: 0px; 6 | height: var(--header-height); 7 | width: 100vw; 8 | } 9 | /* Body*/ 10 | .body-container { 11 | position: fixed; 12 | top: var(--header-height); 13 | left: 0px; 14 | height: calc(100vh - var(--header-height)); 15 | background: linear-gradient(45deg, var(--company-color1) 0, 5%, var(--company-color2) 5% 10%, var(--company-color3) 10% 80%, var(--company-color1) 80% 85%, var(--company-color2) 85% 100%); 16 | } 17 | 18 | .open-right-sidebar-checkbox:checked + .body-container { 19 | width: calc(100vw - var(--right-sidebar-width)); 20 | transition: all var(--sidebar-transition-time) var(--sidebar-transition-animation); 21 | } 22 | .open-right-sidebar-checkbox + .body-container { 23 | width: calc(100vw); 24 | transition: all var(--sidebar-transition-time) var(--sidebar-transition-animation); 25 | } 26 | 27 | /* RightSidebar*/ 28 | .right-sidebar-container { 29 | position: fixed; 30 | top: var(--header-height); 31 | height: calc(100vh - var(--header-height)); 32 | display: flex; 33 | flex-direction: column; 34 | width: var(--right-sidebar-width); 35 | background: var(--company-color3); 36 | z-index: 100; 37 | } 38 | 39 | .right-sidebar-container:before { 40 | content: ""; 41 | position: absolute; 42 | height: 100vh; 43 | width: var(--right-sidebar-width); 44 | background: var(--company-color2-alpha); 45 | z-index: -1; 46 | } 47 | .right-sidebar-container:after { 48 | content: ""; 49 | position: absolute; 50 | height: 100vh; 51 | width: var(--right-sidebar-width); 52 | background: var(--company-color1-alpha); 53 | clip-path: ellipse(158% 41% at 60% 30%); 54 | z-index: -1; 55 | } 56 | .open-right-sidebar-checkbox:checked + .right-sidebar-container { 57 | right: 0; 58 | transition: all var(--sidebar-transition-time) var(--sidebar-transition-animation); 59 | } 60 | .open-right-sidebar-checkbox + .right-sidebar-container { 61 | right: calc(-1 * var(--right-sidebar-width)); 62 | transition: all var(--sidebar-transition-time) var(--sidebar-transition-animation); 63 | } 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "screen-recorder-ts", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "backend/dist/index.js", 6 | "scripts": { 7 | "clean": "rimraf docs/*", 8 | "webpack": "webpack --config webpack.frontend.config.js", 9 | "build": "run-s clean webpack", 10 | "watch": "webpack-dev-server --config webpack.frontend.config.js --host 0.0.0.0", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "@babel/plugin-transform-runtime": "^7.21.0", 18 | "@babel/preset-env": "^7.20.2", 19 | "@babel/preset-react": "^7.18.6", 20 | "@babel/preset-typescript": "^7.21.0", 21 | "@types/connect-livereload": "^0.6.0", 22 | "@types/express": "^4.17.17", 23 | "@types/livereload": "^0.9.2", 24 | "@types/node": "^18.14.2", 25 | "@types/react": "^18.0.28", 26 | "@types/react-dom": "^18.0.11", 27 | "autoprefixer": "^10.4.13", 28 | "babel-loader": "^9.1.2", 29 | "copy-webpack-plugin": "^11.0.0", 30 | "css-loader": "^6.7.3", 31 | "html-loader": "^4.2.0", 32 | "html-webpack-plugin": "^5.5.0", 33 | "npm-run-all": "^4.1.5", 34 | "postcss-loader": "^7.0.2", 35 | "postcss-nested": "^6.0.1", 36 | "rimraf": "^4.1.2", 37 | "style-loader": "^3.3.1", 38 | "ts-loader": "^9.4.2", 39 | "typescript": "^4.9.5", 40 | "webpack": "^5.75.0", 41 | "webpack-cli": "^5.0.1", 42 | "webpack-dev-server": "^4.11.1" 43 | }, 44 | "dependencies": { 45 | "@ffmpeg/core": "^0.11.0", 46 | "@ffmpeg/ffmpeg": "^0.11.6", 47 | "@fortawesome/fontawesome": "^1.1.8", 48 | "@fortawesome/fontawesome-free-solid": "^5.0.13", 49 | "@fortawesome/fontawesome-svg-core": "^6.3.0", 50 | "@fortawesome/free-brands-svg-icons": "^6.3.0", 51 | "@fortawesome/free-regular-svg-icons": "^6.3.0", 52 | "@fortawesome/free-solid-svg-icons": "^6.3.0", 53 | "@fortawesome/react-fontawesome": "^0.2.0", 54 | "react": "^18.2.0", 55 | "react-dom": "^18.2.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /webpack.frontend.config.js: -------------------------------------------------------------------------------- 1 | /* eslint @typescript-eslint/no-var-requires: "off" */ 2 | const path = require("path"); 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 4 | const CopyPlugin = require("copy-webpack-plugin"); 5 | 6 | module.exports = { 7 | // mode: "development", 8 | mode: "production", 9 | entry: path.resolve(__dirname, "frontend/src/index.tsx"), 10 | output: { 11 | path: path.resolve(__dirname, "docs"), 12 | filename: "index.js", 13 | }, 14 | resolve: { 15 | modules: [path.resolve(__dirname, "node_modules")], 16 | extensions: [".ts", ".tsx", ".js"], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: [/\.ts$/, /\.tsx$/], 22 | use: [ 23 | { 24 | loader: "ts-loader", 25 | options: { 26 | transpileOnly: true, 27 | configFile: "tsconfig.frontend.json", 28 | }, 29 | }, 30 | ], 31 | }, 32 | { 33 | test: /\.css$/, 34 | use: ["style-loader", { loader: "css-loader", options: { importLoaders: 1 } }, "postcss-loader"], 35 | }, 36 | { 37 | test: /\.html$/, 38 | loader: "html-loader", 39 | }, 40 | ], 41 | }, 42 | plugins: [ 43 | new HtmlWebpackPlugin({ 44 | template: path.resolve(__dirname, "frontend/public/index.html"), 45 | filename: "./index.html", 46 | }), 47 | new CopyPlugin({ 48 | patterns: [{ from: "frontend/public/assets", to: "assets" }], 49 | }), 50 | ], 51 | optimization: { 52 | // workaround for the issue on bundling js from html with html-loader (coi-serviceworker.js) 53 | // https://stackoverflow.com/questions/67361319/htmlwebpackplugin-wrong-hash-for-script-file-is-injected-into-html-file 54 | // https://webpack.js.org/configuration/optimization/#optimizationrealcontenthash 55 | realContentHash: false, 56 | }, 57 | devServer: { 58 | static: { 59 | directory: path.join(__dirname, "docs"), 60 | }, 61 | headers: { 62 | "Cross-Origin-Opener-Policy": "same-origin", 63 | "Cross-Origin-Embedder-Policy": "require-corp", 64 | }, 65 | client: { 66 | overlay: { 67 | errors: false, 68 | warnings: false, 69 | }, 70 | }, 71 | host: "0.0.0.0", 72 | https: true, 73 | }, 74 | }; 75 | -------------------------------------------------------------------------------- /frontend/src/100_components/002_parts/321_DeviceSelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, useMemo } from "react"; 2 | import { useAppSetting } from "../../003_provider/001_AppSettingProvider"; 3 | 4 | export const DeviceType = { 5 | audioinput: "audioinput", 6 | videoinput: "videoinput", 7 | audiooutput: "audiooutput", 8 | } as const; 9 | export type DeviceType = typeof DeviceType[keyof typeof DeviceType]; 10 | 11 | export type DeviceManagerProps = { 12 | deviceType: DeviceType; 13 | }; 14 | 15 | export const DeviceSelector = (props: DeviceManagerProps) => { 16 | const { deviceManagerState } = useAppSetting(); 17 | 18 | const targetDevices = useMemo(() => { 19 | if (props.deviceType === "audioinput") { 20 | return deviceManagerState.audioInputDevices; 21 | } else if (props.deviceType === "videoinput") { 22 | return deviceManagerState.videoInputDevices; 23 | } else { 24 | return deviceManagerState.audioOutputDevices; 25 | } 26 | }, [deviceManagerState.audioInputDevices, deviceManagerState.videoInputDevices, deviceManagerState.audioOutputDevices]); 27 | 28 | const currentValue = useMemo(() => { 29 | if (props.deviceType === "audioinput") { 30 | return deviceManagerState.audioInputDeviceId || "none"; 31 | } else if (props.deviceType === "videoinput") { 32 | return deviceManagerState.videoInputDeviceId || "none"; 33 | } else { 34 | return deviceManagerState.audioOutputDeviceId || "none"; 35 | } 36 | }, [deviceManagerState.audioInputDeviceId, deviceManagerState.videoInputDeviceId, deviceManagerState.audioOutputDeviceId]); 37 | 38 | const setDeviceId = (deviceId: string) => { 39 | if (props.deviceType === "audioinput") { 40 | deviceManagerState.setAudioInputDeviceId(deviceId); 41 | } else if (props.deviceType === "videoinput") { 42 | deviceManagerState.setVideoInputDeviceId(deviceId); 43 | } else { 44 | deviceManagerState.setAudioOutputDeviceId(deviceId); 45 | } 46 | }; 47 | 48 | const options = useMemo(() => { 49 | return targetDevices.map((x) => { 50 | return ( 51 | 54 | ); 55 | }); 56 | }, [targetDevices]); 57 | 58 | const selector = useMemo(() => { 59 | return ( 60 | 69 | ); 70 | }, [targetDevices, options, currentValue]); 71 | 72 | const Wrapper = () => { 73 | if (targetDevices.length === 0) { 74 | throw new Promise((resolve) => setTimeout(resolve, 1000 * 2)); 75 | } 76 | return selector; 77 | }; 78 | return ( 79 | device loading...}> 80 | 81 | 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /frontend/src/002_hooks/001_useDeviceManager.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react" 2 | import { DeviceInfo, DeviceManager } from "../001_clients_and_managers/001_DeviceManager" 3 | 4 | 5 | 6 | type DeviceManagerState = { 7 | lastUpdateTime: number 8 | audioInputDevices: DeviceInfo[] 9 | videoInputDevices: DeviceInfo[] 10 | audioOutputDevices: DeviceInfo[] 11 | 12 | audioInputDeviceId: string | null 13 | videoInputDeviceId: string | null 14 | audioOutputDeviceId: string | null 15 | } 16 | export type DeviceManagerStateAndMethod = DeviceManagerState & { 17 | reloadDevices: () => Promise 18 | setAudioInputDeviceId: (val: string | null) => void 19 | setVideoInputDeviceId: (val: string | null) => void 20 | setAudioOutputDeviceId: (val: string | null) => void 21 | 22 | } 23 | export const useDeviceManager = (): DeviceManagerStateAndMethod => { 24 | const [lastUpdateTime, setLastUpdateTime] = useState(0) 25 | const [audioInputDeviceId, _setAudioInputDeviceId] = useState(null) 26 | const [videoInputDeviceId, _setVideoInputDeviceId] = useState(null) 27 | const [audioOutputDeviceId, _setAudioOutputDeviceId] = useState(null) 28 | 29 | const deviceManager = useMemo(() => { 30 | const manager = new DeviceManager() 31 | manager.setUpdateListener({ 32 | update: () => { 33 | setLastUpdateTime(new Date().getTime()) 34 | } 35 | }) 36 | manager.reloadDevices() 37 | return manager 38 | }, []) 39 | 40 | // () Enumerate 41 | const reloadDevices = useMemo(() => { 42 | return async () => { 43 | deviceManager.reloadDevices() 44 | } 45 | }, []) 46 | 47 | const setAudioInputDeviceId = async (val: string | null) => { 48 | localStorage.audioInputDevice = val; 49 | _setAudioInputDeviceId(val) 50 | } 51 | useEffect(() => { 52 | const audioInputDeviceId = localStorage.audioInputDevice || null 53 | _setAudioInputDeviceId(audioInputDeviceId) 54 | }, []) 55 | 56 | 57 | const setVideoInputDeviceId = async (val: string | null) => { 58 | localStorage.videoInputDevice = val; 59 | _setVideoInputDeviceId(val) 60 | } 61 | useEffect(() => { 62 | const videoInputDeviceId = localStorage.videoInputDevice || null 63 | _setVideoInputDeviceId(videoInputDeviceId) 64 | }, []) 65 | 66 | const setAudioOutputDeviceId = async (val: string | null) => { 67 | localStorage.audioOutputDevice = val; 68 | _setAudioOutputDeviceId(val) 69 | } 70 | useEffect(() => { 71 | const audioOutputDeviceId = localStorage.audioOutputDevice || null 72 | _setAudioOutputDeviceId(audioOutputDeviceId) 73 | }, []) 74 | 75 | return { 76 | lastUpdateTime, 77 | audioInputDevices: deviceManager.realAudioInputDevices, 78 | videoInputDevices: deviceManager.realVideoInputDevices, 79 | audioOutputDevices: deviceManager.realAudioOutputDevices, 80 | 81 | audioInputDeviceId, 82 | videoInputDeviceId, 83 | audioOutputDeviceId, 84 | reloadDevices, 85 | setAudioInputDeviceId, 86 | setVideoInputDeviceId, 87 | setAudioOutputDeviceId, 88 | } 89 | } -------------------------------------------------------------------------------- /frontend/src/100_components/003_hooks/useStateControlCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useRef } from "react"; 2 | import { useEffect } from "react"; 3 | 4 | export type StateControlCheckbox = { 5 | trigger: JSX.Element; 6 | updateState: (newVal: boolean) => void; 7 | className: string; 8 | }; 9 | 10 | export const useStateControlCheckbox = (className: string, changeCallback?: (newVal: boolean) => void): StateControlCheckbox => { 11 | const currentValForTriggerCallbackRef = useRef(false); 12 | // (4) トリガチェックボックス 13 | const callback = useMemo(() => { 14 | console.log("generate callback function", className); 15 | return (newVal: boolean) => { 16 | if (!changeCallback) { 17 | return; 18 | } 19 | // 値が同じときはスルー (== 初期値(undefined)か、値が違ったのみ発火) 20 | if (currentValForTriggerCallbackRef.current === newVal) { 21 | return; 22 | } 23 | // 初期値(undefined)か、値が違ったのみ発火 24 | currentValForTriggerCallbackRef.current = newVal; 25 | changeCallback(currentValForTriggerCallbackRef.current); 26 | }; 27 | }, []); 28 | const trigger = useMemo(() => { 29 | if (changeCallback) { 30 | return ( 31 | { 36 | callback(e.target.checked); 37 | }} 38 | /> 39 | ); 40 | } else { 41 | return ; 42 | } 43 | }, []); 44 | 45 | useEffect(() => { 46 | const checkboxes = document.querySelectorAll(`.${className}`); 47 | // (1) On/Off同期 48 | checkboxes.forEach((x) => { 49 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 50 | // @ts-ignore 51 | x.onchange = (ev) => { 52 | updateState(ev.target.checked); 53 | }; 54 | }); 55 | // (2) 全エレメントoff 56 | const removers = document.querySelectorAll(`.${className}-remover`); 57 | removers.forEach((x) => { 58 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 59 | // @ts-ignore 60 | x.onclick = (ev) => { 61 | if (ev.target.className.indexOf(`${className}-remover`) > 0) { 62 | updateState(false); 63 | } 64 | }; 65 | }); 66 | }, []); 67 | 68 | // (3) ステート変更 69 | const updateState = useMemo(() => { 70 | return (newVal: boolean) => { 71 | const currentCheckboxes = document.querySelectorAll(`.${className}`); 72 | currentCheckboxes.forEach((y) => { 73 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 74 | // @ts-ignore 75 | y.checked = newVal; 76 | }); 77 | if (changeCallback) { 78 | callback(newVal); 79 | } 80 | }; 81 | }, []); 82 | 83 | return { 84 | trigger, 85 | updateState, 86 | className, 87 | }; 88 | }; 89 | -------------------------------------------------------------------------------- /frontend/src/100_components/002_parts/320_MixController.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { useAppState } from "../../003_provider/003_AppStateProvider"; 3 | import { DeviceSelector } from "./321_DeviceSelector"; 4 | 5 | export const MixController = () => { 6 | const { frontendManagerState } = useAppState() 7 | 8 | const useMicRow = useMemo(() => { 9 | return ( 10 |
11 |
UseMic:
12 |
13 | { 17 | frontendManagerState.setUseMicrophone(e.target.checked) 18 | }} 19 | /> 20 |
21 |
22 | ); 23 | }, [frontendManagerState.useMicrophone]) 24 | const micSelectorRow = useMemo(() => { 25 | return ( 26 |
27 |
Mic:
28 |
29 | 30 |
31 |
32 | ); 33 | }, []); 34 | 35 | const systemAudioGain = useMemo(() => { 36 | return ( 37 |
38 |
Audio Gain
39 |
40 |
41 | { 42 | frontendManagerState.setSystemAudioGain(Number(e.target.value)) 43 | }} /> 44 |
45 |
{frontendManagerState.systemAudioGain}
46 | 47 |
48 |
49 | ); 50 | }, [frontendManagerState.systemAudioGain]); 51 | 52 | const microphoneAudioGain = useMemo(() => { 53 | return ( 54 |
55 |
Mic Gain
56 |
57 |
58 | { 59 | frontendManagerState.setMicrophoneGain(Number(e.target.value)) 60 | }} /> 61 |
62 |
{frontendManagerState.microphoneGain}
63 | 64 |
65 |
66 | ); 67 | }, [frontendManagerState.microphoneGain]); 68 | 69 | return ( 70 |
71 | {useMicRow} 72 | {micSelectorRow} 73 | {systemAudioGain} 74 | {microphoneAudioGain} 75 |
76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /frontend/src/100_components/001_css/001_App.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Chicle&family=Poppins:ital,wght@0,200;0,400;0,600;1,200;1,400;1,600&display=swap"); 2 | 3 | @import "./010_Frame.css"; 4 | @import "./020_Header.css"; 5 | @import "./030_Body.css"; 6 | @import "./040_RightSidebar.css"; 7 | 8 | @import "./101_RotatedButton.css"; 9 | 10 | :root { 11 | --text-color: #333; 12 | --company-color1: rgba(64, 119, 187, 1); 13 | --company-color2: rgba(29, 47, 78, 1); 14 | --company-color3: rgba(255, 255, 255, 1); 15 | --company-color1-alpha: rgba(64, 119, 187, 0.3); 16 | --company-color2-alpha: rgba(29, 47, 78, 0.3); 17 | --company-color3-alpha: rgba(255, 255, 255, 0.3); 18 | --global-shadow-color: rgba(0, 0, 0, 0.4); 19 | 20 | --sidebar-transition-time: 0.3s; 21 | --sidebar-transition-animation: ease-in-out; 22 | 23 | --header-height: 1.5rem; 24 | --right-sidebar-width: 320px; 25 | 26 | --dialog-border-color: rgba(100, 100, 100, 1); 27 | --dialog-shadow-color: rgba(0, 0, 0, 0.3); 28 | --dialog-background-color: rgba(255, 255, 255, 1); 29 | --dialog-primary-color: rgba(19, 70, 209, 1); 30 | --dialog-active-color: rgba(40, 70, 209, 1); 31 | --dialog-input-border-color: rgba(200, 200, 200, 1); 32 | --dialog-submit-button-color: rgba(180, 190, 230, 1); 33 | --dialog-cancel-button-color: rgba(235, 80, 80, 1); 34 | } 35 | 36 | * { 37 | margin: 0; 38 | padding: 0; 39 | box-sizing: border-box; 40 | font-family: "Poppins", sans-serif; 41 | } 42 | html { 43 | font-size: 16px; 44 | } 45 | body { 46 | height: 100%; 47 | width: 100%; 48 | color: var(--text-color); 49 | background: linear-gradient(45deg, var(--company-color1) 0, 5%, var(--company-color2) 5% 10%, var(--company-color3) 10% 80%, var(--company-color1) 80% 85%, var(--company-color2) 85% 100%); 50 | } 51 | 52 | .application-container { 53 | position: relative; 54 | height: 100vh; 55 | width: 100%; 56 | overflow: hidden; 57 | list-style-type: none; 58 | } 59 | 60 | .state-control-checkbox { 61 | display: none; 62 | } 63 | 64 | .video-for-recorder-container { 65 | position: absolute; 66 | left: -1000px; 67 | width: 30px; 68 | height: 30px; 69 | } 70 | 71 | /* */ 72 | /* */ 73 | /* */ 74 | /* start button */ 75 | .front-container { 76 | display: flex; 77 | flex-direction: column; 78 | justify-content: center; 79 | align-items: center; 80 | margin-top: 30px; 81 | } 82 | .front-title { 83 | font-size: 4rem; 84 | font-weight: 100; 85 | text-align: center; 86 | } 87 | .front-description { 88 | font-size: 0.9rem; 89 | text-align: center; 90 | background: rgba(255, 255, 255, 0.3); 91 | padding: 20px 20px 20px 20px; 92 | width: 640px; 93 | } 94 | .front-description-img { 95 | height: 4rem; 96 | } 97 | .front-description-strong { 98 | color: #f66; 99 | font-size: 0.9rem; 100 | font-weight: 600; 101 | } 102 | .front-start-button { 103 | font-size: 4rem; 104 | border: 3px solid #333; 105 | background: #eef; 106 | width: 500px; 107 | padding: 15px; 108 | cursor: pointer; 109 | text-align: center; 110 | margin: 100px 0 0 0 auto; 111 | user-select: none; 112 | } 113 | .front-note { 114 | font-size: 1rem; 115 | } 116 | .front-attention { 117 | font-size: 0.8rem; 118 | color: #f55; 119 | font-weight: 600; 120 | } 121 | .front-disclaimer { 122 | font-size: 0.8rem; 123 | } 124 | -------------------------------------------------------------------------------- /frontend/src/100_components/001_css/040_RightSidebar.css: -------------------------------------------------------------------------------- 1 | @import "./041_RightSidebarItems.css"; 2 | 3 | .right-sidebar { 4 | } 5 | /* Partition */ 6 | .sidebar-partition { 7 | position: static; 8 | display: flex; 9 | flex-direction: column; 10 | width: 100%; 11 | color: rgba(255, 255, 255, 1); 12 | background: rgba(0, 0, 0, 0); 13 | z-index: 10; 14 | overflow: hidden; 15 | } 16 | .state-control-checkbox:checked + .sidebar-partition .sidebar-content { 17 | max-height: 300px; 18 | transition: all var(--sidebar-transition-time) var(--sidebar-transition-animation); 19 | } 20 | .state-control-checkbox + .sidebar-partition .sidebar-content { 21 | max-height: 0px; 22 | transition: all var(--sidebar-transition-time) var(--sidebar-transition-animation); 23 | } 24 | 25 | /* Header */ 26 | .sidebar-header { 27 | position: static; 28 | width: 100%; 29 | height: var(--header-height); 30 | font-size: 1.1rem; 31 | background: rgba(10, 10, 10, 0.5); 32 | display: flex; 33 | justify-content: space-between; 34 | .sidebar-header-title { 35 | padding-left: 1rem; 36 | user-select: none; 37 | } 38 | .sidebar-header-caret { 39 | align-items: right; 40 | } 41 | } 42 | /* Content */ 43 | .sidebar-content { 44 | padding: 0px 5px 0px 5px; 45 | position: static; 46 | width: 100%; 47 | height: auto; 48 | /* height: calc(100% - var(--header-height)); */ 49 | background: rgba(200, 0, 0, 1); 50 | user-select: none; 51 | } 52 | 53 | .sidebar-content-row-3-7 { 54 | display: flex; 55 | width: 100%; 56 | justify-content: center; 57 | margin: 1px 0px 1px 0px; 58 | & > div:nth-child(1) { 59 | left: 0px; 60 | width: 30%; 61 | } 62 | & > div:nth-child(2) { 63 | left: 30%; 64 | width: 70%; 65 | } 66 | } 67 | 68 | .sidebar-content-row-5-5 { 69 | display: flex; 70 | width: 100%; 71 | justify-content: center; 72 | margin: 1px 0px 1px 0px; 73 | & > div:nth-child(1) { 74 | left: 0px; 75 | width: 50%; 76 | } 77 | & > div:nth-child(2) { 78 | left: 50%; 79 | width: 50%; 80 | } 81 | } 82 | 83 | .sidebar-content-row-7-3 { 84 | display: flex; 85 | width: 100%; 86 | justify-content: center; 87 | margin: 1px 0px 1px 0px; 88 | & > div:nth-child(1) { 89 | left: 0px; 90 | width: 70%; 91 | } 92 | & > div:nth-child(2) { 93 | left: 70%; 94 | width: 30%; 95 | } 96 | } 97 | 98 | // Button 99 | 100 | .sidebar-content-row-buttons { 101 | display: flex; 102 | justify-content: flex-end; 103 | } 104 | 105 | .sidebar-content-row-button, 106 | .sidebar-content-row-button-activated, 107 | .sidebar-content-row-button-stanby { 108 | padding: 0px 5px 0px 5px; 109 | margin: 0px 5px 0px 5px; 110 | border-radius: 2px; 111 | border: 1px solid #446; 112 | cursor: pointer; 113 | /* width: 30%; */ 114 | text-align: center; 115 | font-weight: 100; 116 | } 117 | .sidebar-content-row-button-activated { 118 | /* width: 50%; */ 119 | background: #bbd; 120 | color: #000; 121 | } 122 | .sidebar-content-row-button-activated:hover { 123 | /* background: #4f5; */ 124 | font-weight: 600; 125 | } 126 | .sidebar-content-row-button, 127 | .sidebar-content-row-button-stanby { 128 | background: #555; 129 | } 130 | .sidebar-content-row-button:hover, 131 | .sidebar-content-row-button-stanby:hover { 132 | /* background: #666; */ 133 | font-weight: 400; 134 | } 135 | 136 | /* Select */ 137 | .sidebar-content-row-select { 138 | left: 30%; 139 | width: 70%; 140 | } 141 | 142 | .device-selector-option { 143 | font-size: 1rem; 144 | } 145 | .device-selector-select { 146 | max-width: 90%; 147 | min-width: 50%; 148 | font-size: 0.7rem; 149 | } 150 | 151 | /* Slider */ 152 | .sidebar-content-row-slider-container { 153 | display: flex; 154 | } 155 | .sidebar-content-row-slider { 156 | } 157 | .sidebar-content-row-slider-val { 158 | margin-left: 10px; 159 | } 160 | -------------------------------------------------------------------------------- /frontend/src/100_components/002_parts/300_RightSidebar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo } from "react"; 2 | import { useStateControlCheckbox } from "../003_hooks/useStateControlCheckbox"; 3 | import { AnimationTypes, HeaderButton, HeaderButtonProps } from "./101_HeaderButton"; 4 | import { ScreenRecorderController } from "./310_ScreenRecorderController"; 5 | import { MixController } from "./320_MixController"; 6 | import { Links } from "./330_Links"; 7 | 8 | export const RightSidebar = () => { 9 | const sidebarAccordionScreenRecorderControllerCheckBox = useStateControlCheckbox("screen-recorder-controller"); 10 | const sidebarAccordionMixControllerCheckBox = useStateControlCheckbox("mix-controller"); 11 | const sidebarAccordionLinksCheckBox = useStateControlCheckbox("links"); 12 | 13 | const accodionButtonForScreenRecorderController = useMemo(() => { 14 | const accodionButtonForScreenRecorderControllerProps: HeaderButtonProps = { 15 | stateControlCheckbox: sidebarAccordionScreenRecorderControllerCheckBox, 16 | tooltip: "Open/Close", 17 | onIcon: ["fas", "caret-up"], 18 | offIcon: ["fas", "caret-up"], 19 | animation: AnimationTypes.spinner, 20 | tooltipClass: "tooltip-right", 21 | }; 22 | return ; 23 | }, []); 24 | 25 | const accodionButtonForMixController = useMemo(() => { 26 | const accodionButtonForMixControllerProps: HeaderButtonProps = { 27 | stateControlCheckbox: sidebarAccordionMixControllerCheckBox, 28 | tooltip: "Open/Close", 29 | onIcon: ["fas", "caret-up"], 30 | offIcon: ["fas", "caret-up"], 31 | animation: AnimationTypes.spinner, 32 | tooltipClass: "tooltip-right", 33 | }; 34 | return ; 35 | }, []); 36 | 37 | const accodionButtonForLinks = useMemo(() => { 38 | const accodionButtonForLinksProps: HeaderButtonProps = { 39 | stateControlCheckbox: sidebarAccordionLinksCheckBox, 40 | tooltip: "Open/Close", 41 | onIcon: ["fas", "caret-up"], 42 | offIcon: ["fas", "caret-up"], 43 | animation: AnimationTypes.spinner, 44 | tooltipClass: "tooltip-right", 45 | }; 46 | return ; 47 | }, []); 48 | 49 | 50 | useEffect(() => { 51 | sidebarAccordionScreenRecorderControllerCheckBox.updateState(true); 52 | sidebarAccordionMixControllerCheckBox.updateState(true); 53 | }, []); 54 | return ( 55 | <> 56 |
57 | {sidebarAccordionScreenRecorderControllerCheckBox.trigger} 58 |
59 |
60 |
Screen Recorder
61 |
{accodionButtonForScreenRecorderController}
62 |
63 | 64 |
65 | 66 | {sidebarAccordionMixControllerCheckBox.trigger} 67 |
68 |
69 |
Mix Controll
70 |
{accodionButtonForMixController}
71 |
72 | 73 |
74 | 75 | {sidebarAccordionLinksCheckBox.trigger} 76 |
77 |
78 |
Links
79 |
{accodionButtonForLinks}
80 |
81 | 82 |
83 | 84 |
85 | 86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /frontend/public/coi-serviceworker.js: -------------------------------------------------------------------------------- 1 | /*! coi-serviceworker v0.1.6 - Guido Zuidhof, licensed under MIT */ 2 | let coepCredentialless = false; 3 | if (typeof window === "undefined") { 4 | self.addEventListener("install", () => self.skipWaiting()); 5 | self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim())); 6 | 7 | self.addEventListener("message", (ev) => { 8 | if (!ev.data) { 9 | return; 10 | } else if (ev.data.type === "deregister") { 11 | self.registration 12 | .unregister() 13 | .then(() => { 14 | return self.clients.matchAll(); 15 | }) 16 | .then((clients) => { 17 | clients.forEach((client) => client.navigate(client.url)); 18 | }); 19 | } else if (ev.data.type === "coepCredentialless") { 20 | coepCredentialless = ev.data.value; 21 | } 22 | }); 23 | 24 | self.addEventListener("fetch", function (event) { 25 | const r = event.request; 26 | if (r.cache === "only-if-cached" && r.mode !== "same-origin") { 27 | return; 28 | } 29 | 30 | const request = 31 | coepCredentialless && r.mode === "no-cors" 32 | ? new Request(r, { 33 | credentials: "omit", 34 | }) 35 | : r; 36 | event.respondWith( 37 | fetch(request) 38 | .then((response) => { 39 | if (response.status === 0) { 40 | return response; 41 | } 42 | const newHeaders = new Headers(response.headers); 43 | newHeaders.set("Cross-Origin-Embedder-Policy", coepCredentialless ? "credentialless" : "require-corp"); 44 | newHeaders.set("Cross-Origin-Opener-Policy", "same-origin"); 45 | 46 | return new Response(response.body, { 47 | status: response.status, 48 | statusText: response.statusText, 49 | headers: newHeaders, 50 | }); 51 | }) 52 | .catch((e) => console.error(e)) 53 | ); 54 | }); 55 | } else { 56 | (() => { 57 | // You can customize the behavior of this script through a global `coi` variable. 58 | const coi = { 59 | shouldRegister: () => true, 60 | shouldDeregister: () => false, 61 | coepCredentialless: () => false, 62 | doReload: () => window.location.reload(), 63 | quiet: false, 64 | ...window.coi, 65 | }; 66 | 67 | const n = navigator; 68 | 69 | if (n.serviceWorker && n.serviceWorker.controller) { 70 | n.serviceWorker.controller.postMessage({ 71 | type: "coepCredentialless", 72 | value: coi.coepCredentialless(), 73 | }); 74 | 75 | if (coi.shouldDeregister()) { 76 | n.serviceWorker.controller.postMessage({ type: "deregister" }); 77 | } 78 | } 79 | 80 | // If we're already coi: do nothing. Perhaps it's due to this script doing its job, or COOP/COEP are 81 | // already set from the origin server. Also if the browser has no notion of crossOriginIsolated, just give up here. 82 | if (window.crossOriginIsolated !== false || !coi.shouldRegister()) return; 83 | 84 | if (!window.isSecureContext) { 85 | !coi.quiet && console.log("COOP/COEP Service Worker not registered, a secure context is required."); 86 | return; 87 | } 88 | 89 | // In some environments (e.g. Chrome incognito mode) this won't be available 90 | if (n.serviceWorker) { 91 | n.serviceWorker.register(window.document.currentScript.src).then( 92 | (registration) => { 93 | !coi.quiet && console.log("COOP/COEP Service Worker registered", registration.scope); 94 | 95 | registration.addEventListener("updatefound", () => { 96 | !coi.quiet && console.log("Reloading page to make use of updated COOP/COEP Service Worker."); 97 | coi.doReload(); 98 | }); 99 | 100 | // If the registration is active, but it's not controlling the page 101 | if (registration.active && !n.serviceWorker.controller) { 102 | !coi.quiet && console.log("Reloading page to make use of COOP/COEP Service Worker."); 103 | coi.doReload(); 104 | } 105 | }, 106 | (err) => { 107 | !coi.quiet && console.error("COOP/COEP Service Worker failed to register:", err); 108 | } 109 | ); 110 | } 111 | })(); 112 | } 113 | -------------------------------------------------------------------------------- /frontend/src/100_components/002_parts/310_ScreenRecorderController.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { useAppState } from "../../003_provider/003_AppStateProvider"; 3 | // import { TARGET_SCREEN_VIDEO_ID } from "../../const"; 4 | 5 | export const ScreenRecorderController = () => { 6 | const { frontendManagerState } = useAppState() 7 | 8 | const chooseWindowRow = useMemo(() => { 9 | const onChooseWindowClicked = async () => { 10 | // @ts-ignore 11 | const constraints: DisplayMediaStreamConstraints = { 12 | audio: true, 13 | video: { 14 | width: { ideal: 3840 }, 15 | height: { ideal: 2160 }, 16 | frameRate: 15 17 | } 18 | } 19 | const ms = await navigator.mediaDevices.getDisplayMedia(constraints); 20 | frontendManagerState.setScreenMediaStream(ms) 21 | } 22 | return ( 23 |
24 |
Choose Window:
25 |
26 |
onChooseWindowClicked()}>click
27 |
28 |
29 | ) 30 | }, []) 31 | 32 | const startRecordingButtonRow = useMemo(() => { 33 | let statusMessage = "" 34 | let buttonMessage = "" 35 | let buttonClass = "" 36 | let buttonAction: () => void = () => { } 37 | switch (frontendManagerState.recordingStatus) { 38 | case "initializing": 39 | statusMessage = "(initializing...)"; 40 | buttonMessage = "wait" 41 | buttonClass = "sidebar-content-row-button" 42 | buttonAction = () => { } 43 | break 44 | case "stop": 45 | statusMessage = "(stopped)"; 46 | buttonMessage = "start" 47 | buttonClass = "sidebar-content-row-button" 48 | buttonAction = () => { frontendManagerState.startRecording() } 49 | break 50 | case "recording": 51 | statusMessage = `(recording... ${frontendManagerState.chunkNum})`; 52 | buttonMessage = "stop" 53 | buttonClass = "sidebar-content-row-button-activated" 54 | buttonAction = () => { frontendManagerState.stopRecording() } 55 | break 56 | case "converting": 57 | statusMessage = `(converting...${frontendManagerState.convertProgress})`; 58 | buttonMessage = "wait" 59 | buttonClass = "sidebar-content-row-button-activated" 60 | buttonAction = () => { console.log("wait") } 61 | break 62 | } 63 | 64 | return ( 65 |
66 |
67 |
buttonAction()}>{buttonMessage}
68 |
69 |
70 | {statusMessage} 71 |
72 |
73 | ) 74 | 75 | }, [frontendManagerState.recordingStatus, frontendManagerState.chunkNum, frontendManagerState.convertProgress]) 76 | 77 | const chunkDurationRow = useMemo(() => { 78 | const onChunkDurationChange = (val: number) => { 79 | frontendManagerState.setChunkDuration(val) 80 | } 81 | return ( 82 |
83 |
process interval
84 |
85 | { onChunkDurationChange(Number(e.target.value)) }}> 86 |
87 |
88 | ) 89 | }, [frontendManagerState.chunkDuration]) 90 | const waitToProcessRow = useMemo(() => { 91 | const onWaitToDownloadChange = (val: number) => { 92 | frontendManagerState.setWaitTimeToProcess(val) 93 | } 94 | return ( 95 |
96 |
wait for last data
97 |
98 | { onWaitToDownloadChange(Number(e.target.value)) }}> 99 |
100 |
101 | ) 102 | }, [frontendManagerState.waitTimeToProcess]) 103 | 104 | return ( 105 |
106 | {chooseWindowRow} 107 | {/* {targetScreenViewRow} */} 108 | {startRecordingButtonRow} 109 | {chunkDurationRow} 110 | {waitToProcessRow} 111 |
112 | ); 113 | }; 114 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { AppSettingProvider, useAppSetting } from "./003_provider/001_AppSettingProvider"; 4 | import { AppRootStateProvider } from "./003_provider/002_AppRootStateProvider"; 5 | import "./100_components/001_css/001_App.css"; 6 | import { AppStateProvider } from "./003_provider/003_AppStateProvider"; 7 | import App from "./App"; 8 | 9 | const AppStateProviderWrapper = () => { 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | // アプリの説明 18 | const FrontPageDescriptionJp = () => { 19 | return ( 20 |
21 |

22 | ブラウザを使った画面録画アプリケーションです。 23 | ブラウザ単体で動くため、専用のアプリケーションのインストールは不要です。また、サーバとの通信も発生しないため通信負荷を気にする必要がありません。 24 |

25 |

26 | ソースコード、使用方法は 27 | こちら。 28 |

29 |

使ってみてコーヒーくらいならごちそうしてもいいかなという人はこちらからご支援お願いします。

30 |

31 | 32 | 33 | 34 |

35 |
36 | ); 37 | }; 38 | 39 | const FrontPageDescriptionEn = () => { 40 | return ( 41 |
42 |

43 | Record your screen with your browser! 44 |

45 |

46 | This application run on web browser and there is no need to install a dedicated application. Also, since no communication with the server occurs after loaded, there is no need to worry about communication load. 47 |

48 | 49 |

50 | Usage and source code is in the repository 51 |

52 |

please support me!

53 |

54 | 55 | 56 | 57 |

58 |
59 | ); 60 | }; 61 | 62 | // 免責 63 | const FrontPageDisclaimerJp = () => { 64 | return ( 65 |
免責:本ソフトウェアの使用または使用不能により生じたいかなる直接損害・間接損害・波及的損害・結果的損害 または特別損害についても、一切責任を負いません。
66 | ); 67 | }; 68 | const FrontPageDisclaimerEn = () => { 69 | return ( 70 |
Disclaimer: In no event will we be liable for any direct, indirect, consequential, incidental, or special damages resulting from the use or inability to use this software.
71 | ); 72 | }; 73 | 74 | // Note 75 | const FrontPageNoteJp = () => { 76 | return ( 77 |
78 |

このアプリケーションは ffmpeg.wasmを使用しています

79 |
80 | ); 81 | }; 82 | const FrontPageNoteEn = () => { 83 | return ( 84 |

This software uses ffmpeg.wasm

85 | ); 86 | }; 87 | 88 | 89 | const AppRootStateProviderWrapper = () => { 90 | const { applicationSettingState, deviceManagerState } = useAppSetting(); 91 | const [firstTach, setFirstTouch] = React.useState(false); 92 | const lang = window.navigator.language.toLocaleUpperCase(); 93 | const description = lang.includes("JA") ? : 94 | const disclaimer = lang.includes("JA") ? : 95 | const note = lang.includes("JA") ? : 96 | 97 | if (!applicationSettingState.applicationSetting || !firstTach) { 98 | 99 | return ( 100 |
101 |
Screen Recorder
102 | 103 | {description} 104 | 105 |
{ 108 | setFirstTouch(true); 109 | }} 110 | > 111 | Click to start 112 |
113 |
Tested: Windows 11 + Chrome
114 | 115 | {disclaimer} 116 | 117 | {note} 118 | 119 |
120 | ); 121 | } else if (deviceManagerState.audioInputDevices.length === 0) { 122 | return ( 123 | <> 124 |
Loading Devices...
125 | 126 | ); 127 | } else { 128 | return ( 129 | 130 | 131 | 132 | ); 133 | } 134 | }; 135 | 136 | const container = document.getElementById("app")!; 137 | const root = createRoot(container); 138 | root.render( 139 | 140 | 141 | 142 | ); -------------------------------------------------------------------------------- /frontend/src/002_hooks/100_useFrontendManager.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef, useState } from "react"; 2 | import { StateControlCheckbox, useStateControlCheckbox } from "../100_components/003_hooks/useStateControlCheckbox"; 3 | import { TARGET_SCREEN_VIDEO_ID } from "../const"; 4 | import { FFmpeg, createFFmpeg, fetchFile } from "@ffmpeg/ffmpeg"; 5 | import { useAudioRoot } from "./010_useAudioRoot"; 6 | import { useAppSetting } from "../003_provider/001_AppSettingProvider"; 7 | 8 | export const RECORDING_STATUS = { 9 | initializing: "initializing", 10 | stop: "stop", 11 | recording: "recording", 12 | converting: "converting", 13 | } as const 14 | export type RECORDING_STATUS = typeof RECORDING_STATUS[keyof typeof RECORDING_STATUS] 15 | 16 | export type StateControls = { 17 | openRightSidebarCheckbox: StateControlCheckbox 18 | } 19 | 20 | type FrontendManagerState = { 21 | stateControls: StateControls 22 | screenMediaStream: MediaStream 23 | recordingStatus: RECORDING_STATUS 24 | convertProgress: number 25 | chunkDuration: number 26 | waitTimeToProcess: number 27 | chunkNum: number 28 | 29 | useMicrophone: boolean 30 | systemAudioGain: number 31 | microphoneGain: number 32 | }; 33 | 34 | export type FrontendManagerStateAndMethod = FrontendManagerState & { 35 | setScreenMediaStream: (ms: MediaStream) => void 36 | startRecording: () => void 37 | stopRecording: () => Promise 38 | setUseMicrophone: (val: boolean) => void 39 | setSystemAudioGain: (val: number) => void 40 | setMicrophoneGain: (val: number) => void 41 | 42 | setChunkDuration: (val: number) => void 43 | setWaitTimeToProcess: (val: number) => void 44 | } 45 | 46 | export const useFrontendManager = (): FrontendManagerStateAndMethod => { 47 | const [chunkDuration, setChunkDuration] = useState(2) 48 | const [waitTimeToProcess, setWaitTimeToProcess] = useState(2) 49 | const { audioContext } = useAudioRoot() 50 | const { deviceManagerState } = useAppSetting() 51 | const systemAudioGainNode = useMemo(() => { 52 | return audioContext.createGain() 53 | }, []) 54 | const microphoneGainNode = useMemo(() => { 55 | return audioContext.createGain() 56 | }, []) 57 | 58 | const audioDestNode = useMemo(() => { 59 | const dest = audioContext.createMediaStreamDestination() 60 | systemAudioGainNode.connect(dest) 61 | microphoneGainNode.connect(dest) 62 | return dest 63 | }, []) 64 | 65 | const [screenMediaStream, _setScreenMediaStream] = useState(new MediaStream()) 66 | const [recordingStatus, setRecordingStatus] = useState("initializing") 67 | const [ffmpeg, setFfmpeg] = useState(); 68 | const [convertProgress, setConvertProgress] = useState(0); 69 | const recorderRef = useRef(null) 70 | const chunks = useMemo(() => { 71 | return [] as Blob[]; 72 | }, []); 73 | const [chunkNum, setChuhkNum] = useState(0) 74 | 75 | const [useMicrophone, _setUseMicrophone] = useState(false) 76 | 77 | const [systemAudioGain, _setSystemAudioGain] = useState(1) 78 | const [microphoneGain, _setMicrophoneGain] = useState(1) 79 | 80 | const sysSrcNodeRef = useRef(null) 81 | const micSrcNodeRef = useRef(null) 82 | 83 | 84 | // const requestIdRef = useRef(0) 85 | 86 | // (1) Controller Switch 87 | const openRightSidebarCheckbox = useStateControlCheckbox("open-right-sidebar-checkbox"); 88 | 89 | // (2) initialize 90 | useEffect(() => { 91 | const ffmpeg = createFFmpeg({ 92 | log: true, 93 | // corePath: "./assets/ffmpeg/ffmpeg-core.js", 94 | }); 95 | const loadFfmpeg = async () => { 96 | await ffmpeg!.load(); 97 | 98 | ffmpeg!.setProgress(({ ratio }) => { 99 | console.log("progress:", ratio); 100 | setConvertProgress(ratio); 101 | }); 102 | setFfmpeg(ffmpeg); 103 | setRecordingStatus("stop") 104 | }; 105 | loadFfmpeg(); 106 | }, []); 107 | 108 | 109 | 110 | // (3) operation 111 | //// (3-1) set ms 112 | const setScreenMediaStream = (ms: MediaStream) => { 113 | const videoElem = document.getElementById(TARGET_SCREEN_VIDEO_ID) as HTMLVideoElement 114 | // const canvasElem = document.getElementById(RECORDING_CANVAS_ID) as HTMLCanvasElement 115 | videoElem.onloadedmetadata = () => { 116 | _setScreenMediaStream(ms) 117 | } 118 | videoElem.srcObject = ms 119 | videoElem.play() 120 | 121 | } 122 | 123 | //// (3-2) start 124 | const startRecording = () => { 125 | setRecordingStatus("recording") 126 | // (1) ソース取得 127 | const videoElem = document.getElementById(TARGET_SCREEN_VIDEO_ID) as HTMLVideoElement 128 | // const canvasElem = document.getElementById(RECORDING_CANVAS_ID) as HTMLCanvasElement 129 | // @ts-ignore 130 | const videoMS = videoElem.captureStream() as MediaStream 131 | // const canvasMS = canvasElem.captureStream() as MediaStream 132 | 133 | 134 | // (2) MediaStream作成 135 | const ms = new MediaStream() 136 | //// (2-1) video 137 | // canvasMS.getVideoTracks().forEach(x => { ms.addTrack(x) }) 138 | videoMS.getVideoTracks().forEach(x => { ms.addTrack(x) }) 139 | 140 | ///// (2-2) audio. 最終ノードからtrack取得 141 | audioDestNode.stream.getAudioTracks().forEach(x => { ms.addTrack(x) }) 142 | 143 | const options = { 144 | mimeType: "video/webm;codecs=h264,opus", 145 | }; 146 | const recorder = new MediaRecorder(ms, options); 147 | recorder.ondataavailable = (e: BlobEvent) => { 148 | chunks.push(e.data); 149 | 150 | setChuhkNum(chunks.length) 151 | }; 152 | try { 153 | recorder.start(1000 * chunkDuration) 154 | } catch (exception) { 155 | console.log(exception) 156 | alert(exception) 157 | setRecordingStatus("stop") 158 | } 159 | recorderRef.current = recorder 160 | } 161 | 162 | //// (3-3) stop 163 | const stopRecording = async () => { 164 | if (!recorderRef.current) { 165 | return 166 | } 167 | setRecordingStatus("converting") 168 | 169 | // Wait for receiving frame 170 | await new Promise((resolve, _reject) => { 171 | setTimeout(resolve, 1000 * waitTimeToProcess) 172 | }) 173 | 174 | recorderRef.current.stop(); 175 | if (chunks.length > 0) { 176 | await toMp4(chunks); 177 | } else { 178 | alert("not enough data"); 179 | } 180 | while (chunks.length !== 0) { 181 | chunks.shift(); 182 | } 183 | 184 | setRecordingStatus("stop") 185 | } 186 | 187 | //// (3-3-a) convert 188 | const toMp4 = async (blobs: Blob[]) => { 189 | if (!ffmpeg || ffmpeg.isLoaded() === false) { 190 | return; 191 | } 192 | const name = "record.webm"; 193 | const outName = "out.mp4"; 194 | 195 | // convert 196 | // @ts-ignore 197 | ffmpeg.FS("writeFile", name, await fetchFile(new Blob(blobs))); 198 | await ffmpeg.run("-i", name, "-c", "copy", outName); 199 | const data = ffmpeg!.FS("readFile", outName); 200 | 201 | // download 202 | const a = document.createElement("a"); 203 | a.download = outName; 204 | a.href = URL.createObjectURL(new Blob([data.buffer], { type: "video/mp4" })); 205 | a.click(); 206 | }; 207 | 208 | // マイク有効/無効変更 209 | const setUseMicrophone = (val: boolean) => { 210 | _setUseMicrophone(val) 211 | } 212 | 213 | // 音量調整 (システム) 214 | const setSystemAudioGain = (val: number) => { 215 | // systemAudioGainRef.current = val 216 | _setSystemAudioGain(val) 217 | systemAudioGainNode.gain.value = val 218 | } 219 | 220 | // 音量調整 (マイク) 221 | const setMicrophoneGain = (val: number) => { 222 | // microphoneGainRef.current = val 223 | _setMicrophoneGain(val) 224 | microphoneGainNode.gain.value = val 225 | } 226 | 227 | 228 | // SystemAudioの変更 229 | useEffect(() => { 230 | // (1) 既存の接続を切る 231 | if (sysSrcNodeRef.current) { 232 | sysSrcNodeRef.current.disconnect(microphoneGainNode) 233 | sysSrcNodeRef.current = null 234 | } 235 | 236 | const videoElem = document.getElementById(TARGET_SCREEN_VIDEO_ID) as HTMLVideoElement 237 | // @ts-ignore 238 | const videoMS = videoElem.captureStream() as MediaStream 239 | // (2) 途中終了 240 | if (videoMS.getAudioTracks().length == 0) { 241 | return 242 | } 243 | const systemAudioSrc = audioContext.createMediaStreamSource(videoMS) 244 | systemAudioSrc.connect(systemAudioGainNode) 245 | sysSrcNodeRef.current = systemAudioSrc 246 | 247 | }, [screenMediaStream]) 248 | 249 | // マイク接続の変更。(a) デバイス再指定、 (b)有効/無効変更 250 | useEffect(() => { 251 | // (1) 既存の接続を切る 252 | if (micSrcNodeRef.current) { 253 | micSrcNodeRef.current.disconnect(microphoneGainNode) 254 | micSrcNodeRef.current = null 255 | } 256 | 257 | // (2) 途中終了 258 | //// (2-1) デバイス指定がない場合 259 | if (!deviceManagerState.audioInputDeviceId || deviceManagerState.audioInputDeviceId === "none") { 260 | return 261 | } 262 | //// (2-2) Microphone使用しない場合 263 | if (!useMicrophone) { 264 | return 265 | } 266 | 267 | // (3) 新規接続 268 | const setUserMicrophone = async () => { 269 | const ms = await navigator.mediaDevices.getUserMedia({ 270 | audio: { 271 | deviceId: deviceManagerState.audioInputDeviceId! 272 | } 273 | }) 274 | const micSrcNode = audioContext.createMediaStreamSource(ms) 275 | micSrcNode.connect(microphoneGainNode) 276 | micSrcNodeRef.current = micSrcNode 277 | } 278 | setUserMicrophone() 279 | }, [deviceManagerState.audioInputDeviceId, useMicrophone]) 280 | 281 | 282 | const returnValue: FrontendManagerStateAndMethod = { 283 | stateControls: { 284 | // (1) Controller Switch 285 | openRightSidebarCheckbox, 286 | }, 287 | screenMediaStream, 288 | recordingStatus, 289 | convertProgress, 290 | useMicrophone, 291 | systemAudioGain, 292 | microphoneGain, 293 | chunkDuration, 294 | waitTimeToProcess, 295 | chunkNum, 296 | 297 | setScreenMediaStream, 298 | startRecording, 299 | stopRecording, 300 | setUseMicrophone, 301 | setSystemAudioGain, 302 | setMicrophoneGain, 303 | 304 | setChunkDuration, 305 | setWaitTimeToProcess, 306 | }; 307 | return returnValue; 308 | }; 309 | --------------------------------------------------------------------------------