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