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:
31
src/classes/AudioController.ts
Normal file
31
src/classes/AudioController.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
85
src/classes/VideoPlayer.ts
Normal file
85
src/classes/VideoPlayer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
26
src/classes/VideoSeekController.ts
Normal file
26
src/classes/VideoSeekController.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
23
src/classes/VideoSource.ts
Normal file
23
src/classes/VideoSource.ts
Normal 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
159
src/classes/VolumeMeter.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
117
src/classes/VolumeMeterVisualizer.ts
Normal file
117
src/classes/VolumeMeterVisualizer.ts
Normal 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';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
21
src/interfaces/IAudioController.ts
Normal file
21
src/interfaces/IAudioController.ts
Normal 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;
|
||||
}
|
||||
|
||||
42
src/interfaces/IVideoPlayer.ts
Normal file
42
src/interfaces/IVideoPlayer.ts
Normal 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;
|
||||
}
|
||||
|
||||
17
src/interfaces/IVideoSeekController.ts
Normal file
17
src/interfaces/IVideoSeekController.ts
Normal 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;
|
||||
}
|
||||
|
||||
18
src/interfaces/IVideoSource.ts
Normal file
18
src/interfaces/IVideoSource.ts
Normal 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;
|
||||
}
|
||||
|
||||
38
src/interfaces/IVolumeMeter.ts
Normal file
38
src/interfaces/IVolumeMeter.ts
Normal 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
80
src/main.ts
Normal 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
84
src/styles.css
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user