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

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

14
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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"
}
}

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

26
tsconfig.json Normal file
View 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"]
}