2025-11-25
This commit is contained in:
24
frontend-web-vanilla/.gitignore
vendored
Normal file
24
frontend-web-vanilla/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
1
frontend-web-vanilla/.nvmrc
Normal file
1
frontend-web-vanilla/.nvmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
24
|
||||||
12
frontend-web-vanilla/.prettierrc
Normal file
12
frontend-web-vanilla/.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"bracketSameLine": true,
|
||||||
|
"bracketSpacing": false,
|
||||||
|
"printWidth": 190,
|
||||||
|
"semi": true,
|
||||||
|
"singleAttributePerLine": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
11
frontend-web-vanilla/bunfig.toml
Normal file
11
frontend-web-vanilla/bunfig.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
telemetry = false
|
||||||
|
|
||||||
|
[install]
|
||||||
|
exact = true
|
||||||
|
|
||||||
|
[install.lockfile]
|
||||||
|
save = false
|
||||||
|
|
||||||
|
[test]
|
||||||
|
coverage = true
|
||||||
|
coverageSkipTestFiles = true
|
||||||
15
frontend-web-vanilla/eslint.config.ts
Normal file
15
frontend-web-vanilla/eslint.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import json from '@eslint/json';
|
||||||
|
import markdown from '@eslint/markdown';
|
||||||
|
import css from '@eslint/css';
|
||||||
|
import {defineConfig} from 'eslint/config';
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
{files: ['**/*.{js,mjs,cjs,ts,mts,cts}'], plugins: {js}, extends: ['js/recommended'], languageOptions: {globals: globals.browser}},
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
{files: ['**/*.json'], plugins: {json}, language: 'json/json', extends: ['json/recommended']},
|
||||||
|
{files: ['**/*.md'], plugins: {markdown}, language: 'markdown/commonmark', extends: ['markdown/recommended']},
|
||||||
|
{files: ['**/*.css'], plugins: {css}, language: 'css/css', extends: ['css/recommended']}
|
||||||
|
]);
|
||||||
31
frontend-web-vanilla/index.html
Normal file
31
frontend-web-vanilla/index.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend-web-vanilla</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" class="flex-column">
|
||||||
|
<div class="flex-container flex-row">
|
||||||
|
<div class="video-container">
|
||||||
|
<h3 class="centered-text">Local</h3>
|
||||||
|
<video id="local-video" autoplay playsinline muted controls></video>
|
||||||
|
<div class="controls-container">
|
||||||
|
<button id="start-local-media">Start Local Media</button>
|
||||||
|
<button id="create-and-send-offer" disabled>Create & Send Offer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="video-container">
|
||||||
|
<h3 class="centered-text">Remote</h3>
|
||||||
|
<video id="remote-video" autoplay playsinline muted></video>
|
||||||
|
<div class="controls-container">
|
||||||
|
<button id="create-and-send-answer" disabled>Create & Send Answer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3077
frontend-web-vanilla/package-lock.json
generated
Normal file
3077
frontend-web-vanilla/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend-web-vanilla/package.json
Normal file
31
frontend-web-vanilla/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend-web-vanilla",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"format": "prettier --write ./",
|
||||||
|
"prelint:fix": "bun run format",
|
||||||
|
"lint:fix": "eslint --fix ./src",
|
||||||
|
"lint": "eslint --max-warnings 0 ./src",
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/css": "0.14.1",
|
||||||
|
"@eslint/js": "9.39.1",
|
||||||
|
"@eslint/json": "0.14.0",
|
||||||
|
"@eslint/markdown": "7.5.1",
|
||||||
|
"eslint": "9.39.1",
|
||||||
|
"globals": "16.5.0",
|
||||||
|
"jiti": "2.6.1",
|
||||||
|
"prettier": "3.6.2",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"typescript-eslint": "8.48.0",
|
||||||
|
"vite": "npm:rolldown-vite@7.2.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@techniker-me/tools": "^2025.0.16"
|
||||||
|
}
|
||||||
|
}
|
||||||
110
frontend-web-vanilla/src/Signaling.ts
Normal file
110
frontend-web-vanilla/src/Signaling.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import {Subject, ReadOnlySubject, Disposable, DisposableList} from '@techniker-me/tools';
|
||||||
|
|
||||||
|
export default class Signaling {
|
||||||
|
private readonly _peerConnection: RTCPeerConnection;
|
||||||
|
private readonly _websocket: WebSocket = new WebSocket('ws://' + window.location.host + '/ws');
|
||||||
|
private readonly _disposables: DisposableList = new DisposableList();
|
||||||
|
|
||||||
|
private readonly _signalingState: Subject<RTCSignalingState> = new Subject('closed');
|
||||||
|
private readonly _connectionState: Subject<RTCPeerConnectionState> = new Subject('closed');
|
||||||
|
private readonly _iceGatheringState: Subject<RTCIceGathererState> = new Subject('new');
|
||||||
|
private readonly _iceConnectionState: Subject<RTCIceConnectionState> = new Subject('new');
|
||||||
|
private readonly _readOnlySignalingState: ReadOnlySubject<RTCSignalingState> = new ReadOnlySubject(this._signalingState);
|
||||||
|
private readonly _readOnlyConnectionState: ReadOnlySubject<RTCPeerConnectionState> = new ReadOnlySubject(this._connectionState);
|
||||||
|
private readonly _readOnlyIceGatheringState: ReadOnlySubject<RTCIceGathererState> = new ReadOnlySubject(this._iceGatheringState);
|
||||||
|
private readonly _readOnlyIceConnectionState: ReadOnlySubject<RTCIceConnectionState> = new ReadOnlySubject(this._iceConnectionState);
|
||||||
|
|
||||||
|
constructor(peerConnection: RTCPeerConnection) {
|
||||||
|
this._peerConnection = peerConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public start(): void {
|
||||||
|
this.setPeerConnectionStateEventListeners(this._peerConnection);
|
||||||
|
this.setPeerConnectionEventListeners(this._peerConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
get signalingState(): ReadOnlySubject<RTCSignalingState> {
|
||||||
|
return this._readOnlySignalingState;
|
||||||
|
}
|
||||||
|
|
||||||
|
get connectionState(): ReadOnlySubject<RTCPeerConnectionState> {
|
||||||
|
return this._readOnlyConnectionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
get iceGatheringState(): ReadOnlySubject<RTCIceGathererState> {
|
||||||
|
return this._readOnlyIceGatheringState;
|
||||||
|
}
|
||||||
|
|
||||||
|
get iceConnectionState(): ReadOnlySubject<RTCIceConnectionState> {
|
||||||
|
return this._readOnlyIceConnectionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentRemoteDescription(): RTCSessionDescriptionInit | null {
|
||||||
|
return this._peerConnection.remoteDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentLocalDescription(): RTCSessionDescriptionInit | null {
|
||||||
|
return this._peerConnection.localDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createOffer(options?: RTCOfferOptions): Promise<RTCSessionDescriptionInit> {
|
||||||
|
const offer = await this._peerConnection.createOffer(options);
|
||||||
|
|
||||||
|
await this._peerConnection.setLocalDescription(offer);
|
||||||
|
|
||||||
|
return offer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createAnswer(options?: RTCOfferOptions): Promise<RTCSessionDescriptionInit> {
|
||||||
|
const answer = await this._peerConnection.createAnswer(options);
|
||||||
|
|
||||||
|
await this._peerConnection.setLocalDescription(answer);
|
||||||
|
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||||
|
await this._peerConnection.setRemoteDescription(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setLocalDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||||
|
await this._peerConnection.setLocalDescription(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): void {
|
||||||
|
this._disposables.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setPeerConnectionStateEventListeners(peerConnection: RTCPeerConnection): void {
|
||||||
|
peerConnection.onsignalingstatechange = () => {
|
||||||
|
this._signalingState.value = peerConnection.signalingState;
|
||||||
|
};
|
||||||
|
peerConnection.onconnectionstatechange = () => {
|
||||||
|
this._connectionState.value = peerConnection.connectionState;
|
||||||
|
};
|
||||||
|
peerConnection.onicegatheringstatechange = () => {
|
||||||
|
this._iceGatheringState.value = peerConnection.iceGatheringState;
|
||||||
|
};
|
||||||
|
peerConnection.oniceconnectionstatechange = () => {
|
||||||
|
this._iceConnectionState.value = peerConnection.iceConnectionState;
|
||||||
|
};
|
||||||
|
|
||||||
|
this._disposables.add(new Disposable(() => {
|
||||||
|
peerConnection.oniceconnectionstatechange = null;
|
||||||
|
peerConnection.onicegatheringstatechange = null;
|
||||||
|
peerConnection.onconnectionstatechange = null;
|
||||||
|
peerConnection.onsignalingstatechange = null;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setPeerConnectionEventListeners(peerConnection: RTCPeerConnection): void {
|
||||||
|
peerConnection.onicecandidate = (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
this._websocket.send(JSON.stringify({
|
||||||
|
type: 'icecandidate',
|
||||||
|
payload: event.candidate
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
73
frontend-web-vanilla/src/main.ts
Normal file
73
frontend-web-vanilla/src/main.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
type Elements = {
|
||||||
|
localVideo: HTMLVideoElement;
|
||||||
|
remoteVideo: HTMLVideoElement;
|
||||||
|
startLocalMediaButton: HTMLButtonElement;
|
||||||
|
createAndSendOfferButton: HTMLButtonElement;
|
||||||
|
createAndSendAnswerButton: HTMLButtonElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApplicationState = {
|
||||||
|
localMediaStream: MediaStream | null;
|
||||||
|
remoteMediaStream: MediaStream | null;
|
||||||
|
peerConnection: RTCPeerConnection;
|
||||||
|
offer: RTCSessionDescriptionInit | null;
|
||||||
|
answer: RTCSessionDescriptionInit | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const elements: Elements = {
|
||||||
|
localVideo: document.getElementById('local-video') as HTMLVideoElement,
|
||||||
|
remoteVideo: document.getElementById('remote-video') as HTMLVideoElement,
|
||||||
|
startLocalMediaButton: document.getElementById('start-local-media') as HTMLButtonElement,
|
||||||
|
createAndSendOfferButton: document.getElementById('create-and-send-offer') as HTMLButtonElement,
|
||||||
|
createAndSendAnswerButton: document.getElementById('create-and-send-answer') as HTMLButtonElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: ApplicationState = {
|
||||||
|
localMediaStream: null,
|
||||||
|
remoteMediaStream: null,
|
||||||
|
peerConnection: new RTCPeerConnection(),
|
||||||
|
offer: null,
|
||||||
|
answer: null,
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
getLocalMediaStream: (constraints = {audio: true, video: true}) => navigator.mediaDevices.getUserMedia(constraints),
|
||||||
|
setMediaStream: (target: HTMLVideoElement, stream: MediaStream): void => {target.srcObject = stream},
|
||||||
|
createOffer: (connection: RTCPeerConnection): Promise<RTCSessionDescriptionInit> => connection.createOffer(),
|
||||||
|
createAnswer: (connection: RTCPeerConnection): Promise<RTCSessionDescriptionInit> => connection.createAnswer(),
|
||||||
|
enableButton: (button: HTMLButtonElement): void => {button.disabled = false},
|
||||||
|
disableButton: (button: HTMLButtonElement): void => {button.disabled = true},
|
||||||
|
sendMessage: (message: string): void => {
|
||||||
|
// TODO: Implement message sending
|
||||||
|
},
|
||||||
|
receiveMessage: (message: string): void => {
|
||||||
|
// TODO: Implement message receiving
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
elements.startLocalMediaButton.addEventListener('click', () => {
|
||||||
|
actions.getLocalMediaStream()
|
||||||
|
.then(mediaStream => actions.setMediaStream(elements.localVideo, mediaStream))
|
||||||
|
.then(() => actions.enableButton(elements.createAndSendOfferButton))
|
||||||
|
.then(() => actions.disableButton(elements.startLocalMediaButton))
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.createAndSendOfferButton.addEventListener('click', () => {
|
||||||
|
actions.createOffer(state.peerConnection)
|
||||||
|
.then(offer => state.offer = offer)
|
||||||
|
.then(() => {
|
||||||
|
if (!state.offer) {
|
||||||
|
throw new Error('Offer not created');
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.peerConnection.setLocalDescription(state.offer)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
|
||||||
|
})
|
||||||
|
.then(() => actions.enableButton(elements.createAndSendAnswerButton))
|
||||||
|
.then(() => actions.disableButton(elements.createAndSendOfferButton))
|
||||||
|
});
|
||||||
48
frontend-web-vanilla/styles.css
Normal file
48
frontend-web-vanilla/styles.css
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered-text {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
width: 500px;
|
||||||
|
height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
26
frontend-web-vanilla/tsconfig.json
Normal file
26
frontend-web-vanilla/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES6",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user