commit 1fd455d29b369831ec760a2b8c716098c9e1d976 Author: Alexander Zinn Date: Wed Dec 17 22:33:35 2025 -0500 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -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? diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..56d9e78 --- /dev/null +++ b/Dockerfile @@ -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;"] \ No newline at end of file diff --git a/SOLID_PRINCIPLES.md b/SOLID_PRINCIPLES.md new file mode 100644 index 0000000..be019ca --- /dev/null +++ b/SOLID_PRINCIPLES.md @@ -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 +``` + diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..e75fcee --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9ae1bcf --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..15f8030 --- /dev/null +++ b/index.html @@ -0,0 +1,24 @@ + + + + + + + Biscayne Bay Stream + + +
+ + +
+ +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..0a900a5 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/classes/AudioController.ts b/src/classes/AudioController.ts new file mode 100644 index 0000000..d02c719 --- /dev/null +++ b/src/classes/AudioController.ts @@ -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; + } +} + diff --git a/src/classes/VideoPlayer.ts b/src/classes/VideoPlayer.ts new file mode 100644 index 0000000..6198272 --- /dev/null +++ b/src/classes/VideoPlayer.ts @@ -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; + } +} + diff --git a/src/classes/VideoSeekController.ts b/src/classes/VideoSeekController.ts new file mode 100644 index 0000000..c848891 --- /dev/null +++ b/src/classes/VideoSeekController.ts @@ -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; + } +} + diff --git a/src/classes/VideoSource.ts b/src/classes/VideoSource.ts new file mode 100644 index 0000000..41d5990 --- /dev/null +++ b/src/classes/VideoSource.ts @@ -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; + } +} + diff --git a/src/classes/VolumeMeter.ts b/src/classes/VolumeMeter.ts new file mode 100644 index 0000000..d021a7e --- /dev/null +++ b/src/classes/VolumeMeter.ts @@ -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 { + 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); + } + } + + getTimeDomainData(array: Uint8Array): void { + if (this.analyser) { + this.analyser.getByteTimeDomainData(array as Uint8Array); + } + } + + 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(); + } +} + diff --git a/src/classes/VolumeMeterVisualizer.ts b/src/classes/VolumeMeterVisualizer.ts new file mode 100644 index 0000000..17612e8 --- /dev/null +++ b/src/classes/VolumeMeterVisualizer.ts @@ -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'; + }); + } +} + diff --git a/src/interfaces/IAudioController.ts b/src/interfaces/IAudioController.ts new file mode 100644 index 0000000..25c9088 --- /dev/null +++ b/src/interfaces/IAudioController.ts @@ -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; +} + diff --git a/src/interfaces/IVideoPlayer.ts b/src/interfaces/IVideoPlayer.ts new file mode 100644 index 0000000..39cf780 --- /dev/null +++ b/src/interfaces/IVideoPlayer.ts @@ -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; +} + diff --git a/src/interfaces/IVideoSeekController.ts b/src/interfaces/IVideoSeekController.ts new file mode 100644 index 0000000..83bc797 --- /dev/null +++ b/src/interfaces/IVideoSeekController.ts @@ -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; +} + diff --git a/src/interfaces/IVideoSource.ts b/src/interfaces/IVideoSource.ts new file mode 100644 index 0000000..b277b6d --- /dev/null +++ b/src/interfaces/IVideoSource.ts @@ -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; +} + diff --git a/src/interfaces/IVolumeMeter.ts b/src/interfaces/IVolumeMeter.ts new file mode 100644 index 0000000..ce697a8 --- /dev/null +++ b/src/interfaces/IVolumeMeter.ts @@ -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; + + /** + * 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; +} + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..74863f8 --- /dev/null +++ b/src/main.ts @@ -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); +}); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..3bba5b6 --- /dev/null +++ b/src/styles.css @@ -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); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4ba8dd9 --- /dev/null +++ b/tsconfig.json @@ -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"] +}