release: v3.0.0

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