2025-11-25

This commit is contained in:
2025-11-26 22:33:30 -05:00
commit 4dea48c1e2
12 changed files with 3459 additions and 0 deletions

24
frontend-web-vanilla/.gitignore vendored Normal file
View 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?

View File

@@ -0,0 +1 @@
24

View 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
}

View File

@@ -0,0 +1,11 @@
telemetry = false
[install]
exact = true
[install.lockfile]
save = false
[test]
coverage = true
coverageSkipTestFiles = true

View 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']}
]);

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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
}));
}
};
}
}

View 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))
});

View 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;
}

View 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"]
}