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