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:
24
.gitignore
vendored
Normal file
24
.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?
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM nginx:latest
|
||||||
|
|
||||||
|
COPY dist/ /usr/share/nginx/html
|
||||||
|
|
||||||
|
LABEL traefik.enable=true
|
||||||
|
LABEL traefik.http.routers.techniker-me.entrypoints=https
|
||||||
|
LABEL traefik.http.routers.techniker-me.rule=Host(`techniker.me`)
|
||||||
|
LABEL traefik.http.routers.techniker-me.tls=true
|
||||||
|
LABEL traefik.http.routers.techniker-me.tls.certresolver=cloudflare
|
||||||
|
LABEL traefik.http.services.techniker-me.loadbalancer.server.port=80
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
83
SOLID_PRINCIPLES.md
Normal file
83
SOLID_PRINCIPLES.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# SOLID Principles Implementation
|
||||||
|
|
||||||
|
This project has been refactored to follow the SOLID principles of Object-Oriented Programming.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The codebase is organized into:
|
||||||
|
- **Interfaces** (`src/interfaces/`) - Define contracts following Interface Segregation Principle
|
||||||
|
- **Classes** (`src/classes/`) - Implementations following Single Responsibility Principle
|
||||||
|
- **Main** (`src/main.ts`) - Entry point that uses Dependency Inversion Principle
|
||||||
|
|
||||||
|
## SOLID Principles Applied
|
||||||
|
|
||||||
|
### 1. Single Responsibility Principle (SRP)
|
||||||
|
Each class has one reason to change:
|
||||||
|
|
||||||
|
- **`VideoSource`**: Only responsible for managing video source elements
|
||||||
|
- **`VideoSeekController`**: Only responsible for seeking operations
|
||||||
|
- **`AudioController`**: Only responsible for audio control (mute/unmute)
|
||||||
|
- **`VideoPlayer`**: Only responsible for orchestrating the components
|
||||||
|
|
||||||
|
### 2. Open/Closed Principle (OCP)
|
||||||
|
The system is open for extension but closed for modification:
|
||||||
|
|
||||||
|
- New video source types can be added by implementing `IVideoSource`
|
||||||
|
- New seeking strategies can be added by implementing `IVideoSeekController`
|
||||||
|
- New audio control mechanisms can be added by implementing `IAudioController`
|
||||||
|
- The `VideoPlayer` class doesn't need modification when adding new implementations
|
||||||
|
|
||||||
|
### 3. Liskov Substitution Principle (LSP)
|
||||||
|
Any implementation of an interface can be substituted without breaking functionality:
|
||||||
|
|
||||||
|
- Any class implementing `IVideoSource` can replace `VideoSource`
|
||||||
|
- Any class implementing `IVideoSeekController` can replace `VideoSeekController`
|
||||||
|
- Any class implementing `IAudioController` can replace `AudioController`
|
||||||
|
|
||||||
|
### 4. Interface Segregation Principle (ISP)
|
||||||
|
Interfaces are small and focused on specific behaviors:
|
||||||
|
|
||||||
|
- **`IVideoSource`**: Only methods related to video source management
|
||||||
|
- **`IVideoSeekController`**: Only methods related to seeking
|
||||||
|
- **`IAudioController`**: Only methods related to audio control
|
||||||
|
- **`IVideoPlayer`**: High-level interface for the complete player
|
||||||
|
|
||||||
|
### 5. Dependency Inversion Principle (DIP)
|
||||||
|
High-level modules depend on abstractions, not concrete implementations:
|
||||||
|
|
||||||
|
- `VideoPlayer` depends on interfaces (`IVideoSource`, `IVideoSeekController`, `IAudioController`)
|
||||||
|
- Concrete implementations are injected via constructor
|
||||||
|
- This allows easy testing and swapping of implementations
|
||||||
|
|
||||||
|
## Architecture Benefits
|
||||||
|
|
||||||
|
1. **Testability**: Each component can be tested in isolation
|
||||||
|
2. **Maintainability**: Changes to one component don't affect others
|
||||||
|
3. **Extensibility**: New features can be added without modifying existing code
|
||||||
|
4. **Flexibility**: Implementations can be swapped easily (e.g., for testing or different behaviors)
|
||||||
|
|
||||||
|
## Example: Adding a New Feature
|
||||||
|
|
||||||
|
To add a new video source type (e.g., DASH streaming):
|
||||||
|
|
||||||
|
1. Create a new class `DashVideoSource` implementing `IVideoSource`
|
||||||
|
2. Use it in `VideoPlayer` constructor: `this.videoSource = new DashVideoSource()`
|
||||||
|
3. No other code needs to change!
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── interfaces/
|
||||||
|
│ ├── IVideoSource.ts # Video source contract
|
||||||
|
│ ├── IVideoSeekController.ts # Seeking contract
|
||||||
|
│ ├── IAudioController.ts # Audio control contract
|
||||||
|
│ └── IVideoPlayer.ts # Main player contract
|
||||||
|
├── classes/
|
||||||
|
│ ├── VideoSource.ts # Video source implementation
|
||||||
|
│ ├── VideoSeekController.ts # Seeking implementation
|
||||||
|
│ ├── AudioController.ts # Audio control implementation
|
||||||
|
│ └── VideoPlayer.ts # Main player orchestrator
|
||||||
|
└── main.ts # Application entry point
|
||||||
|
```
|
||||||
|
|
||||||
111
bun.lock
Normal file
111
bun.lock
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "hls-player",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "npm:rolldown-vite@7.2.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"vite": "npm:rolldown-vite@7.2.5",
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
|
||||||
|
|
||||||
|
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
|
||||||
|
|
||||||
|
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||||
|
|
||||||
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.0", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA=="],
|
||||||
|
|
||||||
|
"@oxc-project/runtime": ["@oxc-project/runtime@0.97.0", "", {}, "sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w=="],
|
||||||
|
|
||||||
|
"@oxc-project/types": ["@oxc-project/types@0.97.0", "", {}, "sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.50", "", { "os": "android", "cpu": "arm64" }, "sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.50", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.50", "", { "os": "darwin", "cpu": "x64" }, "sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.50", "", { "os": "freebsd", "cpu": "x64" }, "sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50", "", { "os": "linux", "cpu": "arm" }, "sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50", "", { "os": "linux", "cpu": "arm64" }, "sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.50", "", { "os": "linux", "cpu": "arm64" }, "sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.50", "", { "os": "linux", "cpu": "x64" }, "sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.50", "", { "os": "linux", "cpu": "x64" }, "sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-beta.50", "", { "os": "none", "cpu": "arm64" }, "sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.50", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.7" }, "cpu": "none" }, "sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50", "", { "os": "win32", "cpu": "arm64" }, "sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-ia32-msvc": ["@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50", "", { "os": "win32", "cpu": "ia32" }, "sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.50", "", { "os": "win32", "cpu": "x64" }, "sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.50", "", {}, "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA=="],
|
||||||
|
|
||||||
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"rolldown": ["rolldown@1.0.0-beta.50", "", { "dependencies": { "@oxc-project/types": "=0.97.0", "@rolldown/pluginutils": "1.0.0-beta.50" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-beta.50", "@rolldown/binding-darwin-arm64": "1.0.0-beta.50", "@rolldown/binding-darwin-x64": "1.0.0-beta.50", "@rolldown/binding-freebsd-x64": "1.0.0-beta.50", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.50", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.50", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.50", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.50", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.50", "@rolldown/binding-openharmony-arm64": "1.0.0-beta.50", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.50", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.50", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.50", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.50" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"vite": ["rolldown-vite@7.2.5", "", { "dependencies": { "@oxc-project/runtime": "0.97.0", "fdir": "^6.5.0", "lightningcss": "^1.30.2", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-beta.50", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "esbuild": "^0.25.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
techniker-me:
|
||||||
|
image: techniker-me:2025.0.4
|
||||||
|
container_name: techniker-me
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- services_traefik-public
|
||||||
|
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.techniker-me.entrypoints=https
|
||||||
|
- traefik.http.routers.techniker-me.rule=Host(`techniker.me`)
|
||||||
|
- traefik.http.routers.techniker-me.tls=true
|
||||||
|
- traefik.http.routers.techniker-me.tls.certresolver=cloudflare
|
||||||
|
- traefik.http.services.techniker-me.loadbalancer.server.port=80
|
||||||
|
# --- ADD THESE LINES FOR CORS ---
|
||||||
|
# 1. Define the middleware
|
||||||
|
- "traefik.http.middlewares.hls-cors.headers.accesscontrolalloworiginlist=*"
|
||||||
|
- "traefik.http.middlewares.hls-cors.headers.accesscontrolallowmethods=GET,OPTIONS"
|
||||||
|
- "traefik.http.middlewares.hls-cors.headers.accesscontrolallowheaders=Content-Type"
|
||||||
|
|
||||||
|
# 2. Attach middleware to the router
|
||||||
|
- "traefik.http.routers.techniker-me.middlewares=hls-cors"
|
||||||
|
|
||||||
|
|
||||||
|
networks:
|
||||||
|
services_traefik-public:
|
||||||
|
external: true
|
||||||
24
index.html
Normal file
24
index.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link href="./src/styles.css" rel="stylesheet" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Biscayne Bay Stream</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<video id="video" style="width: 100vw;height: 100vh;background-color: black;" autoplay muted playsinline></video>
|
||||||
|
<button id="unmute-btn" class="unmute-button">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M11 5L6 9H2v6h4l5 4V5z"></path>
|
||||||
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Unmute Audio</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="volume-meter-container" class="volume-meter-container"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "hls-player",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.2",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "npm:rolldown-vite@7.2.5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"vite": "npm:rolldown-vite@7.2.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"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