release: v3.0.0
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
*.tgz
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||||
|
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.0.0] - 2026-02-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `PlayerErrorBoundary` (`src/components/ErrorBoundary.tsx`) and exported it from the public API.
|
||||||
|
- Added release validation pipeline scripts: `typecheck`, `validate:publish`, and `prepublishOnly`.
|
||||||
|
- Added Prettier setup: `.prettierrc`, `.prettierignore`, `format`, and `format:check`.
|
||||||
|
- Added `CHANGELOG.md` for release notes tracking.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Namespaced player CSS classes and keyframes with `sp-` prefix for stronger style isolation.
|
||||||
|
- Enabled strict TypeScript mode for library build via `tsconfig.lib.json`.
|
||||||
|
- Tightened ESLint rules for production code (`no-explicit-any` and `ban-ts-comment` warnings).
|
||||||
|
- Centralized browser/vendor and streaming runtime typings in `src/types/vendor.d.ts`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Removed remaining production `any` usage in HLS, FLV/RTMP, and MPEG-TS setup/control utilities.
|
||||||
|
- Updated internal tests and selectors to reflect namespaced CSS classes.
|
||||||
|
- Patched vulnerable transitive dependencies via `npm audit fix` (`@isaacs/brace-expansion`, `js-yaml`, `lodash`).
|
||||||
|
|
||||||
|
## [2.0.0]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial modern React video player release with HLS, FLV/RTMP, and MPEG-TS support.
|
||||||
@@ -110,11 +110,14 @@ pnpm add @source/player
|
|||||||
yarn add @source/player
|
yarn add @source/player
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Ensure peer dependencies
|
> **Note:** This package requires `react` (>=18) and `react-dom` (>=18) at runtime but does **not** list them as `peerDependencies` to avoid install conflicts with private registries. Make sure your project already has React installed.
|
||||||
|
|
||||||
```bash
|
> **Streaming libraries (optional):** HLS, FLV and MPEG-TS streaming libraries are loaded automatically from CDN when needed. If you prefer to bundle them locally, install them separately:
|
||||||
npm install react react-dom
|
> ```bash
|
||||||
```
|
> npm install hls.js # HLS (.m3u8) streams
|
||||||
|
> npm install flv.js # FLV/RTMP streams
|
||||||
|
> npm install mpegts.js # MPEG-TS (.ts) streams
|
||||||
|
> ```
|
||||||
|
|
||||||
### Local development (optional)
|
### Local development (optional)
|
||||||
|
|
||||||
@@ -145,6 +148,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Error Boundary (Optional)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { VideoPlayer, PlayerErrorBoundary } from '@source/player'
|
||||||
|
import '@source/player/styles.css'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<PlayerErrorBoundary>
|
||||||
|
<VideoPlayer src="https://example.com/video.mp4" />
|
||||||
|
</PlayerErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### With Subtitles
|
### With Subtitles
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -305,6 +323,12 @@ npm run build:lib
|
|||||||
|
|
||||||
# Type check
|
# Type check
|
||||||
npx tsc --noEmit
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
# Format check
|
||||||
|
npm run format:check
|
||||||
|
|
||||||
|
# Full publish validation
|
||||||
|
npm run validate:publish
|
||||||
```
|
```
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
@@ -382,6 +406,16 @@ video-player/
|
|||||||
| `onWaiting` | `() => void` | Fired when buffering starts |
|
| `onWaiting` | `() => void` | Fired when buffering starts |
|
||||||
| `onCanPlay` | `() => void` | Fired when enough data is available to play |
|
| `onCanPlay` | `() => void` | Fired when enough data is available to play |
|
||||||
|
|
||||||
|
### PlayerErrorBoundary Props
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `children` | `ReactNode` | Wrapped player/content tree |
|
||||||
|
| `fallback` | `ReactNode \| (error: Error, retry: () => void) => ReactNode` | Optional custom fallback UI |
|
||||||
|
| `onError` | `(error: Error, errorInfo: React.ErrorInfo) => void` | Called when render errors are captured |
|
||||||
|
| `onReset` | `() => void` | Called when retry/reset is triggered |
|
||||||
|
| `resetKeys` | `readonly unknown[]` | Resets boundary when any key changes |
|
||||||
|
|
||||||
### SubtitleTrack
|
### SubtitleTrack
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -418,7 +452,7 @@ interface PlayerTheme {
|
|||||||
- Core player CSS bundle: **~3.5KB** (gzipped)
|
- Core player CSS bundle: **~3.5KB** (gzipped)
|
||||||
- HLS.js (optional, lazy-loaded): **~200KB** (only when using HLS streams)
|
- HLS.js (optional, lazy-loaded): **~200KB** (only when using HLS streams)
|
||||||
- MPEGTS.js (optional, lazy-loaded): **~72KB** (gzipped, only for `.ts` streams)
|
- MPEGTS.js (optional, lazy-loaded): **~72KB** (gzipped, only for `.ts` streams)
|
||||||
- Zero runtime dependencies (React is peer dependency)
|
- Zero runtime dependencies (React is a prerequisite, see installation notes)
|
||||||
|
|
||||||
## 🔧 Technical Details
|
## 🔧 Technical Details
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -33,8 +33,13 @@ export default [
|
|||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...tsPlugin.configs.recommended.rules,
|
...tsPlugin.configs.recommended.rules,
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'no-undef': 'off',
|
||||||
'@typescript-eslint/ban-ts-comment': 'off',
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/ban-ts-comment': ['warn', {
|
||||||
|
'ts-ignore': 'allow-with-description',
|
||||||
|
'ts-expect-error': 'allow-with-description',
|
||||||
|
minimumDescriptionLength: 6
|
||||||
|
}],
|
||||||
'@typescript-eslint/no-unused-vars': ['error', {
|
'@typescript-eslint/no-unused-vars': ['error', {
|
||||||
argsIgnorePattern: '^_',
|
argsIgnorePattern: '^_',
|
||||||
varsIgnorePattern: '^_'
|
varsIgnorePattern: '^_'
|
||||||
@@ -44,6 +49,13 @@ export default [
|
|||||||
'react-refresh/only-export-components': 'warn'
|
'react-refresh/only-export-components': 'warn'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.test.{ts,tsx}', 'src/test/**/*.{ts,tsx}'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/ban-ts-comment': 'off'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.config.{js,ts}', '**/*.config.{cjs,mjs}', 'vite.config.*', 'eslint.config.js'],
|
files: ['**/*.config.{js,ts}', '**/*.config.{cjs,mjs}', 'vite.config.*', 'eslint.config.js'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
|||||||
Generated
+5057
File diff suppressed because it is too large
Load Diff
+9
-11
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@source/player",
|
"name": "@source/player",
|
||||||
"version": "2.0.0",
|
"version": "3.0.0",
|
||||||
"description": "Modern, feature-rich video player library for React",
|
"description": "Modern, feature-rich video player library for React",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/video-player.umd.cjs",
|
"main": "./dist/video-player.umd.cjs",
|
||||||
@@ -22,14 +22,16 @@
|
|||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"build:lib": "vite build --config vite.config.lib.ts",
|
"build:lib": "vite build --config vite.config.lib.ts",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"test:coverage": "vitest --coverage"
|
"test:coverage": "vitest --coverage",
|
||||||
},
|
"validate:publish": "npm run lint && npm run test:run && npm run typecheck && npm run build:lib && npm pack --dry-run",
|
||||||
"peerDependencies": {
|
"prepublishOnly": "npm run validate:publish"
|
||||||
"react": "^18.0.0",
|
|
||||||
"react-dom": "^18.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.38.0",
|
"@eslint/js": "^9.38.0",
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.4.0",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.0.1",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"terser": "^5.44.0",
|
"terser": "^5.44.0",
|
||||||
@@ -55,11 +58,6 @@
|
|||||||
"vite-plugin-dts": "^4.5.4",
|
"vite-plugin-dts": "^4.5.4",
|
||||||
"vitest": "^4.0.4"
|
"vitest": "^4.0.4"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
|
||||||
"flv.js": "^1.6.2",
|
|
||||||
"hls.js": "^1.6.13",
|
|
||||||
"mpegts.js": "^1.7.3"
|
|
||||||
},
|
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"react",
|
"react",
|
||||||
"video",
|
"video",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.controls-layer {
|
.sp-controls-layer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: var(--player-z-controls);
|
z-index: var(--player-z-controls);
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
transition: opacity var(--player-transition-normal) ease;
|
transition: opacity var(--player-transition-normal) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-layer::before {
|
.sp-controls-layer::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: auto 0 0;
|
inset: auto 0 0;
|
||||||
@@ -27,29 +27,29 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-layer > * {
|
.sp-controls-layer > * {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-layer > .center-play-overlay,
|
.sp-controls-layer > .sp-center-play-overlay,
|
||||||
.controls-layer > .loading-spinner-overlay {
|
.sp-controls-layer > .sp-loading-spinner-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-layer.hidden.playing {
|
.sp-controls-layer.hidden.playing {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-layer.hidden.playing::before {
|
.sp-controls-layer.hidden.playing::before {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-layer.fullscreen.hidden.playing {
|
.sp-controls-layer.fullscreen.hidden.playing {
|
||||||
cursor: none;
|
cursor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-bar {
|
.sp-controls-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--player-spacing-sm);
|
gap: var(--player-spacing-sm);
|
||||||
@@ -58,35 +58,35 @@
|
|||||||
transition: transform var(--player-transition-normal) ease;
|
transition: transform var(--player-transition-normal) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-layer.hidden.playing .controls-bar {
|
.sp-controls-layer.hidden.playing .sp-controls-bar {
|
||||||
transform: translateY(12px);
|
transform: translateY(12px);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-container {
|
.sp-progress-container {
|
||||||
margin-bottom: var(--player-spacing-xs);
|
margin-bottom: var(--player-spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-row {
|
.sp-controls-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--player-spacing-sm);
|
gap: var(--player-spacing-sm);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-left,
|
.sp-controls-left,
|
||||||
.controls-right {
|
.sp-controls-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--player-spacing-sm);
|
gap: var(--player-spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-right {
|
.sp-controls-right {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Live indicator */
|
/* Live indicator */
|
||||||
.live-indicator {
|
.sp-live-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -101,15 +101,15 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-dot {
|
.sp-live-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: rgb(220, 38, 38);
|
background: rgb(220, 38, 38);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: live-pulse 2s ease-in-out infinite;
|
animation: sp-live-pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes live-pulse {
|
@keyframes sp-live-pulse {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
@@ -120,32 +120,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-text {
|
.sp-live-text {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.controls-bar {
|
.sp-controls-bar {
|
||||||
padding: var(--player-spacing-md) var(--player-spacing-md) var(--player-spacing-sm);
|
padding: var(--player-spacing-md) var(--player-spacing-md) var(--player-spacing-sm);
|
||||||
gap: var(--player-spacing-sm);
|
gap: var(--player-spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-row {
|
.sp-controls-row {
|
||||||
gap: var(--player-spacing-xs);
|
gap: var(--player-spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-left,
|
.sp-controls-left,
|
||||||
.controls-right {
|
.sp-controls-right {
|
||||||
gap: var(--player-spacing-xs);
|
gap: var(--player-spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-indicator {
|
.sp-live-indicator {
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-dot {
|
.sp-live-dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
|||||||
// Don't handle clicks on control buttons or other interactive elements
|
// Don't handle clicks on control buttons or other interactive elements
|
||||||
const isClickableArea =
|
const isClickableArea =
|
||||||
target === currentTarget ||
|
target === currentTarget ||
|
||||||
target.classList.contains('center-play-overlay') ||
|
target.classList.contains('sp-center-play-overlay') ||
|
||||||
target.classList.contains('controls-layer')
|
target.classList.contains('sp-controls-layer')
|
||||||
|
|
||||||
if (!isClickableArea) {
|
if (!isClickableArea) {
|
||||||
return
|
return
|
||||||
@@ -202,7 +202,7 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
|||||||
[togglePlay, toggleFullscreen]
|
[togglePlay, toggleFullscreen]
|
||||||
)
|
)
|
||||||
|
|
||||||
const controlsClassName = `controls-layer ${uiState.controlsVisible ? 'visible' : 'hidden'} ${
|
const controlsClassName = `sp-controls-layer ${uiState.controlsVisible ? 'visible' : 'hidden'} ${
|
||||||
videoState.playing ? 'playing' : 'paused'
|
videoState.playing ? 'playing' : 'paused'
|
||||||
} ${videoState.fullscreen ? 'fullscreen' : 'windowed'}`
|
} ${videoState.fullscreen ? 'fullscreen' : 'windowed'}`
|
||||||
|
|
||||||
@@ -222,32 +222,32 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
|||||||
{!videoState.playing && !videoState.loading && <CenterPlayButton />}
|
{!videoState.playing && !videoState.loading && <CenterPlayButton />}
|
||||||
|
|
||||||
{/* Bottom controls bar */}
|
{/* Bottom controls bar */}
|
||||||
<div className="controls-bar">
|
<div className="sp-controls-bar">
|
||||||
{/* Progress bar (full width on top) - hidden for live broadcasts */}
|
{/* Progress bar (full width on top) - hidden for live broadcasts */}
|
||||||
{!videoState.isLiveBroadcast && (
|
{!videoState.isLiveBroadcast && (
|
||||||
<div className="progress-container">
|
<div className="sp-progress-container">
|
||||||
<ProgressBar />
|
<ProgressBar />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Control buttons */}
|
{/* Control buttons */}
|
||||||
<div className="controls-row">
|
<div className="sp-controls-row">
|
||||||
<div className="controls-left">
|
<div className="sp-controls-left">
|
||||||
<PlayPauseButton />
|
<PlayPauseButton />
|
||||||
{features.hasVolumeControl() && <VolumeControl />}
|
{features.hasVolumeControl() && <VolumeControl />}
|
||||||
{/* Time display - hidden for live broadcasts */}
|
{/* Time display - hidden for live broadcasts */}
|
||||||
{!videoState.isLiveBroadcast && <TimeDisplay />}
|
{!videoState.isLiveBroadcast && <TimeDisplay />}
|
||||||
{/* Show "LIVE" badge for live broadcasts */}
|
{/* Show "LIVE" badge for live broadcasts */}
|
||||||
{videoState.isLiveBroadcast && (
|
{videoState.isLiveBroadcast && (
|
||||||
<div className="live-indicator">
|
<div className="sp-live-indicator">
|
||||||
<span className="live-dot"></span>
|
<span className="sp-live-dot"></span>
|
||||||
<span className="live-text">{translations.live}</span>
|
<span className="sp-live-text">{translations.live}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{controlsLeftExtra}
|
{controlsLeftExtra}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="controls-right">
|
<div className="sp-controls-right">
|
||||||
{controlsRightExtra}
|
{controlsRightExtra}
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
.sp-error-boundary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 220px;
|
||||||
|
padding: var(--player-spacing-xl);
|
||||||
|
border-radius: var(--player-radius);
|
||||||
|
background: var(--player-bg);
|
||||||
|
color: var(--player-text);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-error-boundary-content {
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-error-boundary-title {
|
||||||
|
margin: 0 0 var(--player-spacing-sm);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-error-boundary-message {
|
||||||
|
margin: 0 0 var(--player-spacing-lg);
|
||||||
|
color: var(--player-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-error-boundary-retry {
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--player-radius-sm);
|
||||||
|
background: var(--player-primary);
|
||||||
|
color: var(--player-text);
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--player-transition-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-error-boundary-retry:hover {
|
||||||
|
background: var(--player-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-error-boundary-retry:focus-visible {
|
||||||
|
outline: 2px solid var(--player-text);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { PlayerErrorBoundary } from './ErrorBoundary'
|
||||||
|
|
||||||
|
const ThrowOnRender: React.FC<{ message?: string }> = ({ message = 'boom' }) => {
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PlayerErrorBoundary', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders fallback UI when a child throws', () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PlayerErrorBoundary>
|
||||||
|
<ThrowOnRender />
|
||||||
|
</PlayerErrorBoundary>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Player failed to render')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('boom')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onError when a render error is captured', () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
|
||||||
|
const onError = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PlayerErrorBoundary onError={onError}>
|
||||||
|
<ThrowOnRender message="render-failure" />
|
||||||
|
</PlayerErrorBoundary>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(onError).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onError).toHaveBeenCalledWith(expect.any(Error), expect.any(Object))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets when resetKeys change', () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<PlayerErrorBoundary resetKeys={['first']}>
|
||||||
|
<ThrowOnRender message="first-error" />
|
||||||
|
</PlayerErrorBoundary>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('first-error')).toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<PlayerErrorBoundary resetKeys={['second']}>
|
||||||
|
<div>Recovered</div>
|
||||||
|
</PlayerErrorBoundary>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Recovered')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import './ErrorBoundary.css'
|
||||||
|
|
||||||
|
export type PlayerErrorBoundaryFallbackRender = (
|
||||||
|
error: Error,
|
||||||
|
retry: () => void
|
||||||
|
) => React.ReactNode
|
||||||
|
|
||||||
|
export interface PlayerErrorBoundaryProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
fallback?: React.ReactNode | PlayerErrorBoundaryFallbackRender
|
||||||
|
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
|
||||||
|
onReset?: () => void
|
||||||
|
resetKeys?: ReadonlyArray<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerErrorBoundaryState {
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const didResetKeysChange = (
|
||||||
|
previousKeys: ReadonlyArray<unknown> = [],
|
||||||
|
nextKeys: ReadonlyArray<unknown> = []
|
||||||
|
): boolean => {
|
||||||
|
if (previousKeys.length !== nextKeys.length) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < previousKeys.length; index += 1) {
|
||||||
|
if (!Object.is(previousKeys[index], nextKeys[index])) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlayerErrorBoundary extends React.Component<
|
||||||
|
PlayerErrorBoundaryProps,
|
||||||
|
PlayerErrorBoundaryState
|
||||||
|
> {
|
||||||
|
public state: PlayerErrorBoundaryState = {
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): PlayerErrorBoundaryState {
|
||||||
|
return { error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||||
|
this.props.onError?.(error, errorInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(previousProps: Readonly<PlayerErrorBoundaryProps>): void {
|
||||||
|
if (
|
||||||
|
this.state.error &&
|
||||||
|
didResetKeysChange(previousProps.resetKeys, this.props.resetKeys)
|
||||||
|
) {
|
||||||
|
this.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset = (): void => {
|
||||||
|
this.setState({ error: null })
|
||||||
|
this.props.onReset?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDefaultFallback(error: Error): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="sp-error-boundary" role="alert" aria-live="assertive">
|
||||||
|
<div className="sp-error-boundary-content">
|
||||||
|
<p className="sp-error-boundary-title">Player failed to render</p>
|
||||||
|
<p className="sp-error-boundary-message">
|
||||||
|
{error.message || 'An unexpected error occurred while rendering the player.'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sp-error-boundary-retry"
|
||||||
|
onClick={this.reset}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
const { error } = this.state
|
||||||
|
const { children, fallback } = this.props
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof fallback === 'function') {
|
||||||
|
return fallback(error, this.reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallback) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.renderDefaultFallback(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.video-container {
|
.sp-video-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-element {
|
.sp-video-element {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -15,17 +15,17 @@
|
|||||||
background-color: #000;
|
background-color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-element::-webkit-media-controls,
|
.sp-video-element::-webkit-media-controls,
|
||||||
.video-element::-webkit-media-controls-enclosure,
|
.sp-video-element::-webkit-media-controls-enclosure,
|
||||||
.video-element::-webkit-media-controls-panel {
|
.sp-video-element::-webkit-media-controls-panel {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-element::-moz-media-controls {
|
.sp-video-element::-moz-media-controls {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-subtitle-overlay {
|
.sp-subtitle-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -36,25 +36,25 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-subtitle-overlay.bottom {
|
.sp-subtitle-overlay.bottom {
|
||||||
bottom: var(--player-subtitle-bottom);
|
bottom: var(--player-subtitle-bottom);
|
||||||
transition: bottom var(--player-transition-fast) ease;
|
transition: bottom var(--player-transition-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player.controls-hidden .custom-subtitle-overlay.bottom {
|
.sp-video-player.sp-controls-hidden .sp-subtitle-overlay.bottom {
|
||||||
bottom: var(--player-subtitle-bottom-hidden);
|
bottom: var(--player-subtitle-bottom-hidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-subtitle-overlay.top {
|
.sp-subtitle-overlay.top {
|
||||||
top: 24px;
|
top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-subtitle-overlay.center {
|
.sp-subtitle-overlay.center {
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-subtitle-stack {
|
.sp-subtitle-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
max-width: min(92%, 1200px);
|
max-width: min(92%, 1200px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-subtitle-cue {
|
.sp-subtitle-cue {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0.35em 0.75em;
|
padding: 0.35em 0.75em;
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.custom-subtitle-cue {
|
.sp-subtitle-cue {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -591,28 +591,28 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Also check for any lingering player instances
|
// Also check for any lingering player instances
|
||||||
if ((video as any).__hlsInstance) {
|
if (video.__hlsInstance) {
|
||||||
const hls = (video as any).__hlsInstance
|
const hls = video.__hlsInstance
|
||||||
if (hls && typeof hls.destroy === 'function') {
|
if (typeof hls.destroy === 'function') {
|
||||||
hls.destroy()
|
hls.destroy()
|
||||||
}
|
}
|
||||||
delete (video as any).__hlsInstance
|
delete video.__hlsInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((video as any).__rtmpInstance) {
|
if (video.__rtmpInstance) {
|
||||||
const rtmp = (video as any).__rtmpInstance
|
const rtmp = video.__rtmpInstance
|
||||||
if (rtmp && typeof rtmp.destroy === 'function') {
|
if (typeof rtmp.destroy === 'function') {
|
||||||
rtmp.destroy()
|
rtmp.destroy()
|
||||||
}
|
}
|
||||||
delete (video as any).__rtmpInstance
|
delete video.__rtmpInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((video as any).__mpegtsInstance) {
|
if (video.__mpegtsInstance) {
|
||||||
const mpegts = (video as any).__mpegtsInstance
|
const mpegts = video.__mpegtsInstance
|
||||||
if (mpegts && typeof mpegts.destroy === 'function') {
|
if (typeof mpegts.destroy === 'function') {
|
||||||
mpegts.destroy()
|
mpegts.destroy()
|
||||||
}
|
}
|
||||||
delete (video as any).__mpegtsInstance
|
delete video.__mpegtsInstance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -778,7 +778,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video || !settings.audioTrack) return
|
if (!video || !settings.audioTrack) return
|
||||||
|
|
||||||
const hlsInstance = (video as any).__hlsInstance
|
const hlsInstance = video.__hlsInstance
|
||||||
if (!hlsInstance) return
|
if (!hlsInstance) return
|
||||||
|
|
||||||
// Find the index of the selected audio track
|
// Find the index of the selected audio track
|
||||||
@@ -826,7 +826,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
|
|
||||||
const hlsInstance = (video as any).__hlsInstance
|
const hlsInstance = video.__hlsInstance
|
||||||
if (!hlsInstance) return
|
if (!hlsInstance) return
|
||||||
|
|
||||||
if (!settings.quality) {
|
if (!settings.quality) {
|
||||||
@@ -976,10 +976,10 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
}, [settings.subtitle, videoRef, processedSubtitles, hlsSubtitles])
|
}, [settings.subtitle, videoRef, processedSubtitles, hlsSubtitles])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="video-container">
|
<div className="sp-video-container">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="video-element"
|
className="sp-video-element"
|
||||||
poster={poster}
|
poster={poster}
|
||||||
loop={loop}
|
loop={loop}
|
||||||
muted={muted}
|
muted={muted}
|
||||||
@@ -1014,10 +1014,10 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
|||||||
))}
|
))}
|
||||||
</video>
|
</video>
|
||||||
{settings.subtitle && activeSubtitleLines.length > 0 && (
|
{settings.subtitle && activeSubtitleLines.length > 0 && (
|
||||||
<div className={`custom-subtitle-overlay ${subtitlePosition}`} style={subtitleOverlayStyle}>
|
<div className={`sp-subtitle-overlay ${subtitlePosition}`} style={subtitleOverlayStyle}>
|
||||||
<div className="custom-subtitle-stack">
|
<div className="sp-subtitle-stack">
|
||||||
{activeSubtitleLines.map((line, index) => (
|
{activeSubtitleLines.map((line, index) => (
|
||||||
<div key={`${line}-${index}`} className="custom-subtitle-cue" style={subtitleCueStyle}>
|
<div key={`${line}-${index}`} className="sp-subtitle-cue" style={subtitleCueStyle}>
|
||||||
{line}
|
{line}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.video-player {
|
.sp-video-player {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -17,41 +17,41 @@
|
|||||||
transition: border-radius var(--player-transition-normal) ease;
|
transition: border-radius var(--player-transition-normal) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player *,
|
.sp-video-player *,
|
||||||
.video-player *::before,
|
.sp-video-player *::before,
|
||||||
.video-player *::after {
|
.sp-video-player *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player::before {
|
.sp-video-player::before {
|
||||||
content: '';
|
content: '';
|
||||||
display: block;
|
display: block;
|
||||||
padding-top: var(--player-aspect-ratio, 56.25%);
|
padding-top: var(--player-aspect-ratio, 56.25%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player > * {
|
.sp-video-player > * {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player video {
|
.sp-video-player video {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player:fullscreen,
|
.sp-video-player:fullscreen,
|
||||||
.video-player:-webkit-full-screen,
|
.sp-video-player:-webkit-full-screen,
|
||||||
.video-player:-moz-full-screen,
|
.sp-video-player:-moz-full-screen,
|
||||||
.video-player:-ms-fullscreen,
|
.sp-video-player:-ms-fullscreen,
|
||||||
:fullscreen .video-player,
|
:fullscreen .sp-video-player,
|
||||||
:-webkit-full-screen .video-player {
|
:-webkit-full-screen .sp-video-player {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player video::-webkit-media-controls,
|
.sp-video-player video::-webkit-media-controls,
|
||||||
.video-player video::-webkit-media-controls-enclosure,
|
.sp-video-player video::-webkit-media-controls-enclosure,
|
||||||
.video-player video::-webkit-media-controls-panel {
|
.sp-video-player video::-webkit-media-controls-panel {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ describe('VideoPlayer', () => {
|
|||||||
|
|
||||||
it('renders video player container', () => {
|
it('renders video player container', () => {
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} />)
|
const { container } = render(<VideoPlayer {...defaultProps} />)
|
||||||
expect(container.querySelector('.video-player')).toBeInTheDocument()
|
expect(container.querySelector('.sp-video-player')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders video element', () => {
|
it('renders video element', () => {
|
||||||
@@ -41,7 +41,7 @@ describe('VideoPlayer', () => {
|
|||||||
it('applies custom className', () => {
|
it('applies custom className', () => {
|
||||||
const className = 'custom-player'
|
const className = 'custom-player'
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} className={className} />)
|
const { container } = render(<VideoPlayer {...defaultProps} className={className} />)
|
||||||
expect(container.querySelector('.video-player')).toHaveClass('video-player', className)
|
expect(container.querySelector('.sp-video-player')).toHaveClass('sp-video-player', className)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls onPlay callback when play event fires', async () => {
|
it('calls onPlay callback when play event fires', async () => {
|
||||||
@@ -134,7 +134,7 @@ describe('VideoPlayer', () => {
|
|||||||
it('applies custom style', () => {
|
it('applies custom style', () => {
|
||||||
const style = { width: '800px', height: '450px' }
|
const style = { width: '800px', height: '450px' }
|
||||||
const { container } = render(<VideoPlayer {...defaultProps} style={style} />)
|
const { container } = render(<VideoPlayer {...defaultProps} style={style} />)
|
||||||
const playerElement = container.querySelector('.video-player') as HTMLElement
|
const playerElement = container.querySelector('.sp-video-player') as HTMLElement
|
||||||
expect(playerElement.style.width).toBe('800px')
|
expect(playerElement.style.width).toBe('800px')
|
||||||
expect(playerElement.style.height).toBe('450px')
|
expect(playerElement.style.height).toBe('450px')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useMemo, useState, useCallback, useImperativeHandle, forwardRef
|
|||||||
import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
|
import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
|
||||||
import { VideoElement } from './VideoElement'
|
import { VideoElement } from './VideoElement'
|
||||||
import { ControlsLayer } from './ControlsLayer'
|
import { ControlsLayer } from './ControlsLayer'
|
||||||
|
import { PlayerErrorBoundary } from './ErrorBoundary'
|
||||||
import type { VideoPlayerProps, VideoPlayerHandle, AudioTrack, VideoQuality, SubtitleTrack } from '../types'
|
import type { VideoPlayerProps, VideoPlayerHandle, AudioTrack, VideoQuality, SubtitleTrack } from '../types'
|
||||||
import { initializePolyfills } from '../utils/polyfills'
|
import { initializePolyfills } from '../utils/polyfills'
|
||||||
import '../styles/variables.css'
|
import '../styles/variables.css'
|
||||||
@@ -14,7 +15,7 @@ const initializePolyfillsIfNeeded = () => {
|
|||||||
if (typeof document === 'undefined') return
|
if (typeof document === 'undefined') return
|
||||||
|
|
||||||
// Check if polyfills are needed
|
// Check if polyfills are needed
|
||||||
const needsFullscreenPolyfill = !document.fullscreenEnabled && !(document as any).webkitFullscreenEnabled
|
const needsFullscreenPolyfill = !document.fullscreenEnabled && !document.webkitFullscreenEnabled
|
||||||
const needsPIPPolyfill = !('pictureInPictureEnabled' in document)
|
const needsPIPPolyfill = !('pictureInPictureEnabled' in document)
|
||||||
|
|
||||||
if (needsFullscreenPolyfill || needsPIPPolyfill) {
|
if (needsFullscreenPolyfill || needsPIPPolyfill) {
|
||||||
@@ -143,7 +144,7 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
|||||||
[videoRef, containerRef, play, pause, seek, setVolume, toggleMute, toggleFullscreen, togglePictureInPicture, setPlaybackRate]
|
[videoRef, containerRef, play, pause, seek, setVolume, toggleMute, toggleFullscreen, togglePictureInPicture, setPlaybackRate]
|
||||||
)
|
)
|
||||||
|
|
||||||
const controlsHiddenClass = !uiState.controlsVisible ? 'controls-hidden' : ''
|
const controlsHiddenClass = !uiState.controlsVisible ? 'sp-controls-hidden' : ''
|
||||||
const themedStyle = useMemo<React.CSSProperties>(() => {
|
const themedStyle = useMemo<React.CSSProperties>(() => {
|
||||||
const cssVariables: Record<string, string> = {}
|
const cssVariables: Record<string, string> = {}
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`video-player ${controlsHiddenClass} ${className}`}
|
className={`sp-video-player ${controlsHiddenClass} ${className}`}
|
||||||
style={themedStyle}
|
style={themedStyle}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
@@ -264,7 +265,7 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{children && (
|
{children && (
|
||||||
<div className="video-player-overlay" style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 'var(--player-z-controls)' as any }}>
|
<div className="sp-video-player-overlay" style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 'var(--player-z-controls)' as React.CSSProperties['zIndex'] }}>
|
||||||
<div style={{ pointerEvents: 'auto' }}>{children}</div>
|
<div style={{ pointerEvents: 'auto' }}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -351,68 +352,75 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlayerProvider initialMuted={muted} language={language} customTranslations={customTranslations}>
|
<PlayerErrorBoundary
|
||||||
<VideoPlayerContent
|
resetKeys={[src]}
|
||||||
ref={ref}
|
onError={(error) => {
|
||||||
src={src}
|
onError?.(error)
|
||||||
poster={poster}
|
}}
|
||||||
protocol={protocol}
|
>
|
||||||
autoplay={autoplay}
|
<PlayerProvider initialMuted={muted} language={language} customTranslations={customTranslations}>
|
||||||
loop={loop}
|
<VideoPlayerContent
|
||||||
muted={muted}
|
ref={ref}
|
||||||
volume={volume}
|
src={src}
|
||||||
playbackRate={playbackRate}
|
poster={poster}
|
||||||
currentTime={currentTime}
|
protocol={protocol}
|
||||||
crossOrigin={crossOrigin}
|
autoplay={autoplay}
|
||||||
preload={preload}
|
loop={loop}
|
||||||
playsInline={playsInline}
|
muted={muted}
|
||||||
controlsList={controlsList}
|
volume={volume}
|
||||||
controls={controls}
|
playbackRate={playbackRate}
|
||||||
subtitles={subtitles}
|
currentTime={currentTime}
|
||||||
subtitleStyle={subtitleStyle}
|
crossOrigin={crossOrigin}
|
||||||
subtitlePosition={subtitlePosition}
|
preload={preload}
|
||||||
subtitleOffset={subtitleOffset}
|
playsInline={playsInline}
|
||||||
theme={theme}
|
controlsList={controlsList}
|
||||||
keyboardShortcuts={keyboardShortcuts}
|
controls={controls}
|
||||||
pictureInPicture={pictureInPicture}
|
subtitles={subtitles}
|
||||||
className={className}
|
subtitleStyle={subtitleStyle}
|
||||||
style={style}
|
subtitlePosition={subtitlePosition}
|
||||||
controlsAutoHideDelay={controlsAutoHideDelay}
|
subtitleOffset={subtitleOffset}
|
||||||
playbackRates={playbackRates}
|
theme={theme}
|
||||||
aspectRatio={aspectRatio}
|
keyboardShortcuts={keyboardShortcuts}
|
||||||
keyboardShortcutConfig={keyboardShortcutConfig}
|
pictureInPicture={pictureInPicture}
|
||||||
touchConfig={touchConfig}
|
className={className}
|
||||||
children={children}
|
style={style}
|
||||||
controlsLeftExtra={controlsLeftExtra}
|
controlsAutoHideDelay={controlsAutoHideDelay}
|
||||||
controlsRightExtra={controlsRightExtra}
|
playbackRates={playbackRates}
|
||||||
onPlay={onPlay}
|
aspectRatio={aspectRatio}
|
||||||
onPause={onPause}
|
keyboardShortcutConfig={keyboardShortcutConfig}
|
||||||
onEnded={onEnded}
|
touchConfig={touchConfig}
|
||||||
onTimeUpdate={onTimeUpdate}
|
children={children}
|
||||||
onVolumeChange={onVolumeChange}
|
controlsLeftExtra={controlsLeftExtra}
|
||||||
onError={onError}
|
controlsRightExtra={controlsRightExtra}
|
||||||
onLoadedMetadata={onLoadedMetadata}
|
onPlay={onPlay}
|
||||||
onSeeking={onSeeking}
|
onPause={onPause}
|
||||||
onSeeked={onSeeked}
|
onEnded={onEnded}
|
||||||
onProgress={onProgress}
|
onTimeUpdate={onTimeUpdate}
|
||||||
onDurationChange={onDurationChange}
|
onVolumeChange={onVolumeChange}
|
||||||
onRateChange={onRateChange}
|
onError={onError}
|
||||||
onFullscreenChange={onFullscreenChange}
|
onLoadedMetadata={onLoadedMetadata}
|
||||||
onPictureInPictureChange={onPictureInPictureChange}
|
onSeeking={onSeeking}
|
||||||
onWaiting={onWaiting}
|
onSeeked={onSeeked}
|
||||||
onCanPlay={onCanPlay}
|
onProgress={onProgress}
|
||||||
onQualityChange={onQualityChange}
|
onDurationChange={onDurationChange}
|
||||||
onBufferStart={onBufferStart}
|
onRateChange={onRateChange}
|
||||||
onBufferEnd={onBufferEnd}
|
onFullscreenChange={onFullscreenChange}
|
||||||
onFirstPlay={onFirstPlay}
|
onPictureInPictureChange={onPictureInPictureChange}
|
||||||
audioTracks={audioTracks}
|
onWaiting={onWaiting}
|
||||||
onAudioTracksLoadedInternal={handleAudioTracksLoaded}
|
onCanPlay={onCanPlay}
|
||||||
qualities={qualities}
|
onQualityChange={onQualityChange}
|
||||||
onQualityLevelsLoadedInternal={handleQualityLevelsLoaded}
|
onBufferStart={onBufferStart}
|
||||||
hlsSubtitles={hlsSubtitles}
|
onBufferEnd={onBufferEnd}
|
||||||
onSubtitleTracksLoadedInternal={handleSubtitleTracksLoaded}
|
onFirstPlay={onFirstPlay}
|
||||||
/>
|
audioTracks={audioTracks}
|
||||||
</PlayerProvider>
|
onAudioTracksLoadedInternal={handleAudioTracksLoaded}
|
||||||
|
qualities={qualities}
|
||||||
|
onQualityLevelsLoadedInternal={handleQualityLevelsLoaded}
|
||||||
|
hlsSubtitles={hlsSubtitles}
|
||||||
|
onSubtitleTracksLoadedInternal={handleSubtitleTracksLoaded}
|
||||||
|
/>
|
||||||
|
</PlayerProvider>
|
||||||
|
</PlayerErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.center-play-overlay {
|
.sp-center-play-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.center-play-button {
|
.sp-center-play-button {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
border-radius: var(--player-radius-full);
|
border-radius: var(--player-radius-full);
|
||||||
@@ -28,35 +28,35 @@
|
|||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.center-play-button:hover {
|
.sp-center-play-button:hover {
|
||||||
background-color: var(--player-primary-hover);
|
background-color: var(--player-primary-hover);
|
||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.4), 0 10px 20px rgba(239, 68, 68, 0.25);
|
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.4), 0 10px 20px rgba(239, 68, 68, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.center-play-button:active {
|
.sp-center-play-button:active {
|
||||||
background-color: var(--player-primary-active);
|
background-color: var(--player-primary-active);
|
||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
.center-play-button:focus-visible {
|
.sp-center-play-button:focus-visible {
|
||||||
outline: 2px solid var(--player-text);
|
outline: 2px solid var(--player-text);
|
||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.center-play-button svg {
|
.sp-center-play-button svg {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
margin-left: 3px;
|
margin-left: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.center-play-button {
|
.sp-center-play-button {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.center-play-button svg {
|
.sp-center-play-button svg {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ export const CenterPlayButton: React.FC = () => {
|
|||||||
const { play, translations } = usePlayerContext()
|
const { play, translations } = usePlayerContext()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="center-play-overlay">
|
<div className="sp-center-play-overlay">
|
||||||
<button
|
<button
|
||||||
className="center-play-button"
|
className="sp-center-play-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={play}
|
onClick={play}
|
||||||
aria-label={translations.play}
|
aria-label={translations.play}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.control-button {
|
.sp-control-button {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -14,37 +14,37 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-button:hover:not(:disabled) {
|
.sp-control-button:hover:not(:disabled) {
|
||||||
color: var(--player-primary);
|
color: var(--player-primary);
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-button:active:not(:disabled) {
|
.sp-control-button:active:not(:disabled) {
|
||||||
color: var(--player-primary-active);
|
color: var(--player-primary-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-button:focus-visible {
|
.sp-control-button:focus-visible {
|
||||||
outline: 2px solid var(--player-primary);
|
outline: 2px solid var(--player-primary);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-button:disabled {
|
.sp-control-button:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-button svg {
|
.sp-control-button svg {
|
||||||
width: var(--player-icon-md);
|
width: var(--player-icon-md);
|
||||||
height: var(--player-icon-md);
|
height: var(--player-icon-md);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.control-button {
|
.sp-control-button {
|
||||||
padding: var(--player-spacing-xs);
|
padding: var(--player-spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-button svg {
|
.sp-control-button svg {
|
||||||
width: var(--player-icon-sm);
|
width: var(--player-icon-sm);
|
||||||
height: var(--player-icon-sm);
|
height: var(--player-icon-sm);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const FullscreenButton: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="control-button fullscreen-button"
|
className="sp-control-button sp-fullscreen-button"
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
aria-label={actionLabel}
|
aria-label={actionLabel}
|
||||||
title={`${actionLabel} (F)`}
|
title={`${actionLabel} (F)`}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export const PIPButton: React.FC = () => {
|
|||||||
|
|
||||||
// Check if PIP is supported
|
// Check if PIP is supported
|
||||||
const isPIPSupported =
|
const isPIPSupported =
|
||||||
typeof (document as any).pictureInPictureEnabled === 'boolean' &&
|
'pictureInPictureEnabled' in document &&
|
||||||
(document as any).pictureInPictureEnabled &&
|
document.pictureInPictureEnabled &&
|
||||||
typeof HTMLVideoElement.prototype.requestPictureInPicture === 'function'
|
typeof HTMLVideoElement.prototype.requestPictureInPicture === 'function'
|
||||||
|
|
||||||
if (!isPIPSupported) {
|
if (!isPIPSupported) {
|
||||||
@@ -21,7 +21,7 @@ export const PIPButton: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="control-button pip-button"
|
className="sp-control-button sp-pip-button"
|
||||||
onClick={togglePictureInPicture}
|
onClick={togglePictureInPicture}
|
||||||
aria-label={actionLabel}
|
aria-label={actionLabel}
|
||||||
title={`${actionLabel} (P)`}
|
title={`${actionLabel} (P)`}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const PlayPauseButton: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="control-button play-pause-button"
|
className="sp-control-button sp-play-pause-button"
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
aria-label={actionLabel}
|
aria-label={actionLabel}
|
||||||
title={`${actionLabel} (Space)`}
|
title={`${actionLabel} (Space)`}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.progress-bar {
|
.sp-progress-bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-track {
|
.sp-progress-track {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
@@ -17,12 +17,12 @@
|
|||||||
transition: height var(--player-transition-fast) ease;
|
transition: height var(--player-transition-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar:hover .progress-track,
|
.sp-progress-bar:hover .sp-progress-track,
|
||||||
.progress-bar.seeking .progress-track {
|
.sp-progress-bar.seeking .sp-progress-track {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-buffered {
|
.sp-progress-buffered {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 0;
|
width: 0;
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
transition: width 0.12s ease;
|
transition: width 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-played {
|
.sp-progress-played {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 0;
|
width: 0;
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
transition: width 0.12s ease;
|
transition: width 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-handle {
|
.sp-progress-handle {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -54,13 +54,13 @@
|
|||||||
margin-right: -6px;
|
margin-right: -6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar:hover .progress-handle,
|
.sp-progress-bar:hover .sp-progress-handle,
|
||||||
.progress-bar.seeking .progress-handle {
|
.sp-progress-bar.seeking .sp-progress-handle {
|
||||||
transform: scale(1.15);
|
transform: scale(1.15);
|
||||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
|
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-tooltip {
|
.sp-progress-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + 8px);
|
bottom: calc(100% + 8px);
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-tooltip::after {
|
.sp-progress-tooltip::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
@@ -89,20 +89,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.progress-bar {
|
.sp-progress-bar {
|
||||||
height: 22px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-track {
|
.sp-progress-track {
|
||||||
height: 3px;
|
height: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar:hover .progress-track,
|
.sp-progress-bar:hover .sp-progress-track,
|
||||||
.progress-bar.seeking .progress-track {
|
.sp-progress-bar.seeking .sp-progress-track {
|
||||||
height: 5px;
|
height: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-handle {
|
.sp-progress-handle {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
margin-right: -5px;
|
margin-right: -5px;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const ProgressBar: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={progressRef}
|
ref={progressRef}
|
||||||
className={`progress-bar ${seeking ? 'seeking' : ''}`}
|
className={`sp-progress-bar ${seeking ? 'seeking' : ''}`}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
@@ -90,20 +90,20 @@ export const ProgressBar: React.FC = () => {
|
|||||||
aria-valuetext={formatTime(videoState.currentTime)}
|
aria-valuetext={formatTime(videoState.currentTime)}
|
||||||
>
|
>
|
||||||
{/* Background track */}
|
{/* Background track */}
|
||||||
<div className="progress-track">
|
<div className="sp-progress-track">
|
||||||
{/* Buffered progress */}
|
{/* Buffered progress */}
|
||||||
<div className="progress-buffered" style={{ width: `${buffered}%` }} />
|
<div className="sp-progress-buffered" style={{ width: `${buffered}%` }} />
|
||||||
|
|
||||||
{/* Played progress */}
|
{/* Played progress */}
|
||||||
<div className="progress-played" style={{ width: `${progress}%` }}>
|
<div className="sp-progress-played" style={{ width: `${progress}%` }}>
|
||||||
<div className="progress-handle" />
|
<div className="sp-progress-handle" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover time tooltip */}
|
{/* Hover time tooltip */}
|
||||||
{hoverTime !== null && (
|
{hoverTime !== null && (
|
||||||
<div
|
<div
|
||||||
className="progress-tooltip"
|
className="sp-progress-tooltip"
|
||||||
style={{
|
style={{
|
||||||
left: `${hoverPosition}px`,
|
left: `${hoverPosition}px`,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const SettingsButton: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="control-button settings-button"
|
className="sp-control-button sp-settings-button"
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
onClick={toggleSettings}
|
onClick={toggleSettings}
|
||||||
aria-label={translations.settings}
|
aria-label={translations.settings}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.time-display {
|
.sp-time-display {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -10,13 +10,13 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-separator,
|
.sp-time-separator,
|
||||||
.time-duration {
|
.sp-time-duration {
|
||||||
color: var(--player-text-secondary);
|
color: var(--player-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.time-display {
|
.sp-time-display {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ export const TimeDisplay: React.FC = () => {
|
|||||||
const { videoState } = usePlayerContext()
|
const { videoState } = usePlayerContext()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="time-display">
|
<div className="sp-time-display">
|
||||||
<span className="time-current">{formatTime(videoState.currentTime)}</span>
|
<span className="sp-time-current">{formatTime(videoState.currentTime)}</span>
|
||||||
<span className="time-separator">/</span>
|
<span className="sp-time-separator">/</span>
|
||||||
<span className="time-duration">{formatTime(videoState.duration)}</span>
|
<span className="sp-time-duration">{formatTime(videoState.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
.volume-control {
|
.sp-volume-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--player-spacing-xs);
|
gap: var(--player-spacing-xs);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider-container {
|
.sp-volume-slider-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
@@ -17,12 +17,12 @@
|
|||||||
opacity var(--player-transition-normal) ease;
|
opacity var(--player-transition-normal) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider-container.visible {
|
.sp-volume-slider-container.visible {
|
||||||
width: 88px;
|
width: 88px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider {
|
.sp-volume-slider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -32,18 +32,18 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider:focus-visible {
|
.sp-volume-slider:focus-visible {
|
||||||
outline: 2px solid var(--player-primary);
|
outline: 2px solid var(--player-primary);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: var(--player-radius-sm);
|
border-radius: var(--player-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider::-webkit-slider-runnable-track {
|
.sp-volume-slider::-webkit-slider-runnable-track {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider::-webkit-slider-thumb {
|
.sp-volume-slider::-webkit-slider-thumb {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
@@ -56,17 +56,17 @@
|
|||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider:hover::-webkit-slider-thumb {
|
.sp-volume-slider:hover::-webkit-slider-thumb {
|
||||||
transform: scale(1.15);
|
transform: scale(1.15);
|
||||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
|
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider::-moz-range-track {
|
.sp-volume-slider::-moz-range-track {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider::-moz-range-thumb {
|
.sp-volume-slider::-moz-range-thumb {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -77,12 +77,12 @@
|
|||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider:hover::-moz-range-thumb {
|
.sp-volume-slider:hover::-moz-range-thumb {
|
||||||
transform: scale(1.15);
|
transform: scale(1.15);
|
||||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
|
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider-fill {
|
.sp-volume-slider-fill {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.volume-slider-container.visible {
|
.sp-volume-slider-container.visible {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ export const VolumeControl: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="volume-control"
|
className="sp-volume-control"
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="control-button volume-button"
|
className="sp-control-button sp-volume-button"
|
||||||
onClick={toggleMute}
|
onClick={toggleMute}
|
||||||
aria-label={actionLabel}
|
aria-label={actionLabel}
|
||||||
title={`${actionLabel} (M)`}
|
title={`${actionLabel} (M)`}
|
||||||
@@ -47,7 +47,7 @@ export const VolumeControl: React.FC = () => {
|
|||||||
<VolumeIcon size={24} color="var(--player-text)" />
|
<VolumeIcon size={24} color="var(--player-text)" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className={`volume-slider-container ${showSlider ? 'visible' : ''}`}>
|
<div className={`sp-volume-slider-container ${showSlider ? 'visible' : ''}`}>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -55,11 +55,11 @@ export const VolumeControl: React.FC = () => {
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
value={videoState.muted ? 0 : videoState.volume}
|
value={videoState.muted ? 0 : videoState.volume}
|
||||||
onChange={handleSliderChange}
|
onChange={handleSliderChange}
|
||||||
className="volume-slider"
|
className="sp-volume-slider"
|
||||||
aria-label={translations.volume}
|
aria-label={translations.volume}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="volume-slider-fill"
|
className="sp-volume-slider-fill"
|
||||||
style={{ width: `${(videoState.muted ? 0 : videoState.volume) * 100}%` }}
|
style={{ width: `${(videoState.muted ? 0 : videoState.volume) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.settings-menu {
|
.sp-settings-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + 12px);
|
bottom: calc(100% + 12px);
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: var(--player-z-menu);
|
z-index: var(--player-z-menu);
|
||||||
color: var(--player-text);
|
color: var(--player-text);
|
||||||
animation: fadeIn var(--player-transition-normal) ease;
|
animation: sp-fade-in var(--player-transition-normal) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-menu-header {
|
.sp-settings-menu-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--player-spacing-sm);
|
gap: var(--player-spacing-sm);
|
||||||
@@ -21,14 +21,14 @@
|
|||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-menu-header h3 {
|
.sp-settings-menu-header h3 {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-back-button {
|
.sp-settings-back-button {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -44,17 +44,17 @@
|
|||||||
background-color var(--player-transition-fast) ease;
|
background-color var(--player-transition-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-back-button:hover {
|
.sp-settings-back-button:hover {
|
||||||
color: var(--player-primary);
|
color: var(--player-primary);
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-main-options {
|
.sp-settings-main-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-main-option {
|
.sp-settings-main-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--player-spacing-md);
|
gap: var(--player-spacing-md);
|
||||||
@@ -69,11 +69,11 @@
|
|||||||
color var(--player-transition-fast) ease;
|
color var(--player-transition-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-main-option:hover {
|
.sp-settings-main-option:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.06);
|
background-color: rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-main-option-icon {
|
.sp-settings-main-option-icon {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -83,36 +83,36 @@
|
|||||||
background-color: rgba(239, 68, 68, 0.14);
|
background-color: rgba(239, 68, 68, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-main-option-content {
|
.sp-settings-main-option-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-main-option-label {
|
.sp-settings-main-option-label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-main-option-value {
|
.sp-settings-main-option-value {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--player-text-secondary);
|
color: var(--player-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-main-option-arrow {
|
.sp-settings-main-option-arrow {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: var(--player-text-secondary);
|
color: var(--player-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-options {
|
.sp-settings-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-height: 280px;
|
max-height: 280px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-option {
|
.sp-settings-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -128,55 +128,55 @@
|
|||||||
color var(--player-transition-fast) ease;
|
color var(--player-transition-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-option:hover {
|
.sp-settings-option:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.06);
|
background-color: rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-option.active {
|
.sp-settings-option.active {
|
||||||
color: var(--player-primary);
|
color: var(--player-primary);
|
||||||
background-color: rgba(239, 68, 68, 0.14);
|
background-color: rgba(239, 68, 68, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-option span {
|
.sp-settings-option span {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-empty-state {
|
.sp-settings-empty-state {
|
||||||
padding: var(--player-spacing-xl) var(--player-spacing-lg);
|
padding: var(--player-spacing-xl) var(--player-spacing-lg);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--player-text-muted);
|
color: var(--player-text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-options::-webkit-scrollbar {
|
.sp-settings-options::-webkit-scrollbar {
|
||||||
width: 5px;
|
width: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-options::-webkit-scrollbar-track {
|
.sp-settings-options::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-options::-webkit-scrollbar-thumb {
|
.sp-settings-options::-webkit-scrollbar-thumb {
|
||||||
background-color: rgba(255, 255, 255, 0.18);
|
background-color: rgba(255, 255, 255, 0.18);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-options::-webkit-scrollbar-thumb:hover {
|
.sp-settings-options::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.25);
|
background-color: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.settings-menu {
|
.sp-settings-menu {
|
||||||
min-width: 240px;
|
min-width: 240px;
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-main-option,
|
.sp-settings-main-option,
|
||||||
.settings-option {
|
.sp-settings-option {
|
||||||
padding: var(--player-spacing-sm) var(--player-spacing-md);
|
padding: var(--player-spacing-sm) var(--player-spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-options {
|
.sp-settings-options {
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,67 +64,67 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
if (!uiState.settingsOpen) return null
|
if (!uiState.settingsOpen) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={menuRef} className="settings-menu">
|
<div ref={menuRef} className="sp-settings-menu">
|
||||||
{/* Main Menu */}
|
{/* Main Menu */}
|
||||||
{currentView === 'main' && (
|
{currentView === 'main' && (
|
||||||
<>
|
<>
|
||||||
<div className="settings-menu-header">
|
<div className="sp-settings-menu-header">
|
||||||
<h3>{translations.settings}</h3>
|
<h3>{translations.settings}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-main-options">
|
<div className="sp-settings-main-options">
|
||||||
{qualities.length > 0 && (
|
{qualities.length > 0 && (
|
||||||
<button className="settings-main-option" onClick={() => setCurrentView('quality')}>
|
<button className="sp-settings-main-option" onClick={() => setCurrentView('quality')}>
|
||||||
<div className="settings-main-option-icon">
|
<div className="sp-settings-main-option-icon">
|
||||||
<QualityIcon size={20} color="var(--player-text)" />
|
<QualityIcon size={20} color="var(--player-text)" />
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-main-option-content">
|
<div className="sp-settings-main-option-content">
|
||||||
<span className="settings-main-option-label">{translations.quality}</span>
|
<span className="sp-settings-main-option-label">{translations.quality}</span>
|
||||||
<span className="settings-main-option-value">
|
<span className="sp-settings-main-option-value">
|
||||||
{settings.quality ? settings.quality.label : translations.auto}
|
{settings.quality ? settings.quality.label : translations.auto}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-main-option-arrow">›</div>
|
<div className="sp-settings-main-option-arrow">›</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button className="settings-main-option" onClick={() => setCurrentView('speed')}>
|
<button className="sp-settings-main-option" onClick={() => setCurrentView('speed')}>
|
||||||
<div className="settings-main-option-icon">
|
<div className="sp-settings-main-option-icon">
|
||||||
<SpeedIcon size={20} color="var(--player-text)" />
|
<SpeedIcon size={20} color="var(--player-text)" />
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-main-option-content">
|
<div className="sp-settings-main-option-content">
|
||||||
<span className="settings-main-option-label">{translations.speed}</span>
|
<span className="sp-settings-main-option-label">{translations.speed}</span>
|
||||||
<span className="settings-main-option-value">
|
<span className="sp-settings-main-option-value">
|
||||||
{videoState.playbackRate === 1 ? translations.normal : `${videoState.playbackRate}x`}
|
{videoState.playbackRate === 1 ? translations.normal : `${videoState.playbackRate}x`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-main-option-arrow">›</div>
|
<div className="sp-settings-main-option-arrow">›</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="settings-main-option" onClick={() => setCurrentView('subtitles')}>
|
<button className="sp-settings-main-option" onClick={() => setCurrentView('subtitles')}>
|
||||||
<div className="settings-main-option-icon">
|
<div className="sp-settings-main-option-icon">
|
||||||
<SubtitlesIcon size={20} color="var(--player-text)" />
|
<SubtitlesIcon size={20} color="var(--player-text)" />
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-main-option-content">
|
<div className="sp-settings-main-option-content">
|
||||||
<span className="settings-main-option-label">{translations.subtitles}</span>
|
<span className="sp-settings-main-option-label">{translations.subtitles}</span>
|
||||||
<span className="settings-main-option-value">
|
<span className="sp-settings-main-option-value">
|
||||||
{settings.subtitle ? settings.subtitle.label : translations.off}
|
{settings.subtitle ? settings.subtitle.label : translations.off}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-main-option-arrow">›</div>
|
<div className="sp-settings-main-option-arrow">›</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{audioTracks.length > 0 && (
|
{audioTracks.length > 0 && (
|
||||||
<button className="settings-main-option" onClick={() => setCurrentView('audio')}>
|
<button className="sp-settings-main-option" onClick={() => setCurrentView('audio')}>
|
||||||
<div className="settings-main-option-icon">
|
<div className="sp-settings-main-option-icon">
|
||||||
<AudioIcon size={20} color="var(--player-text)" />
|
<AudioIcon size={20} color="var(--player-text)" />
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-main-option-content">
|
<div className="sp-settings-main-option-content">
|
||||||
<span className="settings-main-option-label">{translations.audioTrack}</span>
|
<span className="sp-settings-main-option-label">{translations.audioTrack}</span>
|
||||||
<span className="settings-main-option-value">
|
<span className="sp-settings-main-option-value">
|
||||||
{settings.audioTrack ? settings.audioTrack.name : translations.default}
|
{settings.audioTrack ? settings.audioTrack.name : translations.default}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-main-option-arrow">›</div>
|
<div className="sp-settings-main-option-arrow">›</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,17 +134,17 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
{/* Speed Submenu */}
|
{/* Speed Submenu */}
|
||||||
{currentView === 'speed' && (
|
{currentView === 'speed' && (
|
||||||
<>
|
<>
|
||||||
<div className="settings-menu-header">
|
<div className="sp-settings-menu-header">
|
||||||
<button className="settings-back-button" onClick={goBack}>
|
<button className="sp-settings-back-button" onClick={goBack}>
|
||||||
‹
|
‹
|
||||||
</button>
|
</button>
|
||||||
<h3>{translations.speed}</h3>
|
<h3>{translations.speed}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-options">
|
<div className="sp-sp-settings-options">
|
||||||
{playbackRates.map((rate) => (
|
{playbackRates.map((rate) => (
|
||||||
<button
|
<button
|
||||||
key={rate}
|
key={rate}
|
||||||
className={`settings-option ${videoState.playbackRate === rate ? 'active' : ''}`}
|
className={`sp-settings-option ${videoState.playbackRate === rate ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPlaybackRate(rate)
|
setPlaybackRate(rate)
|
||||||
setTimeout(() => goBack(), 150)
|
setTimeout(() => goBack(), 150)
|
||||||
@@ -161,15 +161,15 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
{/* Subtitles Submenu */}
|
{/* Subtitles Submenu */}
|
||||||
{currentView === 'subtitles' && (
|
{currentView === 'subtitles' && (
|
||||||
<>
|
<>
|
||||||
<div className="settings-menu-header">
|
<div className="sp-settings-menu-header">
|
||||||
<button className="settings-back-button" onClick={goBack}>
|
<button className="sp-settings-back-button" onClick={goBack}>
|
||||||
‹
|
‹
|
||||||
</button>
|
</button>
|
||||||
<h3>{translations.subtitles}</h3>
|
<h3>{translations.subtitles}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-options">
|
<div className="sp-sp-settings-options">
|
||||||
<button
|
<button
|
||||||
className={`settings-option ${!settings.subtitle ? 'active' : ''}`}
|
className={`sp-settings-option ${!settings.subtitle ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSubtitle(null)
|
setSubtitle(null)
|
||||||
setTimeout(() => goBack(), 150)
|
setTimeout(() => goBack(), 150)
|
||||||
@@ -182,7 +182,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
subtitles.map((subtitle) => (
|
subtitles.map((subtitle) => (
|
||||||
<button
|
<button
|
||||||
key={subtitle.lang}
|
key={subtitle.lang}
|
||||||
className={`settings-option ${settings.subtitle?.lang === subtitle.lang ? 'active' : ''}`}
|
className={`sp-settings-option ${settings.subtitle?.lang === subtitle.lang ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSubtitle(subtitle)
|
setSubtitle(subtitle)
|
||||||
setTimeout(() => goBack(), 150)
|
setTimeout(() => goBack(), 150)
|
||||||
@@ -193,7 +193,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="settings-empty-state">
|
<div className="sp-settings-empty-state">
|
||||||
<span>{translations.noSubtitlesAvailable}</span>
|
<span>{translations.noSubtitlesAvailable}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -204,17 +204,17 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
{/* Audio Submenu */}
|
{/* Audio Submenu */}
|
||||||
{currentView === 'audio' && (
|
{currentView === 'audio' && (
|
||||||
<>
|
<>
|
||||||
<div className="settings-menu-header">
|
<div className="sp-settings-menu-header">
|
||||||
<button className="settings-back-button" onClick={goBack}>
|
<button className="sp-settings-back-button" onClick={goBack}>
|
||||||
‹
|
‹
|
||||||
</button>
|
</button>
|
||||||
<h3>{translations.audioTrack}</h3>
|
<h3>{translations.audioTrack}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-options">
|
<div className="sp-sp-settings-options">
|
||||||
{audioTracks.map((track) => (
|
{audioTracks.map((track) => (
|
||||||
<button
|
<button
|
||||||
key={track.language}
|
key={track.language}
|
||||||
className={`settings-option ${settings.audioTrack?.language === track.language ? 'active' : ''}`}
|
className={`sp-settings-option ${settings.audioTrack?.language === track.language ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAudioTrack(track)
|
setAudioTrack(track)
|
||||||
setTimeout(() => goBack(), 150)
|
setTimeout(() => goBack(), 150)
|
||||||
@@ -233,15 +233,15 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
{/* Quality Submenu */}
|
{/* Quality Submenu */}
|
||||||
{currentView === 'quality' && (
|
{currentView === 'quality' && (
|
||||||
<>
|
<>
|
||||||
<div className="settings-menu-header">
|
<div className="sp-settings-menu-header">
|
||||||
<button className="settings-back-button" onClick={goBack}>
|
<button className="sp-settings-back-button" onClick={goBack}>
|
||||||
‹
|
‹
|
||||||
</button>
|
</button>
|
||||||
<h3>{translations.quality}</h3>
|
<h3>{translations.quality}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-options">
|
<div className="sp-sp-settings-options">
|
||||||
<button
|
<button
|
||||||
className={`settings-option ${!settings.quality ? 'active' : ''}`}
|
className={`sp-settings-option ${!settings.quality ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setQuality(null)
|
setQuality(null)
|
||||||
setTimeout(() => goBack(), 150)
|
setTimeout(() => goBack(), 150)
|
||||||
@@ -269,7 +269,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={(quality.levelIndex ?? quality.label).toString()}
|
key={(quality.levelIndex ?? quality.label).toString()}
|
||||||
className={`settings-option ${isActive ? 'active' : ''}`}
|
className={`sp-settings-option ${isActive ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setQuality(quality)
|
setQuality(quality)
|
||||||
setTimeout(() => goBack(), 150)
|
setTimeout(() => goBack(), 150)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.loading-spinner-overlay {
|
.sp-loading-spinner-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -10,6 +10,6 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.sp-loading-spinner {
|
||||||
animation: fadeIn var(--player-transition-normal) ease;
|
animation: sp-fade-in var(--player-transition-normal) ease;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import './LoadingSpinner.css'
|
|||||||
|
|
||||||
export const LoadingSpinner: React.FC = () => {
|
export const LoadingSpinner: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className="loading-spinner-overlay">
|
<div className="sp-loading-spinner-overlay">
|
||||||
<div className="loading-spinner">
|
<div className="sp-loading-spinner">
|
||||||
<LoadingIcon size={48} color="var(--player-primary)" />
|
<LoadingIcon size={48} color="var(--player-primary)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
-9
@@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* Type declarations for optional flv.js module
|
|
||||||
* Since flv.js is an optional dependency that may not be installed,
|
|
||||||
* we declare it as a module that can be dynamically imported
|
|
||||||
*/
|
|
||||||
declare module 'flv.js' {
|
|
||||||
const content: any
|
|
||||||
export default content
|
|
||||||
}
|
|
||||||
@@ -109,7 +109,7 @@ export const useTouchGestures = (containerRef: MutableRefObject<HTMLDivElement |
|
|||||||
feedback.style.color = 'white'
|
feedback.style.color = 'white'
|
||||||
feedback.style.fontSize = '48px'
|
feedback.style.fontSize = '48px'
|
||||||
feedback.style.pointerEvents = 'none'
|
feedback.style.pointerEvents = 'none'
|
||||||
feedback.style.animation = 'fadeOut 0.5s ease-out forwards'
|
feedback.style.animation = 'sp-fade-out 0.5s ease-out forwards'
|
||||||
feedback.textContent = isLeft ? `« ${doubleTapSeekSeconds}s` : `${doubleTapSeekSeconds}s »`
|
feedback.textContent = isLeft ? `« ${doubleTapSeekSeconds}s` : `${doubleTapSeekSeconds}s »`
|
||||||
|
|
||||||
container?.appendChild(feedback)
|
container?.appendChild(feedback)
|
||||||
|
|||||||
+2
-2
@@ -13,7 +13,7 @@ interface BaseIconProps extends IconProps {
|
|||||||
const Icon: React.FC<BaseIconProps> = ({ size = 24, className = '', color = 'currentColor', children }) => (
|
const Icon: React.FC<BaseIconProps> = ({ size = 24, className = '', color = 'currentColor', children }) => (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" className={className}>
|
<svg width={size} height={size} viewBox="0 0 24 24" className={className}>
|
||||||
{React.Children.map(children, child =>
|
{React.Children.map(children, child =>
|
||||||
React.isValidElement(child) ? React.cloneElement(child, { fill: color } as any) : child
|
React.isValidElement<{ fill?: string }>(child) ? React.cloneElement(child, { fill: color }) : child
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
@@ -104,7 +104,7 @@ export const RewindIcon: React.FC<IconProps> = (props) => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const LoadingIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
export const LoadingIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" className={className} style={{ animation: 'spin 1s linear infinite' }}>
|
<svg width={size} height={size} viewBox="0 0 24 24" className={className} style={{ animation: 'sp-spin 1s linear infinite' }}>
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" opacity="0.3" fill={color} />
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" opacity="0.3" fill={color} />
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12h2c0-4.42 3.58-8 8-8s8 3.58 8 8h2c0-5.52-4.48-10-10-10z" fill={color} />
|
<path d="M12 2C6.48 2 2 6.48 2 12h2c0-4.42 3.58-8 8-8s8 3.58 8 8h2c0-5.52-4.48-10-10-10z" fill={color} />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
// Main component
|
// Main component
|
||||||
export { VideoPlayer } from './components/VideoPlayer'
|
export { VideoPlayer } from './components/VideoPlayer'
|
||||||
|
export { PlayerErrorBoundary } from './components/ErrorBoundary'
|
||||||
|
export type {
|
||||||
|
PlayerErrorBoundaryProps,
|
||||||
|
PlayerErrorBoundaryFallbackRender,
|
||||||
|
} from './components/ErrorBoundary'
|
||||||
|
|
||||||
// Context
|
// Context
|
||||||
export { PlayerProvider, usePlayerContext } from './contexts/PlayerContext'
|
export { PlayerProvider, usePlayerContext } from './contexts/PlayerContext'
|
||||||
|
|||||||
@@ -44,13 +44,13 @@
|
|||||||
--player-subtitle-bottom-hidden: 36px;
|
--player-subtitle-bottom-hidden: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes sp-spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes sp-fade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeOut {
|
@keyframes sp-fade-out {
|
||||||
from {
|
from {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -69,9 +69,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.video-player *,
|
.sp-video-player *,
|
||||||
.video-player *::before,
|
.sp-video-player *::before,
|
||||||
.video-player *::after {
|
.sp-video-player *::after {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
animation-iteration-count: 1 !important;
|
animation-iteration-count: 1 !important;
|
||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.01ms !important;
|
||||||
|
|||||||
Vendored
+165
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Type augmentations for vendor-prefixed browser APIs
|
||||||
|
* and dynamic player instance properties on HTMLVideoElement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface PlayerInstance {
|
||||||
|
destroy(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HTMLVideoElement {
|
||||||
|
/** HLS.js player instance attached during HLS setup */
|
||||||
|
__hlsInstance?: HlsInstance | PlayerInstance
|
||||||
|
/** flv.js player instance attached during RTMP/FLV setup */
|
||||||
|
__rtmpInstance?: FlvjsPlayer | PlayerInstance
|
||||||
|
/** mpegts.js player instance attached during MPEG-TS setup */
|
||||||
|
__mpegtsInstance?: MpegtsPlayer | PlayerInstance
|
||||||
|
/** flv.js runtime statistics */
|
||||||
|
__rtmpStats?: Record<string, unknown>
|
||||||
|
/** mpegts.js runtime statistics */
|
||||||
|
__mpegtsStats?: Record<string, unknown>
|
||||||
|
|
||||||
|
// Vendor-prefixed fullscreen APIs
|
||||||
|
webkitRequestFullscreen?: () => Promise<void>
|
||||||
|
mozRequestFullScreen?: () => Promise<void>
|
||||||
|
msRequestFullscreen?: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Document {
|
||||||
|
// Vendor-prefixed fullscreen properties
|
||||||
|
webkitFullscreenEnabled?: boolean
|
||||||
|
mozFullScreenEnabled?: boolean
|
||||||
|
msFullscreenEnabled?: boolean
|
||||||
|
|
||||||
|
webkitFullscreenElement?: Element | null
|
||||||
|
mozFullScreenElement?: Element | null
|
||||||
|
msFullscreenElement?: Element | null
|
||||||
|
|
||||||
|
webkitExitFullscreen?: () => Promise<void>
|
||||||
|
mozCancelFullScreen?: () => Promise<void>
|
||||||
|
msExitFullscreen?: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Element {
|
||||||
|
// Vendor-prefixed fullscreen APIs
|
||||||
|
webkitRequestFullscreen?: () => Promise<void>
|
||||||
|
mozRequestFullScreen?: () => Promise<void>
|
||||||
|
msRequestFullscreen?: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window augmentation for CDN-loaded libraries
|
||||||
|
interface Window {
|
||||||
|
Hls?: HlsConstructor
|
||||||
|
flvjs?: FlvjsStatic
|
||||||
|
mpegts?: MpegtsStatic
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal type definitions for CDN-loaded HLS.js
|
||||||
|
interface HlsConstructor {
|
||||||
|
new (config?: Record<string, unknown>): HlsInstance
|
||||||
|
isSupported(): boolean
|
||||||
|
Events: Record<string, string>
|
||||||
|
ErrorTypes: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HlsInstance {
|
||||||
|
loadSource(src: string): void
|
||||||
|
attachMedia(video: HTMLVideoElement): void
|
||||||
|
destroy(): void
|
||||||
|
on<TArgs extends unknown[]>(event: string, callback: (...args: TArgs) => void): void
|
||||||
|
startLoad(): void
|
||||||
|
recoverMediaError(): void
|
||||||
|
levels: HlsLevel[]
|
||||||
|
audioTracks: HlsAudioTrack[]
|
||||||
|
subtitleTracks: HlsSubtitleTrack[]
|
||||||
|
audioTrack: number
|
||||||
|
currentLevel: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HlsLevel {
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
bitrate?: number
|
||||||
|
name?: string
|
||||||
|
url?: string
|
||||||
|
uri?: string
|
||||||
|
attrs?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HlsAudioTrack {
|
||||||
|
name?: string
|
||||||
|
label?: string
|
||||||
|
lang?: string
|
||||||
|
language?: string
|
||||||
|
url?: string
|
||||||
|
groupId?: string
|
||||||
|
default?: boolean
|
||||||
|
autoselect?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HlsSubtitleTrack {
|
||||||
|
name?: string
|
||||||
|
label?: string
|
||||||
|
lang?: string
|
||||||
|
language?: string
|
||||||
|
url?: string
|
||||||
|
default?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal type definitions for CDN-loaded flv.js
|
||||||
|
interface FlvjsStatic {
|
||||||
|
createPlayer(mediaDataSource: object, config?: object): FlvjsPlayer
|
||||||
|
isSupported(): boolean
|
||||||
|
getFeatureList(): Record<string, boolean>
|
||||||
|
Events: Record<string, string>
|
||||||
|
ErrorTypes: Record<string, string>
|
||||||
|
ErrorDetails: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlvjsPlayer {
|
||||||
|
attachMediaElement(video: HTMLVideoElement): void
|
||||||
|
load(): void
|
||||||
|
play(): Promise<void>
|
||||||
|
unload(): void
|
||||||
|
detachMediaElement(): void
|
||||||
|
destroy(): void
|
||||||
|
on<TArgs extends unknown[]>(event: string, callback: (...args: TArgs) => void): void
|
||||||
|
off<TArgs extends unknown[]>(event: string, callback?: (...args: TArgs) => void): void
|
||||||
|
statisticsInfo?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal type definitions for CDN-loaded mpegts.js
|
||||||
|
interface MpegtsStatic {
|
||||||
|
createPlayer(mediaDataSource: object, config?: object): MpegtsPlayer
|
||||||
|
isSupported(): boolean
|
||||||
|
Events: Record<string, string>
|
||||||
|
ErrorTypes: Record<string, string>
|
||||||
|
ErrorDetails: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MpegtsPlayer {
|
||||||
|
attachMediaElement(video: HTMLVideoElement): void
|
||||||
|
load(): void
|
||||||
|
play(): Promise<void>
|
||||||
|
unload(): void
|
||||||
|
detachMediaElement(): void
|
||||||
|
destroy(): void
|
||||||
|
on<TArgs extends unknown[]>(event: string, callback: (...args: TArgs) => void): void
|
||||||
|
off<TArgs extends unknown[]>(event: string, callback?: (...args: TArgs) => void): void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module declarations for optional npm packages
|
||||||
|
declare module 'hls.js' {
|
||||||
|
const Hls: HlsConstructor
|
||||||
|
export default Hls
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'flv.js' {
|
||||||
|
const flvjs: FlvjsStatic
|
||||||
|
export default flvjs
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'mpegts.js' {
|
||||||
|
const mpegts: MpegtsStatic
|
||||||
|
export default mpegts
|
||||||
|
}
|
||||||
+22
-4
@@ -3,11 +3,26 @@
|
|||||||
* Separated to avoid circular dependencies and enable better tree-shaking
|
* Separated to avoid circular dependencies and enable better tree-shaking
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const hasQualityControls = (
|
||||||
|
hls: HlsInstance | PlayerInstance | null | undefined
|
||||||
|
): hls is HlsInstance => {
|
||||||
|
return Boolean(hls && 'levels' in hls && Array.isArray(hls.levels) && 'currentLevel' in hls)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAudioControls = (
|
||||||
|
hls: HlsInstance | PlayerInstance | null | undefined
|
||||||
|
): hls is HlsInstance => {
|
||||||
|
return Boolean(hls && 'audioTracks' in hls && Array.isArray(hls.audioTracks) && 'audioTrack' in hls)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update active quality level in HLS instance. Passing null re-enables auto.
|
* Update active quality level in HLS instance. Passing null re-enables auto.
|
||||||
*/
|
*/
|
||||||
export const setHlsQualityLevel = (hls: any, levelIndex: number | null | undefined): void => {
|
export const setHlsQualityLevel = (
|
||||||
if (!hls || !Array.isArray(hls.levels)) {
|
hls: HlsInstance | PlayerInstance | null | undefined,
|
||||||
|
levelIndex: number | null | undefined
|
||||||
|
): void => {
|
||||||
|
if (!hasQualityControls(hls)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,8 +45,11 @@ export const setHlsQualityLevel = (hls: any, levelIndex: number | null | undefin
|
|||||||
/**
|
/**
|
||||||
* Set active audio track in HLS instance
|
* Set active audio track in HLS instance
|
||||||
*/
|
*/
|
||||||
export const setHlsAudioTrack = (hls: any, audioTrackIndex: number): void => {
|
export const setHlsAudioTrack = (
|
||||||
if (!hls || !hls.audioTracks) {
|
hls: HlsInstance | PlayerInstance | null | undefined,
|
||||||
|
audioTrackIndex: number
|
||||||
|
): void => {
|
||||||
|
if (!hasAudioControls(hls)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-17
@@ -10,16 +10,16 @@ import { logger } from './logger'
|
|||||||
// Re-export control functions for backward compatibility
|
// Re-export control functions for backward compatibility
|
||||||
export { setHlsQualityLevel, setHlsAudioTrack } from './hlsControl'
|
export { setHlsQualityLevel, setHlsAudioTrack } from './hlsControl'
|
||||||
|
|
||||||
const HLS_CDN_URL = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js'
|
const HLS_CDN_URL = 'https://cdn.jsdelivr.net/npm/hls.js@1.6.13/dist/hls.min.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load hls.js from CDN as fallback
|
* Load hls.js from CDN as fallback
|
||||||
*/
|
*/
|
||||||
const loadHlsFromCDN = (): Promise<any> => {
|
const loadHlsFromCDN = (): Promise<HlsConstructor> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Check if already loaded globally
|
// Check if already loaded globally
|
||||||
if (typeof (window as any).Hls !== 'undefined') {
|
if (window.Hls) {
|
||||||
resolve((window as any).Hls)
|
resolve(window.Hls)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,8 +28,8 @@ const loadHlsFromCDN = (): Promise<any> => {
|
|||||||
script.async = true
|
script.async = true
|
||||||
|
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
if (typeof (window as any).Hls !== 'undefined') {
|
if (window.Hls) {
|
||||||
resolve((window as any).Hls)
|
resolve(window.Hls)
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('HLS.js CDN loaded but Hls global not found'))
|
reject(new Error('HLS.js CDN loaded but Hls global not found'))
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ const loadHlsFromCDN = (): Promise<any> => {
|
|||||||
/**
|
/**
|
||||||
* Load hls.js with npm fallback to CDN
|
* Load hls.js with npm fallback to CDN
|
||||||
*/
|
*/
|
||||||
export const loadHls = async (): Promise<any> => {
|
export const loadHls = async (): Promise<HlsConstructor> => {
|
||||||
try {
|
try {
|
||||||
logger.log('[HLS Loader] Attempting to load from npm package...')
|
logger.log('[HLS Loader] Attempting to load from npm package...')
|
||||||
// Try loading from npm package first
|
// Try loading from npm package first
|
||||||
@@ -70,7 +70,7 @@ export const loadHls = async (): Promise<any> => {
|
|||||||
/**
|
/**
|
||||||
* Check if HLS.js is supported in current browser
|
* Check if HLS.js is supported in current browser
|
||||||
*/
|
*/
|
||||||
export const isHlsSupported = (Hls: any): boolean => {
|
export const isHlsSupported = (Hls: HlsConstructor): boolean => {
|
||||||
return Hls && typeof Hls.isSupported === 'function' && Hls.isSupported()
|
return Hls && typeof Hls.isSupported === 'function' && Hls.isSupported()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ export const hasNativeHlsSupport = (): boolean => {
|
|||||||
/**
|
/**
|
||||||
* Extract audio tracks from HLS instance
|
* Extract audio tracks from HLS instance
|
||||||
*/
|
*/
|
||||||
export const getHlsAudioTracks = (hls: any): AudioTrack[] => {
|
export const getHlsAudioTracks = (hls: HlsInstance): AudioTrack[] => {
|
||||||
try {
|
try {
|
||||||
if (!hls) {
|
if (!hls) {
|
||||||
logger.warn('[HLS Loader] getHlsAudioTracks: No HLS instance provided')
|
logger.warn('[HLS Loader] getHlsAudioTracks: No HLS instance provided')
|
||||||
@@ -100,7 +100,7 @@ export const getHlsAudioTracks = (hls: any): AudioTrack[] => {
|
|||||||
|
|
||||||
logger.log('[HLS Loader] getHlsAudioTracks: Raw audioTracks from HLS:', hls.audioTracks)
|
logger.log('[HLS Loader] getHlsAudioTracks: Raw audioTracks from HLS:', hls.audioTracks)
|
||||||
|
|
||||||
const audioTracks: AudioTrack[] = hls.audioTracks.map((track: any, index: number) => {
|
const audioTracks: AudioTrack[] = hls.audioTracks.map((track: HlsAudioTrack, index: number) => {
|
||||||
const audioTrack = {
|
const audioTrack = {
|
||||||
name: track.name || track.label || `Audio ${index + 1}`,
|
name: track.name || track.label || `Audio ${index + 1}`,
|
||||||
language: track.lang || track.language || 'unknown',
|
language: track.lang || track.language || 'unknown',
|
||||||
@@ -123,7 +123,7 @@ export const getHlsAudioTracks = (hls: any): AudioTrack[] => {
|
|||||||
/**
|
/**
|
||||||
* Extract subtitle tracks from HLS instance
|
* Extract subtitle tracks from HLS instance
|
||||||
*/
|
*/
|
||||||
export const getHlsSubtitleTracks = (hls: any): SubtitleTrack[] => {
|
export const getHlsSubtitleTracks = (hls: HlsInstance): SubtitleTrack[] => {
|
||||||
try {
|
try {
|
||||||
if (!hls) {
|
if (!hls) {
|
||||||
return []
|
return []
|
||||||
@@ -134,7 +134,7 @@ export const getHlsSubtitleTracks = (hls: any): SubtitleTrack[] => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const subtitleTracks: SubtitleTrack[] = hls.subtitleTracks.map((track: any, index: number) => {
|
const subtitleTracks: SubtitleTrack[] = hls.subtitleTracks.map((track: HlsSubtitleTrack, index: number) => {
|
||||||
return {
|
return {
|
||||||
label: track.name || track.label || `Subtitle ${index + 1}`,
|
label: track.name || track.label || `Subtitle ${index + 1}`,
|
||||||
lang: track.lang || track.language || 'unknown',
|
lang: track.lang || track.language || 'unknown',
|
||||||
@@ -152,7 +152,7 @@ export const getHlsSubtitleTracks = (hls: any): SubtitleTrack[] => {
|
|||||||
/**
|
/**
|
||||||
* Extract available quality levels from HLS instance
|
* Extract available quality levels from HLS instance
|
||||||
*/
|
*/
|
||||||
export const getHlsQualities = (hls: any): VideoQuality[] => {
|
export const getHlsQualities = (hls: HlsInstance): VideoQuality[] => {
|
||||||
try {
|
try {
|
||||||
if (!hls) {
|
if (!hls) {
|
||||||
logger.warn('[HLS Loader] getHlsQualities: No HLS instance provided')
|
logger.warn('[HLS Loader] getHlsQualities: No HLS instance provided')
|
||||||
@@ -166,7 +166,7 @@ export const getHlsQualities = (hls: any): VideoQuality[] => {
|
|||||||
|
|
||||||
logger.log('[HLS Loader] getHlsQualities: Raw levels from HLS:', hls.levels)
|
logger.log('[HLS Loader] getHlsQualities: Raw levels from HLS:', hls.levels)
|
||||||
|
|
||||||
const qualities: VideoQuality[] = hls.levels.map((level: any, index: number) => {
|
const qualities: VideoQuality[] = hls.levels.map((level: HlsLevel, index: number) => {
|
||||||
const resolution = typeof level.attrs?.RESOLUTION === 'string' ? level.attrs.RESOLUTION : undefined
|
const resolution = typeof level.attrs?.RESOLUTION === 'string' ? level.attrs.RESOLUTION : undefined
|
||||||
const [widthFromResolution, heightFromResolution] = resolution
|
const [widthFromResolution, heightFromResolution] = resolution
|
||||||
? resolution.split('x').map((value: string) => parseInt(value, 10))
|
? resolution.split('x').map((value: string) => parseInt(value, 10))
|
||||||
@@ -175,7 +175,7 @@ export const getHlsQualities = (hls: any): VideoQuality[] => {
|
|||||||
const translations = getTranslations(detectBrowserLanguage());
|
const translations = getTranslations(detectBrowserLanguage());
|
||||||
const width = level.width || widthFromResolution
|
const width = level.width || widthFromResolution
|
||||||
const height = level.height || heightFromResolution
|
const height = level.height || heightFromResolution
|
||||||
const bitrate = typeof level.bitrate === 'number' ? level.bitrate : level.attrs?.BANDWIDTH
|
const bitrate = typeof level.bitrate === 'number' ? level.bitrate : (level.attrs?.BANDWIDTH ? parseInt(level.attrs.BANDWIDTH, 10) : undefined)
|
||||||
|
|
||||||
let label: string
|
let label: string
|
||||||
if (typeof level.name === 'string' && level.name.trim().length > 0) {
|
if (typeof level.name === 'string' && level.name.trim().length > 0) {
|
||||||
@@ -213,5 +213,3 @@ export const getHlsQualities = (hls: any): VideoQuality[] => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export const setupHlsInstance = async ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
hls.on(Hls.Events.ERROR, (_event: any, data: any) => {
|
hls.on(Hls.Events.ERROR, (_event: unknown, data: { fatal: boolean; type: string }) => {
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
@@ -131,13 +131,13 @@ export const setupHlsInstance = async ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
;(video as any).__hlsInstance = hls
|
video.__hlsInstance = hls as PlayerInstance
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (hls) {
|
if (hls) {
|
||||||
hls.destroy()
|
hls.destroy()
|
||||||
}
|
}
|
||||||
delete (video as any).__hlsInstance
|
delete video.__hlsInstance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-11
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* MPEG-TS loader utility
|
* MPEG-TS loader utility
|
||||||
* Dynamically loads mpegts.js library
|
* Dynamically loads mpegts.js library with CDN fallback
|
||||||
*/
|
*/
|
||||||
import { logger } from './logger'
|
import { logger } from './logger'
|
||||||
|
|
||||||
|
const MPEGTS_CDN_URL = 'https://cdn.jsdelivr.net/npm/mpegts.js@1.7.3/dist/mpegts.js'
|
||||||
|
|
||||||
export interface MpegtsConfig {
|
export interface MpegtsConfig {
|
||||||
enableWorker?: boolean
|
enableWorker?: boolean
|
||||||
enableStashBuffer?: boolean
|
enableStashBuffer?: boolean
|
||||||
@@ -20,14 +22,45 @@ export interface MpegtsConfig {
|
|||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
let mpegtsInstance: any = null
|
let mpegtsInstance: MpegtsStatic | null = null
|
||||||
let loadingPromise: Promise<any> | null = null
|
let loadingPromise: Promise<MpegtsStatic> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load mpegts.js from CDN as fallback
|
||||||
|
*/
|
||||||
|
const loadMpegtsFromCDN = (): Promise<MpegtsStatic> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (window.mpegts) {
|
||||||
|
resolve(window.mpegts)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = MPEGTS_CDN_URL
|
||||||
|
script.async = true
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
if (window.mpegts) {
|
||||||
|
resolve(window.mpegts)
|
||||||
|
} else {
|
||||||
|
reject(new Error('mpegts.js loaded but not available on window'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
script.onerror = () => {
|
||||||
|
reject(new Error(`Failed to load mpegts.js from CDN: ${MPEGTS_CDN_URL}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load mpegts.js library dynamically
|
* Load mpegts.js library dynamically
|
||||||
|
* Tries NPM package first, falls back to CDN if unavailable
|
||||||
* @returns Promise that resolves to mpegts.js module
|
* @returns Promise that resolves to mpegts.js module
|
||||||
*/
|
*/
|
||||||
export const loadMpegts = async (): Promise<any> => {
|
export const loadMpegts = async (): Promise<MpegtsStatic> => {
|
||||||
// Return cached instance if available
|
// Return cached instance if available
|
||||||
if (mpegtsInstance) {
|
if (mpegtsInstance) {
|
||||||
logger.log('[MPEG-TS Loader] Using cached mpegts.js instance')
|
logger.log('[MPEG-TS Loader] Using cached mpegts.js instance')
|
||||||
@@ -47,10 +80,20 @@ export const loadMpegts = async (): Promise<any> => {
|
|||||||
const module = await import('mpegts.js')
|
const module = await import('mpegts.js')
|
||||||
mpegtsInstance = module.default || module
|
mpegtsInstance = module.default || module
|
||||||
logger.log('[MPEG-TS Loader] Successfully loaded from npm package')
|
logger.log('[MPEG-TS Loader] Successfully loaded from npm package')
|
||||||
return mpegtsInstance
|
return mpegtsInstance!
|
||||||
} catch (error) {
|
} catch (npmError) {
|
||||||
logger.error('[MPEG-TS Loader] Failed to load mpegts.js:', error)
|
logger.warn('[MPEG-TS Loader] npm package not available, loading from CDN...', npmError)
|
||||||
throw new Error('Failed to load mpegts.js. Make sure it is installed: npm install mpegts.js')
|
|
||||||
|
try {
|
||||||
|
mpegtsInstance = await loadMpegtsFromCDN()
|
||||||
|
logger.log('[MPEG-TS Loader] Successfully loaded from CDN')
|
||||||
|
return mpegtsInstance!
|
||||||
|
} catch (cdnError) {
|
||||||
|
logger.error('[MPEG-TS Loader] Failed to load from both npm and CDN', cdnError)
|
||||||
|
throw new Error(
|
||||||
|
'Failed to load mpegts.js library. Please ensure mpegts.js is available or check your network connection.'
|
||||||
|
)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loadingPromise = null
|
loadingPromise = null
|
||||||
}
|
}
|
||||||
@@ -64,7 +107,7 @@ export const loadMpegts = async (): Promise<any> => {
|
|||||||
* @param mpegts - The mpegts.js module
|
* @param mpegts - The mpegts.js module
|
||||||
* @returns True if supported
|
* @returns True if supported
|
||||||
*/
|
*/
|
||||||
export const isMpegtsSupported = (mpegts: any): boolean => {
|
export const isMpegtsSupported = (mpegts: MpegtsStatic): boolean => {
|
||||||
return mpegts && mpegts.isSupported()
|
return mpegts && mpegts.isSupported()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +137,7 @@ export const createDefaultMpegtsConfig = (isLive: boolean = false): MpegtsConfig
|
|||||||
* Get the cached mpegts.js instance
|
* Get the cached mpegts.js instance
|
||||||
* @returns The mpegts.js module or null
|
* @returns The mpegts.js module or null
|
||||||
*/
|
*/
|
||||||
export const getMpegtsInstance = (): any | null => {
|
export const getMpegtsInstance = (): MpegtsStatic | null => {
|
||||||
return mpegtsInstance
|
return mpegtsInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,4 +149,3 @@ export const clearMpegtsCache = (): void => {
|
|||||||
loadingPromise = null
|
loadingPromise = null
|
||||||
logger.log('[MPEG-TS Loader] Cache cleared')
|
logger.log('[MPEG-TS Loader] Cache cleared')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-12
@@ -69,10 +69,10 @@ export const setupMpegtsInstance = async ({
|
|||||||
player.load()
|
player.load()
|
||||||
|
|
||||||
// Store player instance on video element for later access
|
// Store player instance on video element for later access
|
||||||
;(video as any).__mpegtsInstance = player
|
video.__mpegtsInstance = player
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
player.on(mpegts.Events.ERROR, (errorType: string, errorDetail: string, errorInfo: any) => {
|
player.on(mpegts.Events.ERROR, (errorType: string, errorDetail: string, errorInfo: unknown) => {
|
||||||
logger.error('mpegts.js error:', { errorType, errorDetail, errorInfo })
|
logger.error('mpegts.js error:', { errorType, errorDetail, errorInfo })
|
||||||
|
|
||||||
const error = new Error(`MPEG-TS Player Error: ${errorType} - ${errorDetail}`)
|
const error = new Error(`MPEG-TS Player Error: ${errorType} - ${errorDetail}`)
|
||||||
@@ -125,7 +125,7 @@ export const setupMpegtsInstance = async ({
|
|||||||
logger.log('mpegts.js: Recovered from early EOF')
|
logger.log('mpegts.js: Recovered from early EOF')
|
||||||
})
|
})
|
||||||
|
|
||||||
player.on(mpegts.Events.METADATA_ARRIVED, (metadata: any) => {
|
player.on(mpegts.Events.METADATA_ARRIVED, (metadata: Record<string, unknown>) => {
|
||||||
logger.log('mpegts.js: Metadata arrived', metadata)
|
logger.log('mpegts.js: Metadata arrived', metadata)
|
||||||
|
|
||||||
// Trigger onLoadedMetadata callback
|
// Trigger onLoadedMetadata callback
|
||||||
@@ -134,10 +134,10 @@ export const setupMpegtsInstance = async ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
player.on(mpegts.Events.STATISTICS_INFO, (stats: any) => {
|
player.on(mpegts.Events.STATISTICS_INFO, (stats: Record<string, unknown>) => {
|
||||||
// Statistics info for debugging/monitoring
|
// Statistics info for debugging/monitoring
|
||||||
if (stats) {
|
if (stats) {
|
||||||
;(video as any).__mpegtsStats = stats
|
video.__mpegtsStats = stats
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -174,8 +174,8 @@ export const setupMpegtsInstance = async ({
|
|||||||
player.destroy()
|
player.destroy()
|
||||||
|
|
||||||
// Clean up stored references
|
// Clean up stored references
|
||||||
delete (video as any).__mpegtsInstance
|
delete video.__mpegtsInstance
|
||||||
delete (video as any).__mpegtsStats
|
delete video.__mpegtsStats
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
logger.error('Error during mpegts.js cleanup:', cleanupError)
|
logger.error('Error during mpegts.js cleanup:', cleanupError)
|
||||||
}
|
}
|
||||||
@@ -199,11 +199,11 @@ export const setupMpegtsInstance = async ({
|
|||||||
* @param video - The video element
|
* @param video - The video element
|
||||||
* @returns The mpegts.js player instance or null
|
* @returns The mpegts.js player instance or null
|
||||||
*/
|
*/
|
||||||
export const getMpegtsInstance = (video: HTMLVideoElement | null): any | null => {
|
export const getMpegtsInstance = (video: HTMLVideoElement | null): MpegtsPlayer | PlayerInstance | null => {
|
||||||
if (!video) {
|
if (!video) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (video as any).__mpegtsInstance || null
|
return video.__mpegtsInstance ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,11 +211,11 @@ export const getMpegtsInstance = (video: HTMLVideoElement | null): any | null =>
|
|||||||
* @param video - The video element
|
* @param video - The video element
|
||||||
* @returns The statistics object or null
|
* @returns The statistics object or null
|
||||||
*/
|
*/
|
||||||
export const getMpegtsStats = (video: HTMLVideoElement | null): any | null => {
|
export const getMpegtsStats = (video: HTMLVideoElement | null): Record<string, unknown> | null => {
|
||||||
if (!video) {
|
if (!video) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (video as any).__mpegtsStats || null
|
return video.__mpegtsStats ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -226,4 +226,3 @@ export const getMpegtsStats = (video: HTMLVideoElement | null): any | null => {
|
|||||||
export const hasMpegtsInstance = (video: HTMLVideoElement | null): boolean => {
|
export const hasMpegtsInstance = (video: HTMLVideoElement | null): boolean => {
|
||||||
return getMpegtsInstance(video) !== null
|
return getMpegtsInstance(video) !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-16
@@ -9,21 +9,15 @@
|
|||||||
*/
|
*/
|
||||||
export const setupFullscreenPolyfill = () => {
|
export const setupFullscreenPolyfill = () => {
|
||||||
if (!document.exitFullscreen) {
|
if (!document.exitFullscreen) {
|
||||||
// @ts-ignore - Legacy API
|
document.exitFullscreen = (document.webkitExitFullscreen ||
|
||||||
document.exitFullscreen = document.webkitExitFullscreen ||
|
|
||||||
// @ts-ignore
|
|
||||||
document.mozCancelFullScreen ||
|
document.mozCancelFullScreen ||
|
||||||
// @ts-ignore
|
document.msExitFullscreen) as typeof document.exitFullscreen
|
||||||
document.msExitFullscreen
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Element.prototype.requestFullscreen) {
|
if (!Element.prototype.requestFullscreen) {
|
||||||
// @ts-ignore - Legacy API
|
Element.prototype.requestFullscreen = (Element.prototype.webkitRequestFullscreen ||
|
||||||
Element.prototype.requestFullscreen = Element.prototype.webkitRequestFullscreen ||
|
|
||||||
// @ts-ignore
|
|
||||||
Element.prototype.mozRequestFullScreen ||
|
Element.prototype.mozRequestFullScreen ||
|
||||||
// @ts-ignore
|
Element.prototype.msRequestFullscreen) as typeof Element.prototype.requestFullscreen
|
||||||
Element.prototype.msRequestFullscreen
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fullscreen change event polyfill
|
// Fullscreen change event polyfill
|
||||||
@@ -41,11 +35,8 @@ export const setupFullscreenPolyfill = () => {
|
|||||||
if (!Object.prototype.hasOwnProperty.call(document, 'fullscreenElement')) {
|
if (!Object.prototype.hasOwnProperty.call(document, 'fullscreenElement')) {
|
||||||
Object.defineProperty(document, 'fullscreenElement', {
|
Object.defineProperty(document, 'fullscreenElement', {
|
||||||
get: function() {
|
get: function() {
|
||||||
// @ts-ignore
|
|
||||||
return this.webkitFullscreenElement ||
|
return this.webkitFullscreenElement ||
|
||||||
// @ts-ignore
|
|
||||||
this.mozFullScreenElement ||
|
this.mozFullScreenElement ||
|
||||||
// @ts-ignore
|
|
||||||
this.msFullscreenElement
|
this.msFullscreenElement
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -157,11 +148,8 @@ export const features = {
|
|||||||
if (!isBrowser) return false
|
if (!isBrowser) return false
|
||||||
return !!(
|
return !!(
|
||||||
document.fullscreenEnabled ||
|
document.fullscreenEnabled ||
|
||||||
// @ts-ignore
|
|
||||||
document.webkitFullscreenEnabled ||
|
document.webkitFullscreenEnabled ||
|
||||||
// @ts-ignore
|
|
||||||
document.mozFullScreenEnabled ||
|
document.mozFullScreenEnabled ||
|
||||||
// @ts-ignore
|
|
||||||
document.msFullscreenEnabled
|
document.msFullscreenEnabled
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
+16
-17
@@ -11,10 +11,10 @@ const FLVJS_CDN_URL = 'https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js
|
|||||||
* Dynamically loads flv.js from CDN
|
* Dynamically loads flv.js from CDN
|
||||||
* @returns Promise that resolves to the flv.js library
|
* @returns Promise that resolves to the flv.js library
|
||||||
*/
|
*/
|
||||||
const loadFlvjsFromCDN = async (): Promise<any> => {
|
const loadFlvjsFromCDN = async (): Promise<FlvjsStatic> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if ((window as any).flvjs) {
|
if (window.flvjs) {
|
||||||
resolve((window as any).flvjs)
|
resolve(window.flvjs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,8 +23,8 @@ const loadFlvjsFromCDN = async (): Promise<any> => {
|
|||||||
script.async = true
|
script.async = true
|
||||||
|
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
if ((window as any).flvjs) {
|
if (window.flvjs) {
|
||||||
resolve((window as any).flvjs)
|
resolve(window.flvjs)
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('flv.js loaded but not available on window'))
|
reject(new Error('flv.js loaded but not available on window'))
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ const loadFlvjsFromCDN = async (): Promise<any> => {
|
|||||||
* Tries NPM package first, falls back to CDN if unavailable
|
* Tries NPM package first, falls back to CDN if unavailable
|
||||||
* @returns Promise that resolves to the flv.js library
|
* @returns Promise that resolves to the flv.js library
|
||||||
*/
|
*/
|
||||||
export const loadFlvjs = async (): Promise<any> => {
|
export const loadFlvjs = async (): Promise<FlvjsStatic> => {
|
||||||
try {
|
try {
|
||||||
// Try loading from NPM package first
|
// Try loading from NPM package first
|
||||||
const flvModule = await import('flv.js')
|
const flvModule = await import('flv.js')
|
||||||
@@ -69,7 +69,7 @@ export const loadFlvjs = async (): Promise<any> => {
|
|||||||
* @param flvjs - The flv.js library instance
|
* @param flvjs - The flv.js library instance
|
||||||
* @returns True if supported
|
* @returns True if supported
|
||||||
*/
|
*/
|
||||||
export const isFlvjsSupported = (flvjs: any): boolean => {
|
export const isFlvjsSupported = (flvjs: FlvjsStatic): boolean => {
|
||||||
if (!flvjs) {
|
if (!flvjs) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ export const isFlvjsSupported = (flvjs: any): boolean => {
|
|||||||
* @param flvjs - The flv.js library instance
|
* @param flvjs - The flv.js library instance
|
||||||
* @returns Support information object
|
* @returns Support information object
|
||||||
*/
|
*/
|
||||||
export const getFlvjsSupportInfo = (flvjs: any): {
|
export const getFlvjsSupportInfo = (flvjs: FlvjsStatic): {
|
||||||
mseSupported: boolean
|
mseSupported: boolean
|
||||||
networkStreamIOSupported: boolean
|
networkStreamIOSupported: boolean
|
||||||
httpsSupported: boolean
|
httpsSupported: boolean
|
||||||
@@ -138,7 +138,7 @@ export const createDefaultFlvConfig = (isLive: boolean = true) => {
|
|||||||
* @param player - The flv.js player instance
|
* @param player - The flv.js player instance
|
||||||
* @returns Basic quality information
|
* @returns Basic quality information
|
||||||
*/
|
*/
|
||||||
export const extractFlvQualityInfo = (player: any): {
|
export const extractFlvQualityInfo = (player: FlvjsPlayer): {
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
videoCodec?: string
|
videoCodec?: string
|
||||||
@@ -154,17 +154,16 @@ export const extractFlvQualityInfo = (player: any): {
|
|||||||
try {
|
try {
|
||||||
const stats = player.statisticsInfo
|
const stats = player.statisticsInfo
|
||||||
return {
|
return {
|
||||||
width: stats.videoWidth,
|
width: stats.videoWidth as number | undefined,
|
||||||
height: stats.videoHeight,
|
height: stats.videoHeight as number | undefined,
|
||||||
videoCodec: stats.videoCodec,
|
videoCodec: stats.videoCodec as string | undefined,
|
||||||
audioCodec: stats.audioCodec,
|
audioCodec: stats.audioCodec as string | undefined,
|
||||||
fps: stats.fps,
|
fps: stats.fps as number | undefined,
|
||||||
videoBitrate: stats.videoBitrate,
|
videoBitrate: stats.videoBitrate as number | undefined,
|
||||||
audioBitrate: stats.audioBitrate,
|
audioBitrate: stats.audioBitrate as number | undefined,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to extract flv.js quality info:', error)
|
logger.warn('Failed to extract flv.js quality info:', error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-12
@@ -81,10 +81,10 @@ export const setupRtmpInstance = async ({
|
|||||||
player.load()
|
player.load()
|
||||||
|
|
||||||
// Store player instance on video element for later access
|
// Store player instance on video element for later access
|
||||||
;(video as any).__rtmpInstance = player
|
video.__rtmpInstance = player
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
player.on(flvjs.Events.ERROR, (errorType: string, errorDetail: string, errorInfo: any) => {
|
player.on(flvjs.Events.ERROR, (errorType: string, errorDetail: string, errorInfo: unknown) => {
|
||||||
logger.error('flv.js error:', { errorType, errorDetail, errorInfo })
|
logger.error('flv.js error:', { errorType, errorDetail, errorInfo })
|
||||||
|
|
||||||
const error = new Error(`FLV Player Error: ${errorType} - ${errorDetail}`)
|
const error = new Error(`FLV Player Error: ${errorType} - ${errorDetail}`)
|
||||||
@@ -137,7 +137,7 @@ export const setupRtmpInstance = async ({
|
|||||||
logger.log('flv.js: Recovered from early EOF')
|
logger.log('flv.js: Recovered from early EOF')
|
||||||
})
|
})
|
||||||
|
|
||||||
player.on(flvjs.Events.METADATA_ARRIVED, (metadata: any) => {
|
player.on(flvjs.Events.METADATA_ARRIVED, (metadata: Record<string, unknown>) => {
|
||||||
logger.log('flv.js: Metadata arrived', metadata)
|
logger.log('flv.js: Metadata arrived', metadata)
|
||||||
|
|
||||||
// Trigger onLoadedMetadata callback
|
// Trigger onLoadedMetadata callback
|
||||||
@@ -146,11 +146,11 @@ export const setupRtmpInstance = async ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
player.on(flvjs.Events.STATISTICS_INFO, (stats: any) => {
|
player.on(flvjs.Events.STATISTICS_INFO, (stats: Record<string, unknown>) => {
|
||||||
// Statistics info for debugging/monitoring
|
// Statistics info for debugging/monitoring
|
||||||
// Can be used to display stream quality, bitrate, etc.
|
// Can be used to display stream quality, bitrate, etc.
|
||||||
if (stats) {
|
if (stats) {
|
||||||
;(video as any).__rtmpStats = stats
|
video.__rtmpStats = stats
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -187,8 +187,8 @@ export const setupRtmpInstance = async ({
|
|||||||
player.destroy()
|
player.destroy()
|
||||||
|
|
||||||
// Clean up stored references
|
// Clean up stored references
|
||||||
delete (video as any).__rtmpInstance
|
delete video.__rtmpInstance
|
||||||
delete (video as any).__rtmpStats
|
delete video.__rtmpStats
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
logger.error('Error during flv.js cleanup:', cleanupError)
|
logger.error('Error during flv.js cleanup:', cleanupError)
|
||||||
}
|
}
|
||||||
@@ -212,11 +212,11 @@ export const setupRtmpInstance = async ({
|
|||||||
* @param video - The video element
|
* @param video - The video element
|
||||||
* @returns The flv.js player instance or null
|
* @returns The flv.js player instance or null
|
||||||
*/
|
*/
|
||||||
export const getRtmpInstance = (video: HTMLVideoElement | null): any | null => {
|
export const getRtmpInstance = (video: HTMLVideoElement | null): FlvjsPlayer | PlayerInstance | null => {
|
||||||
if (!video) {
|
if (!video) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (video as any).__rtmpInstance || null
|
return video.__rtmpInstance ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,11 +224,11 @@ export const getRtmpInstance = (video: HTMLVideoElement | null): any | null => {
|
|||||||
* @param video - The video element
|
* @param video - The video element
|
||||||
* @returns The statistics object or null
|
* @returns The statistics object or null
|
||||||
*/
|
*/
|
||||||
export const getRtmpStats = (video: HTMLVideoElement | null): any | null => {
|
export const getRtmpStats = (video: HTMLVideoElement | null): Record<string, unknown> | null => {
|
||||||
if (!video) {
|
if (!video) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (video as any).__rtmpStats || null
|
return video.__rtmpStats ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -239,4 +239,3 @@ export const getRtmpStats = (video: HTMLVideoElement | null): any | null => {
|
|||||||
export const hasRtmpInstance = (video: HTMLVideoElement | null): boolean => {
|
export const hasRtmpInstance = (video: HTMLVideoElement | null): boolean => {
|
||||||
return getRtmpInstance(video) !== null
|
return getRtmpInstance(video) !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"strict": false,
|
"strict": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
|||||||
+2
-1
@@ -42,7 +42,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['react', 'react-dom', 'react/jsx-runtime', 'hls.js', 'flv.js'],
|
external: ['react', 'react-dom', 'react/jsx-runtime', 'hls.js', 'flv.js', 'mpegts.js'],
|
||||||
output: {
|
output: {
|
||||||
globals: {
|
globals: {
|
||||||
react: 'React',
|
react: 'React',
|
||||||
@@ -50,6 +50,7 @@ export default defineConfig({
|
|||||||
'react/jsx-runtime': 'jsxRuntime',
|
'react/jsx-runtime': 'jsxRuntime',
|
||||||
'hls.js': 'Hls',
|
'hls.js': 'Hls',
|
||||||
'flv.js': 'flvjs',
|
'flv.js': 'flvjs',
|
||||||
|
'mpegts.js': 'mpegts',
|
||||||
},
|
},
|
||||||
compact: true,
|
compact: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user