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
|
||||
```
|
||||
|
||||
### 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
|
||||
npm install react react-dom
|
||||
```
|
||||
> **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:
|
||||
> ```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)
|
||||
|
||||
@@ -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
|
||||
|
||||
```tsx
|
||||
@@ -305,6 +323,12 @@ npm run build:lib
|
||||
|
||||
# Type check
|
||||
npx tsc --noEmit
|
||||
|
||||
# Format check
|
||||
npm run format:check
|
||||
|
||||
# Full publish validation
|
||||
npm run validate:publish
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
@@ -382,6 +406,16 @@ video-player/
|
||||
| `onWaiting` | `() => void` | Fired when buffering starts |
|
||||
| `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
|
||||
|
||||
```typescript
|
||||
@@ -418,7 +452,7 @@ interface PlayerTheme {
|
||||
- Core player CSS bundle: **~3.5KB** (gzipped)
|
||||
- HLS.js (optional, lazy-loaded): **~200KB** (only when using HLS 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
|
||||
|
||||
|
||||
+14
-2
@@ -33,8 +33,13 @@ export default [
|
||||
},
|
||||
rules: {
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'no-undef': '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', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_'
|
||||
@@ -44,6 +49,13 @@ export default [
|
||||
'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'],
|
||||
languageOptions: {
|
||||
|
||||
Generated
+5057
File diff suppressed because it is too large
Load Diff
+9
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@source/player",
|
||||
"version": "2.0.0",
|
||||
"version": "3.0.0",
|
||||
"description": "Modern, feature-rich video player library for React",
|
||||
"type": "module",
|
||||
"main": "./dist/video-player.umd.cjs",
|
||||
@@ -22,14 +22,16 @@
|
||||
"build": "tsc && vite build",
|
||||
"build:lib": "vite build --config vite.config.lib.ts",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
"test:coverage": "vitest --coverage",
|
||||
"validate:publish": "npm run lint && npm run test:run && npm run typecheck && npm run build:lib && npm pack --dry-run",
|
||||
"prepublishOnly": "npm run validate:publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.38.0",
|
||||
@@ -47,6 +49,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.4.0",
|
||||
"jsdom": "^27.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"terser": "^5.44.0",
|
||||
@@ -55,11 +58,6 @@
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vitest": "^4.0.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"flv.js": "^1.6.2",
|
||||
"hls.js": "^1.6.13",
|
||||
"mpegts.js": "^1.7.3"
|
||||
},
|
||||
"keywords": [
|
||||
"react",
|
||||
"video",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.controls-layer {
|
||||
.sp-controls-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: var(--player-z-controls);
|
||||
@@ -11,7 +11,7 @@
|
||||
transition: opacity var(--player-transition-normal) ease;
|
||||
}
|
||||
|
||||
.controls-layer::before {
|
||||
.sp-controls-layer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
@@ -27,29 +27,29 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.controls-layer > * {
|
||||
.sp-controls-layer > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.controls-layer > .center-play-overlay,
|
||||
.controls-layer > .loading-spinner-overlay {
|
||||
.sp-controls-layer > .sp-center-play-overlay,
|
||||
.sp-controls-layer > .sp-loading-spinner-overlay {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.controls-layer.hidden.playing {
|
||||
.sp-controls-layer.hidden.playing {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.controls-layer.hidden.playing::before {
|
||||
.sp-controls-layer.hidden.playing::before {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.controls-layer.fullscreen.hidden.playing {
|
||||
.sp-controls-layer.fullscreen.hidden.playing {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.controls-bar {
|
||||
.sp-controls-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--player-spacing-sm);
|
||||
@@ -58,35 +58,35 @@
|
||||
transition: transform var(--player-transition-normal) ease;
|
||||
}
|
||||
|
||||
.controls-layer.hidden.playing .controls-bar {
|
||||
.sp-controls-layer.hidden.playing .sp-controls-bar {
|
||||
transform: translateY(12px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
.sp-progress-container {
|
||||
margin-bottom: var(--player-spacing-xs);
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
.sp-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--player-spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls-left,
|
||||
.controls-right {
|
||||
.sp-controls-left,
|
||||
.sp-controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--player-spacing-sm);
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
.sp-controls-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Live indicator */
|
||||
.live-indicator {
|
||||
.sp-live-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -101,15 +101,15 @@
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
.sp-live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: rgb(220, 38, 38);
|
||||
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% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
@@ -120,32 +120,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
.live-text {
|
||||
.sp-live-text {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.controls-bar {
|
||||
.sp-controls-bar {
|
||||
padding: var(--player-spacing-md) var(--player-spacing-md) var(--player-spacing-sm);
|
||||
gap: var(--player-spacing-sm);
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
.sp-controls-row {
|
||||
gap: var(--player-spacing-xs);
|
||||
}
|
||||
|
||||
.controls-left,
|
||||
.controls-right {
|
||||
.sp-controls-left,
|
||||
.sp-controls-right {
|
||||
gap: var(--player-spacing-xs);
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
.sp-live-indicator {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
.sp-live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
@@ -174,8 +174,8 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
||||
// Don't handle clicks on control buttons or other interactive elements
|
||||
const isClickableArea =
|
||||
target === currentTarget ||
|
||||
target.classList.contains('center-play-overlay') ||
|
||||
target.classList.contains('controls-layer')
|
||||
target.classList.contains('sp-center-play-overlay') ||
|
||||
target.classList.contains('sp-controls-layer')
|
||||
|
||||
if (!isClickableArea) {
|
||||
return
|
||||
@@ -202,7 +202,7 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
||||
[togglePlay, toggleFullscreen]
|
||||
)
|
||||
|
||||
const controlsClassName = `controls-layer ${uiState.controlsVisible ? 'visible' : 'hidden'} ${
|
||||
const controlsClassName = `sp-controls-layer ${uiState.controlsVisible ? 'visible' : 'hidden'} ${
|
||||
videoState.playing ? 'playing' : 'paused'
|
||||
} ${videoState.fullscreen ? 'fullscreen' : 'windowed'}`
|
||||
|
||||
@@ -222,32 +222,32 @@ export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
||||
{!videoState.playing && !videoState.loading && <CenterPlayButton />}
|
||||
|
||||
{/* Bottom controls bar */}
|
||||
<div className="controls-bar">
|
||||
<div className="sp-controls-bar">
|
||||
{/* Progress bar (full width on top) - hidden for live broadcasts */}
|
||||
{!videoState.isLiveBroadcast && (
|
||||
<div className="progress-container">
|
||||
<div className="sp-progress-container">
|
||||
<ProgressBar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className="controls-row">
|
||||
<div className="controls-left">
|
||||
<div className="sp-controls-row">
|
||||
<div className="sp-controls-left">
|
||||
<PlayPauseButton />
|
||||
{features.hasVolumeControl() && <VolumeControl />}
|
||||
{/* Time display - hidden for live broadcasts */}
|
||||
{!videoState.isLiveBroadcast && <TimeDisplay />}
|
||||
{/* Show "LIVE" badge for live broadcasts */}
|
||||
{videoState.isLiveBroadcast && (
|
||||
<div className="live-indicator">
|
||||
<span className="live-dot"></span>
|
||||
<span className="live-text">{translations.live}</span>
|
||||
<div className="sp-live-indicator">
|
||||
<span className="sp-live-dot"></span>
|
||||
<span className="sp-live-text">{translations.live}</span>
|
||||
</div>
|
||||
)}
|
||||
{controlsLeftExtra}
|
||||
</div>
|
||||
|
||||
<div className="controls-right">
|
||||
<div className="sp-controls-right">
|
||||
{controlsRightExtra}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<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;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -6,7 +6,7 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-element {
|
||||
.sp-video-element {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
@@ -15,17 +15,17 @@
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.video-element::-webkit-media-controls,
|
||||
.video-element::-webkit-media-controls-enclosure,
|
||||
.video-element::-webkit-media-controls-panel {
|
||||
.sp-video-element::-webkit-media-controls,
|
||||
.sp-video-element::-webkit-media-controls-enclosure,
|
||||
.sp-video-element::-webkit-media-controls-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.video-element::-moz-media-controls {
|
||||
.sp-video-element::-moz-media-controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.custom-subtitle-overlay {
|
||||
.sp-subtitle-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
@@ -36,25 +36,25 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.custom-subtitle-overlay.bottom {
|
||||
.sp-subtitle-overlay.bottom {
|
||||
bottom: var(--player-subtitle-bottom);
|
||||
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);
|
||||
}
|
||||
|
||||
.custom-subtitle-overlay.top {
|
||||
.sp-subtitle-overlay.top {
|
||||
top: 24px;
|
||||
}
|
||||
|
||||
.custom-subtitle-overlay.center {
|
||||
.sp-subtitle-overlay.center {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.custom-subtitle-stack {
|
||||
.sp-subtitle-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -63,7 +63,7 @@
|
||||
max-width: min(92%, 1200px);
|
||||
}
|
||||
|
||||
.custom-subtitle-cue {
|
||||
.sp-subtitle-cue {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
padding: 0.35em 0.75em;
|
||||
@@ -83,7 +83,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.custom-subtitle-cue {
|
||||
.sp-subtitle-cue {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,28 +591,28 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
}
|
||||
|
||||
// Also check for any lingering player instances
|
||||
if ((video as any).__hlsInstance) {
|
||||
const hls = (video as any).__hlsInstance
|
||||
if (hls && typeof hls.destroy === 'function') {
|
||||
if (video.__hlsInstance) {
|
||||
const hls = video.__hlsInstance
|
||||
if (typeof hls.destroy === 'function') {
|
||||
hls.destroy()
|
||||
}
|
||||
delete (video as any).__hlsInstance
|
||||
delete video.__hlsInstance
|
||||
}
|
||||
|
||||
if ((video as any).__rtmpInstance) {
|
||||
const rtmp = (video as any).__rtmpInstance
|
||||
if (rtmp && typeof rtmp.destroy === 'function') {
|
||||
if (video.__rtmpInstance) {
|
||||
const rtmp = video.__rtmpInstance
|
||||
if (typeof rtmp.destroy === 'function') {
|
||||
rtmp.destroy()
|
||||
}
|
||||
delete (video as any).__rtmpInstance
|
||||
delete video.__rtmpInstance
|
||||
}
|
||||
|
||||
if ((video as any).__mpegtsInstance) {
|
||||
const mpegts = (video as any).__mpegtsInstance
|
||||
if (mpegts && typeof mpegts.destroy === 'function') {
|
||||
if (video.__mpegtsInstance) {
|
||||
const mpegts = video.__mpegtsInstance
|
||||
if (typeof mpegts.destroy === 'function') {
|
||||
mpegts.destroy()
|
||||
}
|
||||
delete (video as any).__mpegtsInstance
|
||||
delete video.__mpegtsInstance
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,7 +778,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
const video = videoRef.current
|
||||
if (!video || !settings.audioTrack) return
|
||||
|
||||
const hlsInstance = (video as any).__hlsInstance
|
||||
const hlsInstance = video.__hlsInstance
|
||||
if (!hlsInstance) return
|
||||
|
||||
// Find the index of the selected audio track
|
||||
@@ -826,7 +826,7 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
const hlsInstance = (video as any).__hlsInstance
|
||||
const hlsInstance = video.__hlsInstance
|
||||
if (!hlsInstance) return
|
||||
|
||||
if (!settings.quality) {
|
||||
@@ -976,10 +976,10 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
}, [settings.subtitle, videoRef, processedSubtitles, hlsSubtitles])
|
||||
|
||||
return (
|
||||
<div className="video-container">
|
||||
<div className="sp-video-container">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="video-element"
|
||||
className="sp-video-element"
|
||||
poster={poster}
|
||||
loop={loop}
|
||||
muted={muted}
|
||||
@@ -1014,10 +1014,10 @@ export const VideoElement: React.FC<VideoElementProps> = ({
|
||||
))}
|
||||
</video>
|
||||
{settings.subtitle && activeSubtitleLines.length > 0 && (
|
||||
<div className={`custom-subtitle-overlay ${subtitlePosition}`} style={subtitleOverlayStyle}>
|
||||
<div className="custom-subtitle-stack">
|
||||
<div className={`sp-subtitle-overlay ${subtitlePosition}`} style={subtitleOverlayStyle}>
|
||||
<div className="sp-subtitle-stack">
|
||||
{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}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.video-player {
|
||||
.sp-video-player {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -17,41 +17,41 @@
|
||||
transition: border-radius var(--player-transition-normal) ease;
|
||||
}
|
||||
|
||||
.video-player *,
|
||||
.video-player *::before,
|
||||
.video-player *::after {
|
||||
.sp-video-player *,
|
||||
.sp-video-player *::before,
|
||||
.sp-video-player *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.video-player::before {
|
||||
.sp-video-player::before {
|
||||
content: '';
|
||||
display: block;
|
||||
padding-top: var(--player-aspect-ratio, 56.25%);
|
||||
}
|
||||
|
||||
.video-player > * {
|
||||
.sp-video-player > * {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.video-player video {
|
||||
.sp-video-player video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-player:fullscreen,
|
||||
.video-player:-webkit-full-screen,
|
||||
.video-player:-moz-full-screen,
|
||||
.video-player:-ms-fullscreen,
|
||||
:fullscreen .video-player,
|
||||
:-webkit-full-screen .video-player {
|
||||
.sp-video-player:fullscreen,
|
||||
.sp-video-player:-webkit-full-screen,
|
||||
.sp-video-player:-moz-full-screen,
|
||||
.sp-video-player:-ms-fullscreen,
|
||||
:fullscreen .sp-video-player,
|
||||
:-webkit-full-screen .sp-video-player {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.video-player video::-webkit-media-controls,
|
||||
.video-player video::-webkit-media-controls-enclosure,
|
||||
.video-player video::-webkit-media-controls-panel {
|
||||
.sp-video-player video::-webkit-media-controls,
|
||||
.sp-video-player video::-webkit-media-controls-enclosure,
|
||||
.sp-video-player video::-webkit-media-controls-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('VideoPlayer', () => {
|
||||
|
||||
it('renders video player container', () => {
|
||||
const { container } = render(<VideoPlayer {...defaultProps} />)
|
||||
expect(container.querySelector('.video-player')).toBeInTheDocument()
|
||||
expect(container.querySelector('.sp-video-player')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders video element', () => {
|
||||
@@ -41,7 +41,7 @@ describe('VideoPlayer', () => {
|
||||
it('applies custom className', () => {
|
||||
const className = 'custom-player'
|
||||
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 () => {
|
||||
@@ -134,7 +134,7 @@ describe('VideoPlayer', () => {
|
||||
it('applies custom style', () => {
|
||||
const style = { width: '800px', height: '450px' }
|
||||
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.height).toBe('450px')
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo, useState, useCallback, useImperativeHandle, forwardRef
|
||||
import { PlayerProvider, usePlayerContext } from '../contexts/PlayerContext'
|
||||
import { VideoElement } from './VideoElement'
|
||||
import { ControlsLayer } from './ControlsLayer'
|
||||
import { PlayerErrorBoundary } from './ErrorBoundary'
|
||||
import type { VideoPlayerProps, VideoPlayerHandle, AudioTrack, VideoQuality, SubtitleTrack } from '../types'
|
||||
import { initializePolyfills } from '../utils/polyfills'
|
||||
import '../styles/variables.css'
|
||||
@@ -14,7 +15,7 @@ const initializePolyfillsIfNeeded = () => {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
// Check if polyfills are needed
|
||||
const needsFullscreenPolyfill = !document.fullscreenEnabled && !(document as any).webkitFullscreenEnabled
|
||||
const needsFullscreenPolyfill = !document.fullscreenEnabled && !document.webkitFullscreenEnabled
|
||||
const needsPIPPolyfill = !('pictureInPictureEnabled' in document)
|
||||
|
||||
if (needsFullscreenPolyfill || needsPIPPolyfill) {
|
||||
@@ -143,7 +144,7 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
||||
[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 cssVariables: Record<string, string> = {}
|
||||
|
||||
@@ -202,7 +203,7 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`video-player ${controlsHiddenClass} ${className}`}
|
||||
className={`sp-video-player ${controlsHiddenClass} ${className}`}
|
||||
style={themedStyle}
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -264,7 +265,7 @@ const VideoPlayerContent = forwardRef<VideoPlayerHandle, VideoPlayerContentProps
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
@@ -351,68 +352,75 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PlayerProvider initialMuted={muted} language={language} customTranslations={customTranslations}>
|
||||
<VideoPlayerContent
|
||||
ref={ref}
|
||||
src={src}
|
||||
poster={poster}
|
||||
protocol={protocol}
|
||||
autoplay={autoplay}
|
||||
loop={loop}
|
||||
muted={muted}
|
||||
volume={volume}
|
||||
playbackRate={playbackRate}
|
||||
currentTime={currentTime}
|
||||
crossOrigin={crossOrigin}
|
||||
preload={preload}
|
||||
playsInline={playsInline}
|
||||
controlsList={controlsList}
|
||||
controls={controls}
|
||||
subtitles={subtitles}
|
||||
subtitleStyle={subtitleStyle}
|
||||
subtitlePosition={subtitlePosition}
|
||||
subtitleOffset={subtitleOffset}
|
||||
theme={theme}
|
||||
keyboardShortcuts={keyboardShortcuts}
|
||||
pictureInPicture={pictureInPicture}
|
||||
className={className}
|
||||
style={style}
|
||||
controlsAutoHideDelay={controlsAutoHideDelay}
|
||||
playbackRates={playbackRates}
|
||||
aspectRatio={aspectRatio}
|
||||
keyboardShortcutConfig={keyboardShortcutConfig}
|
||||
touchConfig={touchConfig}
|
||||
children={children}
|
||||
controlsLeftExtra={controlsLeftExtra}
|
||||
controlsRightExtra={controlsRightExtra}
|
||||
onPlay={onPlay}
|
||||
onPause={onPause}
|
||||
onEnded={onEnded}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onError={onError}
|
||||
onLoadedMetadata={onLoadedMetadata}
|
||||
onSeeking={onSeeking}
|
||||
onSeeked={onSeeked}
|
||||
onProgress={onProgress}
|
||||
onDurationChange={onDurationChange}
|
||||
onRateChange={onRateChange}
|
||||
onFullscreenChange={onFullscreenChange}
|
||||
onPictureInPictureChange={onPictureInPictureChange}
|
||||
onWaiting={onWaiting}
|
||||
onCanPlay={onCanPlay}
|
||||
onQualityChange={onQualityChange}
|
||||
onBufferStart={onBufferStart}
|
||||
onBufferEnd={onBufferEnd}
|
||||
onFirstPlay={onFirstPlay}
|
||||
audioTracks={audioTracks}
|
||||
onAudioTracksLoadedInternal={handleAudioTracksLoaded}
|
||||
qualities={qualities}
|
||||
onQualityLevelsLoadedInternal={handleQualityLevelsLoaded}
|
||||
hlsSubtitles={hlsSubtitles}
|
||||
onSubtitleTracksLoadedInternal={handleSubtitleTracksLoaded}
|
||||
/>
|
||||
</PlayerProvider>
|
||||
<PlayerErrorBoundary
|
||||
resetKeys={[src]}
|
||||
onError={(error) => {
|
||||
onError?.(error)
|
||||
}}
|
||||
>
|
||||
<PlayerProvider initialMuted={muted} language={language} customTranslations={customTranslations}>
|
||||
<VideoPlayerContent
|
||||
ref={ref}
|
||||
src={src}
|
||||
poster={poster}
|
||||
protocol={protocol}
|
||||
autoplay={autoplay}
|
||||
loop={loop}
|
||||
muted={muted}
|
||||
volume={volume}
|
||||
playbackRate={playbackRate}
|
||||
currentTime={currentTime}
|
||||
crossOrigin={crossOrigin}
|
||||
preload={preload}
|
||||
playsInline={playsInline}
|
||||
controlsList={controlsList}
|
||||
controls={controls}
|
||||
subtitles={subtitles}
|
||||
subtitleStyle={subtitleStyle}
|
||||
subtitlePosition={subtitlePosition}
|
||||
subtitleOffset={subtitleOffset}
|
||||
theme={theme}
|
||||
keyboardShortcuts={keyboardShortcuts}
|
||||
pictureInPicture={pictureInPicture}
|
||||
className={className}
|
||||
style={style}
|
||||
controlsAutoHideDelay={controlsAutoHideDelay}
|
||||
playbackRates={playbackRates}
|
||||
aspectRatio={aspectRatio}
|
||||
keyboardShortcutConfig={keyboardShortcutConfig}
|
||||
touchConfig={touchConfig}
|
||||
children={children}
|
||||
controlsLeftExtra={controlsLeftExtra}
|
||||
controlsRightExtra={controlsRightExtra}
|
||||
onPlay={onPlay}
|
||||
onPause={onPause}
|
||||
onEnded={onEnded}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onError={onError}
|
||||
onLoadedMetadata={onLoadedMetadata}
|
||||
onSeeking={onSeeking}
|
||||
onSeeked={onSeeked}
|
||||
onProgress={onProgress}
|
||||
onDurationChange={onDurationChange}
|
||||
onRateChange={onRateChange}
|
||||
onFullscreenChange={onFullscreenChange}
|
||||
onPictureInPictureChange={onPictureInPictureChange}
|
||||
onWaiting={onWaiting}
|
||||
onCanPlay={onCanPlay}
|
||||
onQualityChange={onQualityChange}
|
||||
onBufferStart={onBufferStart}
|
||||
onBufferEnd={onBufferEnd}
|
||||
onFirstPlay={onFirstPlay}
|
||||
audioTracks={audioTracks}
|
||||
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;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
@@ -8,7 +8,7 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.center-play-button {
|
||||
.sp-center-play-button {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: var(--player-radius-full);
|
||||
@@ -28,35 +28,35 @@
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.center-play-button:hover {
|
||||
.sp-center-play-button:hover {
|
||||
background-color: var(--player-primary-hover);
|
||||
transform: scale(1.08);
|
||||
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);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.center-play-button:focus-visible {
|
||||
.sp-center-play-button:focus-visible {
|
||||
outline: 2px solid var(--player-text);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.center-play-button svg {
|
||||
.sp-center-play-button svg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.center-play-button {
|
||||
.sp-center-play-button {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.center-play-button svg {
|
||||
.sp-center-play-button svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ export const CenterPlayButton: React.FC = () => {
|
||||
const { play, translations } = usePlayerContext()
|
||||
|
||||
return (
|
||||
<div className="center-play-overlay">
|
||||
<div className="sp-center-play-overlay">
|
||||
<button
|
||||
className="center-play-button"
|
||||
className="sp-center-play-button"
|
||||
type="button"
|
||||
onClick={play}
|
||||
aria-label={translations.play}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.control-button {
|
||||
.sp-control-button {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -14,37 +14,37 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.control-button:hover:not(:disabled) {
|
||||
.sp-control-button:hover:not(:disabled) {
|
||||
color: var(--player-primary);
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.control-button:active:not(:disabled) {
|
||||
.sp-control-button:active:not(:disabled) {
|
||||
color: var(--player-primary-active);
|
||||
}
|
||||
|
||||
.control-button:focus-visible {
|
||||
.sp-control-button:focus-visible {
|
||||
outline: 2px solid var(--player-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.control-button:disabled {
|
||||
.sp-control-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.control-button svg {
|
||||
.sp-control-button svg {
|
||||
width: var(--player-icon-md);
|
||||
height: var(--player-icon-md);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.control-button {
|
||||
.sp-control-button {
|
||||
padding: var(--player-spacing-xs);
|
||||
}
|
||||
|
||||
.control-button svg {
|
||||
.sp-control-button svg {
|
||||
width: var(--player-icon-sm);
|
||||
height: var(--player-icon-sm);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const FullscreenButton: React.FC = () => {
|
||||
|
||||
return (
|
||||
<button
|
||||
className="control-button fullscreen-button"
|
||||
className="sp-control-button sp-fullscreen-button"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label={actionLabel}
|
||||
title={`${actionLabel} (F)`}
|
||||
|
||||
@@ -11,8 +11,8 @@ export const PIPButton: React.FC = () => {
|
||||
|
||||
// Check if PIP is supported
|
||||
const isPIPSupported =
|
||||
typeof (document as any).pictureInPictureEnabled === 'boolean' &&
|
||||
(document as any).pictureInPictureEnabled &&
|
||||
'pictureInPictureEnabled' in document &&
|
||||
document.pictureInPictureEnabled &&
|
||||
typeof HTMLVideoElement.prototype.requestPictureInPicture === 'function'
|
||||
|
||||
if (!isPIPSupported) {
|
||||
@@ -21,7 +21,7 @@ export const PIPButton: React.FC = () => {
|
||||
|
||||
return (
|
||||
<button
|
||||
className="control-button pip-button"
|
||||
className="sp-control-button sp-pip-button"
|
||||
onClick={togglePictureInPicture}
|
||||
aria-label={actionLabel}
|
||||
title={`${actionLabel} (P)`}
|
||||
|
||||
@@ -9,7 +9,7 @@ export const PlayPauseButton: React.FC = () => {
|
||||
|
||||
return (
|
||||
<button
|
||||
className="control-button play-pause-button"
|
||||
className="sp-control-button sp-play-pause-button"
|
||||
onClick={togglePlay}
|
||||
aria-label={actionLabel}
|
||||
title={`${actionLabel} (Space)`}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.progress-bar {
|
||||
.sp-progress-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
@@ -7,7 +7,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
.sp-progress-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
@@ -17,12 +17,12 @@
|
||||
transition: height var(--player-transition-fast) ease;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-track,
|
||||
.progress-bar.seeking .progress-track {
|
||||
.sp-progress-bar:hover .sp-progress-track,
|
||||
.sp-progress-bar.seeking .sp-progress-track {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.progress-buffered {
|
||||
.sp-progress-buffered {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 0;
|
||||
@@ -31,7 +31,7 @@
|
||||
transition: width 0.12s ease;
|
||||
}
|
||||
|
||||
.progress-played {
|
||||
.sp-progress-played {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 0;
|
||||
@@ -43,7 +43,7 @@
|
||||
transition: width 0.12s ease;
|
||||
}
|
||||
|
||||
.progress-handle {
|
||||
.sp-progress-handle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
@@ -54,13 +54,13 @@
|
||||
margin-right: -6px;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-handle,
|
||||
.progress-bar.seeking .progress-handle {
|
||||
.sp-progress-bar:hover .sp-progress-handle,
|
||||
.sp-progress-bar.seeking .sp-progress-handle {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.progress-tooltip {
|
||||
.sp-progress-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
@@ -77,7 +77,7 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.progress-tooltip::after {
|
||||
.sp-progress-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
@@ -89,20 +89,20 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.progress-bar {
|
||||
.sp-progress-bar {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
.sp-progress-track {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-track,
|
||||
.progress-bar.seeking .progress-track {
|
||||
.sp-progress-bar:hover .sp-progress-track,
|
||||
.sp-progress-bar.seeking .sp-progress-track {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.progress-handle {
|
||||
.sp-progress-handle {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-right: -5px;
|
||||
|
||||
@@ -77,7 +77,7 @@ export const ProgressBar: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
ref={progressRef}
|
||||
className={`progress-bar ${seeking ? 'seeking' : ''}`}
|
||||
className={`sp-progress-bar ${seeking ? 'seeking' : ''}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
@@ -90,20 +90,20 @@ export const ProgressBar: React.FC = () => {
|
||||
aria-valuetext={formatTime(videoState.currentTime)}
|
||||
>
|
||||
{/* Background track */}
|
||||
<div className="progress-track">
|
||||
<div className="sp-progress-track">
|
||||
{/* Buffered progress */}
|
||||
<div className="progress-buffered" style={{ width: `${buffered}%` }} />
|
||||
<div className="sp-progress-buffered" style={{ width: `${buffered}%` }} />
|
||||
|
||||
{/* Played progress */}
|
||||
<div className="progress-played" style={{ width: `${progress}%` }}>
|
||||
<div className="progress-handle" />
|
||||
<div className="sp-progress-played" style={{ width: `${progress}%` }}>
|
||||
<div className="sp-progress-handle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover time tooltip */}
|
||||
{hoverTime !== null && (
|
||||
<div
|
||||
className="progress-tooltip"
|
||||
className="sp-progress-tooltip"
|
||||
style={{
|
||||
left: `${hoverPosition}px`,
|
||||
}}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const SettingsButton: React.FC = () => {
|
||||
|
||||
return (
|
||||
<button
|
||||
className="control-button settings-button"
|
||||
className="sp-control-button sp-settings-button"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={toggleSettings}
|
||||
aria-label={translations.settings}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.time-display {
|
||||
.sp-time-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -10,13 +10,13 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.time-separator,
|
||||
.time-duration {
|
||||
.sp-time-separator,
|
||||
.sp-time-duration {
|
||||
color: var(--player-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.time-display {
|
||||
.sp-time-display {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ export const TimeDisplay: React.FC = () => {
|
||||
const { videoState } = usePlayerContext()
|
||||
|
||||
return (
|
||||
<div className="time-display">
|
||||
<span className="time-current">{formatTime(videoState.currentTime)}</span>
|
||||
<span className="time-separator">/</span>
|
||||
<span className="time-duration">{formatTime(videoState.duration)}</span>
|
||||
<div className="sp-time-display">
|
||||
<span className="sp-time-current">{formatTime(videoState.currentTime)}</span>
|
||||
<span className="sp-time-separator">/</span>
|
||||
<span className="sp-time-duration">{formatTime(videoState.duration)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
.volume-control {
|
||||
.sp-volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--player-spacing-xs);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.volume-slider-container {
|
||||
.sp-volume-slider-container {
|
||||
position: relative;
|
||||
width: 0;
|
||||
height: 4px;
|
||||
@@ -17,12 +17,12 @@
|
||||
opacity var(--player-transition-normal) ease;
|
||||
}
|
||||
|
||||
.volume-slider-container.visible {
|
||||
.sp-volume-slider-container.visible {
|
||||
width: 88px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
.sp-volume-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
@@ -32,18 +32,18 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-slider:focus-visible {
|
||||
.sp-volume-slider:focus-visible {
|
||||
outline: 2px solid var(--player-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--player-radius-sm);
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-runnable-track {
|
||||
.sp-volume-slider::-webkit-slider-runnable-track {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
.sp-volume-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
@@ -56,17 +56,17 @@
|
||||
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);
|
||||
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%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb {
|
||||
.sp-volume-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
@@ -77,12 +77,12 @@
|
||||
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);
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.volume-slider-fill {
|
||||
.sp-volume-slider-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -95,7 +95,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.volume-slider-container.visible {
|
||||
.sp-volume-slider-container.visible {
|
||||
width: 72px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,12 @@ export const VolumeControl: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="volume-control"
|
||||
className="sp-volume-control"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
className="control-button volume-button"
|
||||
className="sp-control-button sp-volume-button"
|
||||
onClick={toggleMute}
|
||||
aria-label={actionLabel}
|
||||
title={`${actionLabel} (M)`}
|
||||
@@ -47,7 +47,7 @@ export const VolumeControl: React.FC = () => {
|
||||
<VolumeIcon size={24} color="var(--player-text)" />
|
||||
</button>
|
||||
|
||||
<div className={`volume-slider-container ${showSlider ? 'visible' : ''}`}>
|
||||
<div className={`sp-volume-slider-container ${showSlider ? 'visible' : ''}`}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
@@ -55,11 +55,11 @@ export const VolumeControl: React.FC = () => {
|
||||
step="0.01"
|
||||
value={videoState.muted ? 0 : videoState.volume}
|
||||
onChange={handleSliderChange}
|
||||
className="volume-slider"
|
||||
className="sp-volume-slider"
|
||||
aria-label={translations.volume}
|
||||
/>
|
||||
<div
|
||||
className="volume-slider-fill"
|
||||
className="sp-volume-slider-fill"
|
||||
style={{ width: `${(videoState.muted ? 0 : videoState.volume) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.settings-menu {
|
||||
.sp-settings-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 12px);
|
||||
right: 0;
|
||||
@@ -10,10 +10,10 @@
|
||||
overflow: hidden;
|
||||
z-index: var(--player-z-menu);
|
||||
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;
|
||||
align-items: center;
|
||||
gap: var(--player-spacing-sm);
|
||||
@@ -21,14 +21,14 @@
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.settings-menu-header h3 {
|
||||
.sp-settings-menu-header h3 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-back-button {
|
||||
.sp-settings-back-button {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -44,17 +44,17 @@
|
||||
background-color var(--player-transition-fast) ease;
|
||||
}
|
||||
|
||||
.settings-back-button:hover {
|
||||
.sp-settings-back-button:hover {
|
||||
color: var(--player-primary);
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.settings-main-options {
|
||||
.sp-settings-main-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-main-option {
|
||||
.sp-settings-main-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--player-spacing-md);
|
||||
@@ -69,11 +69,11 @@
|
||||
color var(--player-transition-fast) ease;
|
||||
}
|
||||
|
||||
.settings-main-option:hover {
|
||||
.sp-settings-main-option:hover {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.settings-main-option-icon {
|
||||
.sp-settings-main-option-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
@@ -83,36 +83,36 @@
|
||||
background-color: rgba(239, 68, 68, 0.14);
|
||||
}
|
||||
|
||||
.settings-main-option-content {
|
||||
.sp-settings-main-option-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.settings-main-option-label {
|
||||
.sp-settings-main-option-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-main-option-value {
|
||||
.sp-settings-main-option-value {
|
||||
font-size: 12px;
|
||||
color: var(--player-text-secondary);
|
||||
}
|
||||
|
||||
.settings-main-option-arrow {
|
||||
.sp-settings-main-option-arrow {
|
||||
font-size: 18px;
|
||||
color: var(--player-text-secondary);
|
||||
}
|
||||
|
||||
.settings-options {
|
||||
.sp-settings-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-option {
|
||||
.sp-settings-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -128,55 +128,55 @@
|
||||
color var(--player-transition-fast) ease;
|
||||
}
|
||||
|
||||
.settings-option:hover {
|
||||
.sp-settings-option:hover {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.settings-option.active {
|
||||
.sp-settings-option.active {
|
||||
color: var(--player-primary);
|
||||
background-color: rgba(239, 68, 68, 0.14);
|
||||
}
|
||||
|
||||
.settings-option span {
|
||||
.sp-settings-option span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-empty-state {
|
||||
.sp-settings-empty-state {
|
||||
padding: var(--player-spacing-xl) var(--player-spacing-lg);
|
||||
text-align: center;
|
||||
color: var(--player-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-options::-webkit-scrollbar {
|
||||
.sp-settings-options::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.settings-options::-webkit-scrollbar-track {
|
||||
.sp-settings-options::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-options::-webkit-scrollbar-thumb {
|
||||
.sp-settings-options::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.18);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.settings-options::-webkit-scrollbar-thumb:hover {
|
||||
.sp-settings-options::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settings-menu {
|
||||
.sp-settings-menu {
|
||||
min-width: 240px;
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.settings-main-option,
|
||||
.settings-option {
|
||||
.sp-settings-main-option,
|
||||
.sp-settings-option {
|
||||
padding: var(--player-spacing-sm) var(--player-spacing-md);
|
||||
}
|
||||
|
||||
.settings-options {
|
||||
.sp-settings-options {
|
||||
max-height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,67 +64,67 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
if (!uiState.settingsOpen) return null
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="settings-menu">
|
||||
<div ref={menuRef} className="sp-settings-menu">
|
||||
{/* Main Menu */}
|
||||
{currentView === 'main' && (
|
||||
<>
|
||||
<div className="settings-menu-header">
|
||||
<div className="sp-settings-menu-header">
|
||||
<h3>{translations.settings}</h3>
|
||||
</div>
|
||||
<div className="settings-main-options">
|
||||
<div className="sp-settings-main-options">
|
||||
{qualities.length > 0 && (
|
||||
<button className="settings-main-option" onClick={() => setCurrentView('quality')}>
|
||||
<div className="settings-main-option-icon">
|
||||
<button className="sp-settings-main-option" onClick={() => setCurrentView('quality')}>
|
||||
<div className="sp-settings-main-option-icon">
|
||||
<QualityIcon size={20} color="var(--player-text)" />
|
||||
</div>
|
||||
<div className="settings-main-option-content">
|
||||
<span className="settings-main-option-label">{translations.quality}</span>
|
||||
<span className="settings-main-option-value">
|
||||
<div className="sp-settings-main-option-content">
|
||||
<span className="sp-settings-main-option-label">{translations.quality}</span>
|
||||
<span className="sp-settings-main-option-value">
|
||||
{settings.quality ? settings.quality.label : translations.auto}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-main-option-arrow">›</div>
|
||||
<div className="sp-settings-main-option-arrow">›</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="settings-main-option" onClick={() => setCurrentView('speed')}>
|
||||
<div className="settings-main-option-icon">
|
||||
<button className="sp-settings-main-option" onClick={() => setCurrentView('speed')}>
|
||||
<div className="sp-settings-main-option-icon">
|
||||
<SpeedIcon size={20} color="var(--player-text)" />
|
||||
</div>
|
||||
<div className="settings-main-option-content">
|
||||
<span className="settings-main-option-label">{translations.speed}</span>
|
||||
<span className="settings-main-option-value">
|
||||
<div className="sp-settings-main-option-content">
|
||||
<span className="sp-settings-main-option-label">{translations.speed}</span>
|
||||
<span className="sp-settings-main-option-value">
|
||||
{videoState.playbackRate === 1 ? translations.normal : `${videoState.playbackRate}x`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-main-option-arrow">›</div>
|
||||
<div className="sp-settings-main-option-arrow">›</div>
|
||||
</button>
|
||||
|
||||
<button className="settings-main-option" onClick={() => setCurrentView('subtitles')}>
|
||||
<div className="settings-main-option-icon">
|
||||
<button className="sp-settings-main-option" onClick={() => setCurrentView('subtitles')}>
|
||||
<div className="sp-settings-main-option-icon">
|
||||
<SubtitlesIcon size={20} color="var(--player-text)" />
|
||||
</div>
|
||||
<div className="settings-main-option-content">
|
||||
<span className="settings-main-option-label">{translations.subtitles}</span>
|
||||
<span className="settings-main-option-value">
|
||||
<div className="sp-settings-main-option-content">
|
||||
<span className="sp-settings-main-option-label">{translations.subtitles}</span>
|
||||
<span className="sp-settings-main-option-value">
|
||||
{settings.subtitle ? settings.subtitle.label : translations.off}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-main-option-arrow">›</div>
|
||||
<div className="sp-settings-main-option-arrow">›</div>
|
||||
</button>
|
||||
|
||||
{audioTracks.length > 0 && (
|
||||
<button className="settings-main-option" onClick={() => setCurrentView('audio')}>
|
||||
<div className="settings-main-option-icon">
|
||||
<button className="sp-settings-main-option" onClick={() => setCurrentView('audio')}>
|
||||
<div className="sp-settings-main-option-icon">
|
||||
<AudioIcon size={20} color="var(--player-text)" />
|
||||
</div>
|
||||
<div className="settings-main-option-content">
|
||||
<span className="settings-main-option-label">{translations.audioTrack}</span>
|
||||
<span className="settings-main-option-value">
|
||||
<div className="sp-settings-main-option-content">
|
||||
<span className="sp-settings-main-option-label">{translations.audioTrack}</span>
|
||||
<span className="sp-settings-main-option-value">
|
||||
{settings.audioTrack ? settings.audioTrack.name : translations.default}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-main-option-arrow">›</div>
|
||||
<div className="sp-settings-main-option-arrow">›</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -134,17 +134,17 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
{/* Speed Submenu */}
|
||||
{currentView === 'speed' && (
|
||||
<>
|
||||
<div className="settings-menu-header">
|
||||
<button className="settings-back-button" onClick={goBack}>
|
||||
<div className="sp-settings-menu-header">
|
||||
<button className="sp-settings-back-button" onClick={goBack}>
|
||||
‹
|
||||
</button>
|
||||
<h3>{translations.speed}</h3>
|
||||
</div>
|
||||
<div className="settings-options">
|
||||
<div className="sp-sp-settings-options">
|
||||
{playbackRates.map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
className={`settings-option ${videoState.playbackRate === rate ? 'active' : ''}`}
|
||||
className={`sp-settings-option ${videoState.playbackRate === rate ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setPlaybackRate(rate)
|
||||
setTimeout(() => goBack(), 150)
|
||||
@@ -161,15 +161,15 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
{/* Subtitles Submenu */}
|
||||
{currentView === 'subtitles' && (
|
||||
<>
|
||||
<div className="settings-menu-header">
|
||||
<button className="settings-back-button" onClick={goBack}>
|
||||
<div className="sp-settings-menu-header">
|
||||
<button className="sp-settings-back-button" onClick={goBack}>
|
||||
‹
|
||||
</button>
|
||||
<h3>{translations.subtitles}</h3>
|
||||
</div>
|
||||
<div className="settings-options">
|
||||
<div className="sp-sp-settings-options">
|
||||
<button
|
||||
className={`settings-option ${!settings.subtitle ? 'active' : ''}`}
|
||||
className={`sp-settings-option ${!settings.subtitle ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSubtitle(null)
|
||||
setTimeout(() => goBack(), 150)
|
||||
@@ -182,7 +182,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
subtitles.map((subtitle) => (
|
||||
<button
|
||||
key={subtitle.lang}
|
||||
className={`settings-option ${settings.subtitle?.lang === subtitle.lang ? 'active' : ''}`}
|
||||
className={`sp-settings-option ${settings.subtitle?.lang === subtitle.lang ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSubtitle(subtitle)
|
||||
setTimeout(() => goBack(), 150)
|
||||
@@ -193,7 +193,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="settings-empty-state">
|
||||
<div className="sp-settings-empty-state">
|
||||
<span>{translations.noSubtitlesAvailable}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -204,17 +204,17 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
{/* Audio Submenu */}
|
||||
{currentView === 'audio' && (
|
||||
<>
|
||||
<div className="settings-menu-header">
|
||||
<button className="settings-back-button" onClick={goBack}>
|
||||
<div className="sp-settings-menu-header">
|
||||
<button className="sp-settings-back-button" onClick={goBack}>
|
||||
‹
|
||||
</button>
|
||||
<h3>{translations.audioTrack}</h3>
|
||||
</div>
|
||||
<div className="settings-options">
|
||||
<div className="sp-sp-settings-options">
|
||||
{audioTracks.map((track) => (
|
||||
<button
|
||||
key={track.language}
|
||||
className={`settings-option ${settings.audioTrack?.language === track.language ? 'active' : ''}`}
|
||||
className={`sp-settings-option ${settings.audioTrack?.language === track.language ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setAudioTrack(track)
|
||||
setTimeout(() => goBack(), 150)
|
||||
@@ -233,15 +233,15 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
{/* Quality Submenu */}
|
||||
{currentView === 'quality' && (
|
||||
<>
|
||||
<div className="settings-menu-header">
|
||||
<button className="settings-back-button" onClick={goBack}>
|
||||
<div className="sp-settings-menu-header">
|
||||
<button className="sp-settings-back-button" onClick={goBack}>
|
||||
‹
|
||||
</button>
|
||||
<h3>{translations.quality}</h3>
|
||||
</div>
|
||||
<div className="settings-options">
|
||||
<div className="sp-sp-settings-options">
|
||||
<button
|
||||
className={`settings-option ${!settings.quality ? 'active' : ''}`}
|
||||
className={`sp-settings-option ${!settings.quality ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setQuality(null)
|
||||
setTimeout(() => goBack(), 150)
|
||||
@@ -269,7 +269,7 @@ export const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
return (
|
||||
<button
|
||||
key={(quality.levelIndex ?? quality.label).toString()}
|
||||
className={`settings-option ${isActive ? 'active' : ''}`}
|
||||
className={`sp-settings-option ${isActive ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setQuality(quality)
|
||||
setTimeout(() => goBack(), 150)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.loading-spinner-overlay {
|
||||
.sp-loading-spinner-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
@@ -10,6 +10,6 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
animation: fadeIn var(--player-transition-normal) ease;
|
||||
.sp-loading-spinner {
|
||||
animation: sp-fade-in var(--player-transition-normal) ease;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import './LoadingSpinner.css'
|
||||
|
||||
export const LoadingSpinner: React.FC = () => {
|
||||
return (
|
||||
<div className="loading-spinner-overlay">
|
||||
<div className="loading-spinner">
|
||||
<div className="sp-loading-spinner-overlay">
|
||||
<div className="sp-loading-spinner">
|
||||
<LoadingIcon size={48} color="var(--player-primary)" />
|
||||
</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.fontSize = '48px'
|
||||
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 »`
|
||||
|
||||
container?.appendChild(feedback)
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ interface BaseIconProps extends IconProps {
|
||||
const Icon: React.FC<BaseIconProps> = ({ size = 24, className = '', color = 'currentColor', children }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" className={className}>
|
||||
{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>
|
||||
)
|
||||
@@ -104,7 +104,7 @@ export const RewindIcon: React.FC<IconProps> = (props) => (
|
||||
)
|
||||
|
||||
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 12h2c0-4.42 3.58-8 8-8s8 3.58 8 8h2c0-5.52-4.48-10-10-10z" fill={color} />
|
||||
</svg>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Main component
|
||||
export { VideoPlayer } from './components/VideoPlayer'
|
||||
export { PlayerErrorBoundary } from './components/ErrorBoundary'
|
||||
export type {
|
||||
PlayerErrorBoundaryProps,
|
||||
PlayerErrorBoundaryFallbackRender,
|
||||
} from './components/ErrorBoundary'
|
||||
|
||||
// Context
|
||||
export { PlayerProvider, usePlayerContext } from './contexts/PlayerContext'
|
||||
|
||||
@@ -44,13 +44,13 @@
|
||||
--player-subtitle-bottom-hidden: 36px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@keyframes sp-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@keyframes sp-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
@keyframes sp-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -69,9 +69,9 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.video-player *,
|
||||
.video-player *::before,
|
||||
.video-player *::after {
|
||||
.sp-video-player *,
|
||||
.sp-video-player *::before,
|
||||
.sp-video-player *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !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
|
||||
*/
|
||||
|
||||
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.
|
||||
*/
|
||||
export const setHlsQualityLevel = (hls: any, levelIndex: number | null | undefined): void => {
|
||||
if (!hls || !Array.isArray(hls.levels)) {
|
||||
export const setHlsQualityLevel = (
|
||||
hls: HlsInstance | PlayerInstance | null | undefined,
|
||||
levelIndex: number | null | undefined
|
||||
): void => {
|
||||
if (!hasQualityControls(hls)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -30,8 +45,11 @@ export const setHlsQualityLevel = (hls: any, levelIndex: number | null | undefin
|
||||
/**
|
||||
* Set active audio track in HLS instance
|
||||
*/
|
||||
export const setHlsAudioTrack = (hls: any, audioTrackIndex: number): void => {
|
||||
if (!hls || !hls.audioTracks) {
|
||||
export const setHlsAudioTrack = (
|
||||
hls: HlsInstance | PlayerInstance | null | undefined,
|
||||
audioTrackIndex: number
|
||||
): void => {
|
||||
if (!hasAudioControls(hls)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+15
-17
@@ -10,16 +10,16 @@ import { logger } from './logger'
|
||||
// Re-export control functions for backward compatibility
|
||||
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
|
||||
*/
|
||||
const loadHlsFromCDN = (): Promise<any> => {
|
||||
const loadHlsFromCDN = (): Promise<HlsConstructor> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if already loaded globally
|
||||
if (typeof (window as any).Hls !== 'undefined') {
|
||||
resolve((window as any).Hls)
|
||||
if (window.Hls) {
|
||||
resolve(window.Hls)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ const loadHlsFromCDN = (): Promise<any> => {
|
||||
script.async = true
|
||||
|
||||
script.onload = () => {
|
||||
if (typeof (window as any).Hls !== 'undefined') {
|
||||
resolve((window as any).Hls)
|
||||
if (window.Hls) {
|
||||
resolve(window.Hls)
|
||||
} else {
|
||||
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
|
||||
*/
|
||||
export const loadHls = async (): Promise<any> => {
|
||||
export const loadHls = async (): Promise<HlsConstructor> => {
|
||||
try {
|
||||
logger.log('[HLS Loader] Attempting to load from npm package...')
|
||||
// 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
|
||||
*/
|
||||
export const isHlsSupported = (Hls: any): boolean => {
|
||||
export const isHlsSupported = (Hls: HlsConstructor): boolean => {
|
||||
return Hls && typeof Hls.isSupported === 'function' && Hls.isSupported()
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export const hasNativeHlsSupport = (): boolean => {
|
||||
/**
|
||||
* Extract audio tracks from HLS instance
|
||||
*/
|
||||
export const getHlsAudioTracks = (hls: any): AudioTrack[] => {
|
||||
export const getHlsAudioTracks = (hls: HlsInstance): AudioTrack[] => {
|
||||
try {
|
||||
if (!hls) {
|
||||
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)
|
||||
|
||||
const audioTracks: AudioTrack[] = hls.audioTracks.map((track: any, index: number) => {
|
||||
const audioTracks: AudioTrack[] = hls.audioTracks.map((track: HlsAudioTrack, index: number) => {
|
||||
const audioTrack = {
|
||||
name: track.name || track.label || `Audio ${index + 1}`,
|
||||
language: track.lang || track.language || 'unknown',
|
||||
@@ -123,7 +123,7 @@ export const getHlsAudioTracks = (hls: any): AudioTrack[] => {
|
||||
/**
|
||||
* Extract subtitle tracks from HLS instance
|
||||
*/
|
||||
export const getHlsSubtitleTracks = (hls: any): SubtitleTrack[] => {
|
||||
export const getHlsSubtitleTracks = (hls: HlsInstance): SubtitleTrack[] => {
|
||||
try {
|
||||
if (!hls) {
|
||||
return []
|
||||
@@ -134,7 +134,7 @@ export const getHlsSubtitleTracks = (hls: any): SubtitleTrack[] => {
|
||||
return []
|
||||
}
|
||||
|
||||
const subtitleTracks: SubtitleTrack[] = hls.subtitleTracks.map((track: any, index: number) => {
|
||||
const subtitleTracks: SubtitleTrack[] = hls.subtitleTracks.map((track: HlsSubtitleTrack, index: number) => {
|
||||
return {
|
||||
label: track.name || track.label || `Subtitle ${index + 1}`,
|
||||
lang: track.lang || track.language || 'unknown',
|
||||
@@ -152,7 +152,7 @@ export const getHlsSubtitleTracks = (hls: any): SubtitleTrack[] => {
|
||||
/**
|
||||
* Extract available quality levels from HLS instance
|
||||
*/
|
||||
export const getHlsQualities = (hls: any): VideoQuality[] => {
|
||||
export const getHlsQualities = (hls: HlsInstance): VideoQuality[] => {
|
||||
try {
|
||||
if (!hls) {
|
||||
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)
|
||||
|
||||
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 [widthFromResolution, heightFromResolution] = resolution
|
||||
? resolution.split('x').map((value: string) => parseInt(value, 10))
|
||||
@@ -175,7 +175,7 @@ export const getHlsQualities = (hls: any): VideoQuality[] => {
|
||||
const translations = getTranslations(detectBrowserLanguage());
|
||||
const width = level.width || widthFromResolution
|
||||
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
|
||||
if (typeof level.name === 'string' && level.name.trim().length > 0) {
|
||||
@@ -213,5 +213,3 @@ export const getHlsQualities = (hls: any): VideoQuality[] => {
|
||||
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) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
@@ -131,13 +131,13 @@ export const setupHlsInstance = async ({
|
||||
}
|
||||
})
|
||||
|
||||
;(video as any).__hlsInstance = hls
|
||||
video.__hlsInstance = hls as PlayerInstance
|
||||
|
||||
return () => {
|
||||
if (hls) {
|
||||
hls.destroy()
|
||||
}
|
||||
delete (video as any).__hlsInstance
|
||||
delete video.__hlsInstance
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+53
-11
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* MPEG-TS loader utility
|
||||
* Dynamically loads mpegts.js library
|
||||
* Dynamically loads mpegts.js library with CDN fallback
|
||||
*/
|
||||
import { logger } from './logger'
|
||||
|
||||
const MPEGTS_CDN_URL = 'https://cdn.jsdelivr.net/npm/mpegts.js@1.7.3/dist/mpegts.js'
|
||||
|
||||
export interface MpegtsConfig {
|
||||
enableWorker?: boolean
|
||||
enableStashBuffer?: boolean
|
||||
@@ -20,14 +22,45 @@ export interface MpegtsConfig {
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
let mpegtsInstance: any = null
|
||||
let loadingPromise: Promise<any> | null = null
|
||||
let mpegtsInstance: MpegtsStatic | 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
|
||||
* Tries NPM package first, falls back to CDN if unavailable
|
||||
* @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
|
||||
if (mpegtsInstance) {
|
||||
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')
|
||||
mpegtsInstance = module.default || module
|
||||
logger.log('[MPEG-TS Loader] Successfully loaded from npm package')
|
||||
return mpegtsInstance
|
||||
} catch (error) {
|
||||
logger.error('[MPEG-TS Loader] Failed to load mpegts.js:', error)
|
||||
throw new Error('Failed to load mpegts.js. Make sure it is installed: npm install mpegts.js')
|
||||
return mpegtsInstance!
|
||||
} catch (npmError) {
|
||||
logger.warn('[MPEG-TS Loader] npm package not available, loading from CDN...', npmError)
|
||||
|
||||
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 {
|
||||
loadingPromise = null
|
||||
}
|
||||
@@ -64,7 +107,7 @@ export const loadMpegts = async (): Promise<any> => {
|
||||
* @param mpegts - The mpegts.js module
|
||||
* @returns True if supported
|
||||
*/
|
||||
export const isMpegtsSupported = (mpegts: any): boolean => {
|
||||
export const isMpegtsSupported = (mpegts: MpegtsStatic): boolean => {
|
||||
return mpegts && mpegts.isSupported()
|
||||
}
|
||||
|
||||
@@ -94,7 +137,7 @@ export const createDefaultMpegtsConfig = (isLive: boolean = false): MpegtsConfig
|
||||
* Get the cached mpegts.js instance
|
||||
* @returns The mpegts.js module or null
|
||||
*/
|
||||
export const getMpegtsInstance = (): any | null => {
|
||||
export const getMpegtsInstance = (): MpegtsStatic | null => {
|
||||
return mpegtsInstance
|
||||
}
|
||||
|
||||
@@ -106,4 +149,3 @@ export const clearMpegtsCache = (): void => {
|
||||
loadingPromise = null
|
||||
logger.log('[MPEG-TS Loader] Cache cleared')
|
||||
}
|
||||
|
||||
|
||||
+11
-12
@@ -69,10 +69,10 @@ export const setupMpegtsInstance = async ({
|
||||
player.load()
|
||||
|
||||
// Store player instance on video element for later access
|
||||
;(video as any).__mpegtsInstance = player
|
||||
video.__mpegtsInstance = player
|
||||
|
||||
// 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 })
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
// 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
|
||||
if (stats) {
|
||||
;(video as any).__mpegtsStats = stats
|
||||
video.__mpegtsStats = stats
|
||||
}
|
||||
})
|
||||
|
||||
@@ -174,8 +174,8 @@ export const setupMpegtsInstance = async ({
|
||||
player.destroy()
|
||||
|
||||
// Clean up stored references
|
||||
delete (video as any).__mpegtsInstance
|
||||
delete (video as any).__mpegtsStats
|
||||
delete video.__mpegtsInstance
|
||||
delete video.__mpegtsStats
|
||||
} catch (cleanupError) {
|
||||
logger.error('Error during mpegts.js cleanup:', cleanupError)
|
||||
}
|
||||
@@ -199,11 +199,11 @@ export const setupMpegtsInstance = async ({
|
||||
* @param video - The video element
|
||||
* @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) {
|
||||
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
|
||||
* @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) {
|
||||
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 => {
|
||||
return getMpegtsInstance(video) !== null
|
||||
}
|
||||
|
||||
|
||||
+4
-16
@@ -9,21 +9,15 @@
|
||||
*/
|
||||
export const setupFullscreenPolyfill = () => {
|
||||
if (!document.exitFullscreen) {
|
||||
// @ts-ignore - Legacy API
|
||||
document.exitFullscreen = document.webkitExitFullscreen ||
|
||||
// @ts-ignore
|
||||
document.exitFullscreen = (document.webkitExitFullscreen ||
|
||||
document.mozCancelFullScreen ||
|
||||
// @ts-ignore
|
||||
document.msExitFullscreen
|
||||
document.msExitFullscreen) as typeof document.exitFullscreen
|
||||
}
|
||||
|
||||
if (!Element.prototype.requestFullscreen) {
|
||||
// @ts-ignore - Legacy API
|
||||
Element.prototype.requestFullscreen = Element.prototype.webkitRequestFullscreen ||
|
||||
// @ts-ignore
|
||||
Element.prototype.requestFullscreen = (Element.prototype.webkitRequestFullscreen ||
|
||||
Element.prototype.mozRequestFullScreen ||
|
||||
// @ts-ignore
|
||||
Element.prototype.msRequestFullscreen
|
||||
Element.prototype.msRequestFullscreen) as typeof Element.prototype.requestFullscreen
|
||||
}
|
||||
|
||||
// Fullscreen change event polyfill
|
||||
@@ -41,11 +35,8 @@ export const setupFullscreenPolyfill = () => {
|
||||
if (!Object.prototype.hasOwnProperty.call(document, 'fullscreenElement')) {
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
get: function() {
|
||||
// @ts-ignore
|
||||
return this.webkitFullscreenElement ||
|
||||
// @ts-ignore
|
||||
this.mozFullScreenElement ||
|
||||
// @ts-ignore
|
||||
this.msFullscreenElement
|
||||
}
|
||||
})
|
||||
@@ -157,11 +148,8 @@ export const features = {
|
||||
if (!isBrowser) return false
|
||||
return !!(
|
||||
document.fullscreenEnabled ||
|
||||
// @ts-ignore
|
||||
document.webkitFullscreenEnabled ||
|
||||
// @ts-ignore
|
||||
document.mozFullScreenEnabled ||
|
||||
// @ts-ignore
|
||||
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
|
||||
* @returns Promise that resolves to the flv.js library
|
||||
*/
|
||||
const loadFlvjsFromCDN = async (): Promise<any> => {
|
||||
const loadFlvjsFromCDN = async (): Promise<FlvjsStatic> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if ((window as any).flvjs) {
|
||||
resolve((window as any).flvjs)
|
||||
if (window.flvjs) {
|
||||
resolve(window.flvjs)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ const loadFlvjsFromCDN = async (): Promise<any> => {
|
||||
script.async = true
|
||||
|
||||
script.onload = () => {
|
||||
if ((window as any).flvjs) {
|
||||
resolve((window as any).flvjs)
|
||||
if (window.flvjs) {
|
||||
resolve(window.flvjs)
|
||||
} else {
|
||||
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
|
||||
* @returns Promise that resolves to the flv.js library
|
||||
*/
|
||||
export const loadFlvjs = async (): Promise<any> => {
|
||||
export const loadFlvjs = async (): Promise<FlvjsStatic> => {
|
||||
try {
|
||||
// Try loading from NPM package first
|
||||
const flvModule = await import('flv.js')
|
||||
@@ -69,7 +69,7 @@ export const loadFlvjs = async (): Promise<any> => {
|
||||
* @param flvjs - The flv.js library instance
|
||||
* @returns True if supported
|
||||
*/
|
||||
export const isFlvjsSupported = (flvjs: any): boolean => {
|
||||
export const isFlvjsSupported = (flvjs: FlvjsStatic): boolean => {
|
||||
if (!flvjs) {
|
||||
return false
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export const isFlvjsSupported = (flvjs: any): boolean => {
|
||||
* @param flvjs - The flv.js library instance
|
||||
* @returns Support information object
|
||||
*/
|
||||
export const getFlvjsSupportInfo = (flvjs: any): {
|
||||
export const getFlvjsSupportInfo = (flvjs: FlvjsStatic): {
|
||||
mseSupported: boolean
|
||||
networkStreamIOSupported: boolean
|
||||
httpsSupported: boolean
|
||||
@@ -138,7 +138,7 @@ export const createDefaultFlvConfig = (isLive: boolean = true) => {
|
||||
* @param player - The flv.js player instance
|
||||
* @returns Basic quality information
|
||||
*/
|
||||
export const extractFlvQualityInfo = (player: any): {
|
||||
export const extractFlvQualityInfo = (player: FlvjsPlayer): {
|
||||
width?: number
|
||||
height?: number
|
||||
videoCodec?: string
|
||||
@@ -154,17 +154,16 @@ export const extractFlvQualityInfo = (player: any): {
|
||||
try {
|
||||
const stats = player.statisticsInfo
|
||||
return {
|
||||
width: stats.videoWidth,
|
||||
height: stats.videoHeight,
|
||||
videoCodec: stats.videoCodec,
|
||||
audioCodec: stats.audioCodec,
|
||||
fps: stats.fps,
|
||||
videoBitrate: stats.videoBitrate,
|
||||
audioBitrate: stats.audioBitrate,
|
||||
width: stats.videoWidth as number | undefined,
|
||||
height: stats.videoHeight as number | undefined,
|
||||
videoCodec: stats.videoCodec as string | undefined,
|
||||
audioCodec: stats.audioCodec as string | undefined,
|
||||
fps: stats.fps as number | undefined,
|
||||
videoBitrate: stats.videoBitrate as number | undefined,
|
||||
audioBitrate: stats.audioBitrate as number | undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to extract flv.js quality info:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-12
@@ -81,10 +81,10 @@ export const setupRtmpInstance = async ({
|
||||
player.load()
|
||||
|
||||
// Store player instance on video element for later access
|
||||
;(video as any).__rtmpInstance = player
|
||||
video.__rtmpInstance = player
|
||||
|
||||
// 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 })
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
// 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
|
||||
// Can be used to display stream quality, bitrate, etc.
|
||||
if (stats) {
|
||||
;(video as any).__rtmpStats = stats
|
||||
video.__rtmpStats = stats
|
||||
}
|
||||
})
|
||||
|
||||
@@ -187,8 +187,8 @@ export const setupRtmpInstance = async ({
|
||||
player.destroy()
|
||||
|
||||
// Clean up stored references
|
||||
delete (video as any).__rtmpInstance
|
||||
delete (video as any).__rtmpStats
|
||||
delete video.__rtmpInstance
|
||||
delete video.__rtmpStats
|
||||
} catch (cleanupError) {
|
||||
logger.error('Error during flv.js cleanup:', cleanupError)
|
||||
}
|
||||
@@ -212,11 +212,11 @@ export const setupRtmpInstance = async ({
|
||||
* @param video - The video element
|
||||
* @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) {
|
||||
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
|
||||
* @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) {
|
||||
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 => {
|
||||
return getRtmpInstance(video) !== null
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": false,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
|
||||
+2
-1
@@ -42,7 +42,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
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: {
|
||||
globals: {
|
||||
react: 'React',
|
||||
@@ -50,6 +50,7 @@ export default defineConfig({
|
||||
'react/jsx-runtime': 'jsxRuntime',
|
||||
'hls.js': 'Hls',
|
||||
'flv.js': 'flvjs',
|
||||
'mpegts.js': 'mpegts',
|
||||
},
|
||||
compact: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user