Add initial project structure with Docker setup, SOLID principles, and video player implementation

- Created .gitignore to exclude logs and build artifacts.
- Added Dockerfile and docker-compose.yml for containerized deployment.
- Implemented a video player following SOLID principles with classes for video source, audio control, and volume meter.
- Introduced interfaces for audio, video source, and volume meter to ensure adherence to Interface Segregation Principle.
- Developed main entry point and HTML structure for the video player application.
- Included TypeScript configuration and package.json for project dependencies.
This commit is contained in:
2025-12-17 22:33:35 -05:00
commit 1fd455d29b
21 changed files with 1069 additions and 0 deletions

159
src/classes/VolumeMeter.ts Normal file
View File

@@ -0,0 +1,159 @@
import type { IVolumeMeter } from '../interfaces/IVolumeMeter';
/**
* VolumeMeter class
* Follows Single Responsibility Principle (SRP) - only responsible for audio analysis
*/
export class VolumeMeter implements IVolumeMeter {
private audioContext: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private source: MediaElementAudioSourceNode | null = null;
private videoElement: HTMLVideoElement;
private isRunning: boolean = false;
private animationFrameId: number | null = null;
private volumeCallback?: (volume: number) => void;
constructor(videoElement: HTMLVideoElement) {
this.videoElement = videoElement;
}
/**
* Sets a callback function to be called with volume updates
*/
setVolumeCallback(callback: (volume: number) => void): void {
this.volumeCallback = callback;
}
async start(): Promise<void> {
if (this.isRunning) {
return;
}
try {
// Create audio context
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
// Resume audio context if suspended (required by browser autoplay policies)
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume();
console.log('[VolumeMeter] Audio context resumed');
}
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 512; // Increased for better frequency resolution
this.analyser.smoothingTimeConstant = 0.3; // Lower for more reactive visualization
// Create source from video element
this.source = this.audioContext.createMediaElementSource(this.videoElement);
this.source.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
this.isRunning = true;
console.log('[VolumeMeter] Started successfully, frequencyBinCount:', this.analyser.frequencyBinCount);
// Check for CORS issues by testing if we get any audio data
if (this.analyser) {
setTimeout(() => {
if (this.analyser) {
const testArray = new Uint8Array(this.analyser.frequencyBinCount);
this.analyser.getByteFrequencyData(testArray);
const hasData = testArray.some(value => value > 0);
if (!hasData) {
console.warn('[VolumeMeter] No audio data detected - likely CORS restriction. Volume meters may not react.');
console.warn('[VolumeMeter] Solution: Add CORS headers to the HLS stream server or use same-origin content.');
}
}
}, 1000);
}
this.startVolumeMonitoring();
} catch (error) {
console.error('[VolumeMeter] Error starting audio analysis:', error);
this.isRunning = false;
}
}
stop(): void {
if (!this.isRunning) {
return;
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
if (this.source) {
this.source.disconnect();
this.source = null;
}
if (this.analyser) {
this.analyser.disconnect();
this.analyser = null;
}
if (this.audioContext && this.audioContext.state !== 'closed') {
this.audioContext.close();
this.audioContext = null;
}
this.isRunning = false;
}
getVolumeLevel(): number {
if (!this.analyser) {
return 0;
}
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
this.analyser.getByteFrequencyData(dataArray);
// Calculate average volume
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
sum += dataArray[i];
}
const average = sum / dataArray.length;
// Normalize to 0-1 range
return average / 255;
}
getFrequencyData(array: Uint8Array): void {
if (this.analyser) {
this.analyser.getByteFrequencyData(array as Uint8Array<ArrayBuffer>);
}
}
getTimeDomainData(array: Uint8Array): void {
if (this.analyser) {
this.analyser.getByteTimeDomainData(array as Uint8Array<ArrayBuffer>);
}
}
isActive(): boolean {
return this.isRunning;
}
/**
* Continuously monitors volume and calls the callback
*/
private startVolumeMonitoring(): void {
const update = () => {
if (!this.isRunning) {
return;
}
const volume = this.getVolumeLevel();
if (this.volumeCallback) {
this.volumeCallback(volume);
}
this.animationFrameId = requestAnimationFrame(update);
};
update();
}
}