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

View File

@@ -0,0 +1,31 @@
import type {IAudioController} from '../interfaces/IAudioController';
/**
* AudioController class
* Follows Single Responsibility Principle (SRP) - only responsible for audio control
*/
export class AudioController implements IAudioController {
private videoElement: HTMLVideoElement;
private unmuteButton: HTMLButtonElement;
constructor(videoElement: HTMLVideoElement, unmuteButton: HTMLButtonElement) {
this.videoElement = videoElement;
this.unmuteButton = unmuteButton;
}
unmute(): void {
this.videoElement.muted = false;
this.unmuteButton.classList.add('hidden');
}
mute(): void {
this.videoElement.muted = true;
this.unmuteButton.classList.remove('hidden');
console.log('[AudioController] Audio muted');
}
isMuted(): boolean {
return this.videoElement.muted;
}
}

View File

@@ -0,0 +1,85 @@
import type { IVideoPlayer } from '../interfaces/IVideoPlayer';
import type { IVideoSource } from '../interfaces/IVideoSource';
import type { IVideoSeekController } from '../interfaces/IVideoSeekController';
import type { IAudioController } from '../interfaces/IAudioController';
import type { IVolumeMeter } from '../interfaces/IVolumeMeter';
import { VideoSource } from './VideoSource';
import { VideoSeekController } from './VideoSeekController';
import { AudioController } from './AudioController';
import { VolumeMeter } from './VolumeMeter';
/**
* VideoPlayer class
* Follows:
* - Single Responsibility Principle (SRP) - orchestrates video playback
* - Open/Closed Principle (OCP) - open for extension via interfaces, closed for modification
* - Dependency Inversion Principle (DIP) - depends on abstractions (interfaces)
*/
export class VideoPlayer implements IVideoPlayer {
private videoElement: HTMLVideoElement;
private videoSource: IVideoSource;
private seekController: IVideoSeekController;
private audioController: IAudioController;
private volumeMeter: IVolumeMeter;
private unmuteButton: HTMLButtonElement;
constructor(videoElement: HTMLVideoElement, unmuteButton: HTMLButtonElement) {
this.videoElement = videoElement;
this.unmuteButton = unmuteButton;
// Dependency injection - following DIP
this.videoSource = new VideoSource();
this.seekController = new VideoSeekController(videoElement);
this.audioController = new AudioController(videoElement, unmuteButton);
this.volumeMeter = new VolumeMeter(videoElement);
this.setupEventListeners();
}
/**
* Initialize the video player with a stream URL
* Follows Open/Closed Principle - can be extended without modifying this class
*/
initialize(url: string): void {
this.videoSource.setSource(url, 'application/vnd.apple.mpegurl');
this.videoElement.appendChild(this.videoSource.getSourceElement());
console.log('[VideoPlayer] Initialized with URL:', url);
}
/**
* Setup event listeners for video events
* Private method - encapsulation
*/
private setupEventListeners(): void {
this.videoElement.addEventListener('loadedmetadata', () => {
console.log('[VideoPlayer] Video metadata loaded');
this.seekController.seekToLiveEdge(1.0);
});
// Setup unmute button handler
this.unmuteButton.addEventListener('click', () => {
this.audioController.unmute();
});
}
getVideoSource(): IVideoSource {
return this.videoSource;
}
getSeekController(): IVideoSeekController {
return this.seekController;
}
getAudioController(): IAudioController {
return this.audioController;
}
getVolumeMeter(): IVolumeMeter {
return this.volumeMeter;
}
getVideoElement(): HTMLVideoElement {
return this.videoElement;
}
}

View File

@@ -0,0 +1,26 @@
import type { IVideoSeekController } from '../interfaces/IVideoSeekController';
/**
* VideoSeekController class
* Follows Single Responsibility Principle (SRP) - only responsible for seeking operations
*/
export class VideoSeekController implements IVideoSeekController {
private videoElement: HTMLVideoElement;
constructor(videoElement: HTMLVideoElement) {
this.videoElement = videoElement;
}
seekToLiveEdge(offsetSeconds: number = 0.5): void {
if (this.isSeekable()) {
const liveEdge = this.videoElement.seekable.end(0);
this.videoElement.currentTime = liveEdge - offsetSeconds;
console.log(`[VideoSeekController] Seeking to live edge (offset: ${offsetSeconds}s)`);
}
}
isSeekable(): boolean {
return this.videoElement.seekable.length > 0;
}
}

View File

@@ -0,0 +1,23 @@
import type { IVideoSource } from '../interfaces/IVideoSource';
/**
* VideoSource class
* Follows Single Responsibility Principle (SRP) - only responsible for managing video source
*/
export class VideoSource implements IVideoSource {
private sourceElement: HTMLSourceElement;
constructor() {
this.sourceElement = document.createElement('source');
}
setSource(url: string, type: string): void {
this.sourceElement.src = url;
this.sourceElement.type = type;
}
getSourceElement(): HTMLSourceElement {
return this.sourceElement;
}
}

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

View File

@@ -0,0 +1,117 @@
import type { IVolumeMeter } from '../interfaces/IVolumeMeter';
/**
* VolumeMeterVisualizer class
* Follows Single Responsibility Principle (SRP) - only responsible for visualizing volume data
*/
export class VolumeMeterVisualizer {
private container: HTMLElement;
private volumeMeter: IVolumeMeter;
private bars: HTMLElement[] = [];
private barCount: number;
private animationFrameId: number | null = null;
constructor(container: HTMLElement, volumeMeter: IVolumeMeter, barCount: number = 20) {
this.container = container;
this.volumeMeter = volumeMeter;
this.barCount = barCount;
this.createBars();
}
/**
* Creates the visual bar elements
*/
private createBars(): void {
this.container.innerHTML = '';
this.bars = [];
for (let i = 0; i < this.barCount; i++) {
const bar = document.createElement('div');
bar.className = 'volume-bar';
this.container.appendChild(bar);
this.bars.push(bar);
}
}
/**
* Starts the visualization animation
*/
start(): void {
if (this.animationFrameId !== null) {
return;
}
const update = () => {
if (!this.volumeMeter.isActive()) {
// Keep running even if not active, but show minimal activity so bars are visible
this.bars.forEach(bar => {
bar.style.height = '10%';
bar.style.opacity = '0.4';
});
this.animationFrameId = requestAnimationFrame(update);
return;
}
// Get volume level
const volume = this.volumeMeter.getVolumeLevel();
// Update each bar based on volume and frequency
// Use the correct size based on analyser's frequencyBinCount (fftSize / 2)
const frequencyArray = new Uint8Array(256);
this.volumeMeter.getFrequencyData(frequencyArray);
// Check if we're getting any actual audio data (CORS check)
const hasAudioData = frequencyArray.some(value => value > 0) || volume > 0.001;
if (!hasAudioData) {
// If no audio data (likely CORS issue), show a subtle pulsing animation
const time = Date.now() / 1000;
this.bars.forEach((bar, index) => {
const pulse = Math.sin(time * 2 + index * 0.3) * 0.3 + 0.5;
bar.style.height = `${10 + pulse * 15}%`;
bar.style.opacity = `${0.3 + pulse * 0.2}`;
});
this.animationFrameId = requestAnimationFrame(update);
return;
}
this.bars.forEach((bar, index) => {
// Map bar index to frequency bin with some overlap for smoother visualization
const frequencyIndex = Math.floor((index / this.barCount) * frequencyArray.length);
const frequencyValue = frequencyArray[frequencyIndex] || 0;
// Calculate height with better scaling - ensure minimum visibility
const normalizedFreq = frequencyValue / 255;
// Scale more aggressively for better visibility
const height = Math.max(normalizedFreq * 100, volume * 30, 8); // At least 8% when active
bar.style.height = `${Math.min(height, 100)}%`;
// Add color and opacity based on volume level - more dynamic
const intensity = Math.max(normalizedFreq, volume);
bar.style.opacity = `${0.5 + intensity * 0.5}`;
});
this.animationFrameId = requestAnimationFrame(update);
};
update();
}
/**
* Stops the visualization animation
*/
stop(): void {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Reset all bars
this.bars.forEach(bar => {
bar.style.height = '0%';
bar.style.opacity = '0.3';
});
}
}

View File

@@ -0,0 +1,21 @@
/**
* Interface for audio control operations
* Follows Interface Segregation Principle (ISP) - focused on audio behavior
*/
export interface IAudioController {
/**
* Unmutes the video
*/
unmute(): void;
/**
* Mutes the video
*/
mute(): void;
/**
* Checks if the video is muted
*/
isMuted(): boolean;
}

View File

@@ -0,0 +1,42 @@
import type { IVideoSource } from './IVideoSource';
import type { IVideoSeekController } from './IVideoSeekController';
import type { IAudioController } from './IAudioController';
import type { IVolumeMeter } from './IVolumeMeter';
/**
* Main interface for video player
* Follows Dependency Inversion Principle (DIP) - depends on abstractions
*/
export interface IVideoPlayer {
/**
* Initializes the video player with a stream URL
* @param url - The URL of the video stream
*/
initialize(url: string): void;
/**
* Gets the video source controller
*/
getVideoSource(): IVideoSource;
/**
* Gets the seek controller
*/
getSeekController(): IVideoSeekController;
/**
* Gets the audio controller
*/
getAudioController(): IAudioController;
/**
* Gets the volume meter
*/
getVolumeMeter(): IVolumeMeter;
/**
* Gets the underlying video element
*/
getVideoElement(): HTMLVideoElement;
}

View File

@@ -0,0 +1,17 @@
/**
* Interface for video seeking operations
* Follows Interface Segregation Principle (ISP) - focused on seeking behavior
*/
export interface IVideoSeekController {
/**
* Seeks to the live edge of the video stream
* @param offsetSeconds - Offset from live edge in seconds (default: 1.0)
*/
seekToLiveEdge(offsetSeconds?: number): void;
/**
* Checks if the video is seekable
*/
isSeekable(): boolean;
}

View File

@@ -0,0 +1,18 @@
/**
* Interface for video source management
* Follows Interface Segregation Principle (ISP) - focused, single-purpose interface
*/
export interface IVideoSource {
/**
* Sets the video source URL
* @param url - The URL of the video stream
* @param type - The MIME type of the video source
*/
setSource(url: string, type: string): void;
/**
* Gets the current source element
*/
getSourceElement(): HTMLSourceElement;
}

View File

@@ -0,0 +1,38 @@
/**
* Interface for volume metering and audio analysis
* Follows Interface Segregation Principle (ISP) - focused on audio analysis
*/
export interface IVolumeMeter {
/**
* Starts the volume meter analysis
*/
start(): Promise<void>;
/**
* Stops the volume meter analysis
*/
stop(): void;
/**
* Gets the current volume level (0-1)
*/
getVolumeLevel(): number;
/**
* Gets the frequency data for visualization
* @param array - Uint8Array to fill with frequency data
*/
getFrequencyData(array: Uint8Array): void;
/**
* Gets the time domain data for waveform visualization
* @param array - Uint8Array to fill with time domain data
*/
getTimeDomainData(array: Uint8Array): void;
/**
* Checks if the volume meter is currently active
*/
isActive(): boolean;
}

80
src/main.ts Normal file
View File

@@ -0,0 +1,80 @@
import { VideoPlayer } from './classes/VideoPlayer';
import { VolumeMeterVisualizer } from './classes/VolumeMeterVisualizer';
const playlistUrl = "https://techniker.me/hls/master.m3u8";
window.addEventListener('load', () => {
const videoElement = document.querySelector('video') as HTMLVideoElement;
const unmuteButton = document.querySelector('#unmute-btn') as HTMLButtonElement;
const volumeMeterContainer = document.querySelector('#volume-meter-container') as HTMLElement;
if (!volumeMeterContainer) {
console.error('[main] Volume meter container not found');
return;
}
// Create VideoPlayer instance following SOLID principles
// - Single Responsibility: Each class has one responsibility
// - Open/Closed: Can extend via interfaces without modifying existing code
// - Liskov Substitution: Can substitute implementations via interfaces
// - Interface Segregation: Small, focused interfaces
// - Dependency Inversion: Depends on abstractions (interfaces)
const player = new VideoPlayer(videoElement, unmuteButton);
// Initialize with the playlist URL
player.initialize(playlistUrl);
// Setup volume meter visualizer
const volumeMeter = player.getVolumeMeter();
// Ensure container is visible
volumeMeterContainer.style.display = 'flex';
volumeMeterContainer.style.visibility = 'visible';
const visualizer = new VolumeMeterVisualizer(volumeMeterContainer, volumeMeter, 20);
// Start visualizer immediately (it will show minimal activity until volume meter is active)
visualizer.start();
// Start volume meter when video metadata is loaded and starts playing
const startVolumeMeter = async () => {
if (!volumeMeter.isActive()) {
try {
// Check if video is unmuted (required for Web Audio API)
if (videoElement.muted) {
console.log('[main] Video is muted, volume meter needs unmuted audio');
return;
}
await volumeMeter.start();
console.log('[main] Volume meter started, isActive:', volumeMeter.isActive());
} catch (error) {
console.warn('[main] Could not start volume meter:', error);
}
}
};
// Start when video starts playing
videoElement.addEventListener('play', () => {
setTimeout(startVolumeMeter, 300);
});
// Start when metadata is loaded (for autoplay)
videoElement.addEventListener('loadedmetadata', () => {
setTimeout(startVolumeMeter, 500);
});
// Also start when audio is unmuted (Web Audio API needs unmuted audio)
unmuteButton.addEventListener('click', () => {
setTimeout(() => {
startVolumeMeter();
}, 200);
});
// Try to start after a delay if video is already playing
setTimeout(() => {
if (!videoElement.paused && !volumeMeter.isActive()) {
startVolumeMeter();
}
}, 2000);
});

84
src/styles.css Normal file
View File

@@ -0,0 +1,84 @@
body {
margin: 0;
padding: 0;
}
.unmute-button {
position: fixed;
bottom: 2rem;
right: 2rem;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 2rem 3rem;
background: linear-gradient(135deg, #0a1338 0%, #ac8bcd 100%);
color: white;
border: none;
border-radius: 50px;
font-size: 2rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.4);
transition: all 0.3s ease;
z-index: 1000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.unmute-button:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(102, 126, 234, 0.5);
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
}
.unmute-button:active {
transform: translateY(0);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3);
}
.unmute-button svg {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.unmute-button.hidden {
opacity: 0;
pointer-events: none;
transform: scale(0.8);
transition: all 0.3s ease;
}
#volume-meter-container,
.volume-meter-container {
position: fixed !important;
bottom: 2rem !important;
left: 2rem !important;
display: flex !important;
align-items: flex-end;
gap: 0.3rem;
height: 120px;
width: auto;
min-width: 200px;
z-index: 9999 !important;
padding: 1rem;
background: rgba(0, 0, 0, 0.7) !important;
backdrop-filter: blur(10px);
border-radius: 12px;
border: 2px solid rgba(102, 126, 234, 0.5) !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
visibility: visible !important;
opacity: 1 !important;
pointer-events: none; /* Allow clicks to pass through to video */
}
.volume-bar {
flex: 1;
min-width: 6px;
max-width: 8px;
background: linear-gradient(to top, #667eea 0%, #764ba2 50%, #ac8bcd 100%);
border-radius: 3px;
transition: height 0.05s ease, opacity 0.05s ease;
height: 10%;
opacity: 0.5;
box-shadow: 0 0 8px rgba(102, 126, 234, 0.6);
}