(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 |
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 |
--------------------------------------------------------------------------------