ts-video-player 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +438 -0
- package/dist/cdn.d.ts +10 -0
- package/dist/cdn.d.ts.map +1 -0
- package/dist/cdn.min.js +1046 -0
- package/dist/cdn.min.js.map +54 -0
- package/dist/cdn.test.d.ts +2 -0
- package/dist/cdn.test.d.ts.map +1 -0
- package/dist/chunk-36x4yhjn.js +765 -0
- package/dist/chunk-36x4yhjn.js.map +12 -0
- package/dist/chunk-eajts2jv.js +4243 -0
- package/dist/chunk-eajts2jv.js.map +42 -0
- package/dist/chunk-z1ehbz9f.js +380 -0
- package/dist/chunk-z1ehbz9f.js.map +19 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/core/audio-gain.d.ts +67 -0
- package/dist/core/audio-gain.d.ts.map +1 -0
- package/dist/core/caption-styles.d.ts +121 -0
- package/dist/core/caption-styles.d.ts.map +1 -0
- package/dist/core/chapters.d.ts +150 -0
- package/dist/core/chapters.d.ts.map +1 -0
- package/dist/core/error-overlay.d.ts +71 -0
- package/dist/core/error-overlay.d.ts.map +1 -0
- package/dist/core/events.d.ts +119 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/features.d.ts +64 -0
- package/dist/core/features.d.ts.map +1 -0
- package/dist/core/i18n.d.ts +132 -0
- package/dist/core/i18n.d.ts.map +1 -0
- package/dist/core/index.d.ts +37 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/live.d.ts +130 -0
- package/dist/core/live.d.ts.map +1 -0
- package/dist/core/media-session.d.ts +38 -0
- package/dist/core/media-session.d.ts.map +1 -0
- package/dist/core/orientation.d.ts +22 -0
- package/dist/core/orientation.d.ts.map +1 -0
- package/dist/core/popover.d.ts +42 -0
- package/dist/core/popover.d.ts.map +1 -0
- package/dist/core/remote-playback.d.ts +195 -0
- package/dist/core/remote-playback.d.ts.map +1 -0
- package/dist/core/settings-menu.d.ts +132 -0
- package/dist/core/settings-menu.d.ts.map +1 -0
- package/dist/core/spinner.d.ts +67 -0
- package/dist/core/spinner.d.ts.map +1 -0
- package/dist/core/state.d.ts +142 -0
- package/dist/core/state.d.ts.map +1 -0
- package/dist/core/text-tracks.d.ts +162 -0
- package/dist/core/text-tracks.d.ts.map +1 -0
- package/dist/core/themes.d.ts +213 -0
- package/dist/core/themes.d.ts.map +1 -0
- package/dist/core/thumbnails.d.ts +124 -0
- package/dist/core/thumbnails.d.ts.map +1 -0
- package/dist/core/tooltips.d.ts +100 -0
- package/dist/core/tooltips.d.ts.map +1 -0
- package/dist/elements/base.d.ts +3 -0
- package/dist/elements/base.d.ts.map +1 -0
- package/dist/elements/index.d.ts +33 -0
- package/dist/elements/index.d.ts.map +1 -0
- package/dist/elements/index.js +55 -0
- package/dist/elements/index.js.map +9 -0
- package/dist/elements/media-airplay-button.d.ts +13 -0
- package/dist/elements/media-airplay-button.d.ts.map +1 -0
- package/dist/elements/media-error-display.d.ts +13 -0
- package/dist/elements/media-error-display.d.ts.map +1 -0
- package/dist/elements/media-fullscreen-button.d.ts +13 -0
- package/dist/elements/media-fullscreen-button.d.ts.map +1 -0
- package/dist/elements/media-live-button.d.ts +13 -0
- package/dist/elements/media-live-button.d.ts.map +1 -0
- package/dist/elements/media-mute-button.d.ts +13 -0
- package/dist/elements/media-mute-button.d.ts.map +1 -0
- package/dist/elements/media-pip-button.d.ts +13 -0
- package/dist/elements/media-pip-button.d.ts.map +1 -0
- package/dist/elements/media-play-button.d.ts +14 -0
- package/dist/elements/media-play-button.d.ts.map +1 -0
- package/dist/elements/media-playback-rate-button.d.ts +10 -0
- package/dist/elements/media-playback-rate-button.d.ts.map +1 -0
- package/dist/elements/media-preview-time.d.ts +18 -0
- package/dist/elements/media-preview-time.d.ts.map +1 -0
- package/dist/elements/media-progress-bar.d.ts +16 -0
- package/dist/elements/media-progress-bar.d.ts.map +1 -0
- package/dist/elements/media-remote-playback-button.d.ts +16 -0
- package/dist/elements/media-remote-playback-button.d.ts.map +1 -0
- package/dist/elements/media-seek-button.d.ts +9 -0
- package/dist/elements/media-seek-button.d.ts.map +1 -0
- package/dist/elements/media-settings-menu.d.ts +32 -0
- package/dist/elements/media-settings-menu.d.ts.map +1 -0
- package/dist/elements/media-time-display.d.ts +21 -0
- package/dist/elements/media-time-display.d.ts.map +1 -0
- package/dist/elements/media-time-group.d.ts +22 -0
- package/dist/elements/media-time-group.d.ts.map +1 -0
- package/dist/elements/media-time-separator.d.ts +13 -0
- package/dist/elements/media-time-separator.d.ts.map +1 -0
- package/dist/elements/media-volume-slider.d.ts +16 -0
- package/dist/elements/media-volume-slider.d.ts.map +1 -0
- package/dist/elements/utils.d.ts +27 -0
- package/dist/elements/utils.d.ts.map +1 -0
- package/dist/elements/video-player.d.ts +23 -0
- package/dist/elements/video-player.d.ts.map +1 -0
- package/dist/elements/video-skin.d.ts +23 -0
- package/dist/elements/video-skin.d.ts.map +1 -0
- package/dist/features/captions.d.ts +8 -0
- package/dist/features/captions.d.ts.map +1 -0
- package/dist/features/create-player.d.ts +45 -0
- package/dist/features/create-player.d.ts.map +1 -0
- package/dist/features/fullscreen.d.ts +8 -0
- package/dist/features/fullscreen.d.ts.map +1 -0
- package/dist/features/gestures.d.ts +8 -0
- package/dist/features/gestures.d.ts.map +1 -0
- package/dist/features/index.d.ts +26 -0
- package/dist/features/index.d.ts.map +1 -0
- package/dist/features/index.js +31 -0
- package/dist/features/index.js.map +9 -0
- package/dist/features/keyboard.d.ts +8 -0
- package/dist/features/keyboard.d.ts.map +1 -0
- package/dist/features/pip.d.ts +8 -0
- package/dist/features/pip.d.ts.map +1 -0
- package/dist/features/playback.d.ts +8 -0
- package/dist/features/playback.d.ts.map +1 -0
- package/dist/features/quality.d.ts +8 -0
- package/dist/features/quality.d.ts.map +1 -0
- package/dist/features/types.d.ts +26 -0
- package/dist/features/types.d.ts.map +1 -0
- package/dist/features/volume.d.ts +8 -0
- package/dist/features/volume.d.ts.map +1 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8149 -0
- package/dist/index.js.map +31 -0
- package/dist/layouts/index.d.ts +58 -0
- package/dist/layouts/index.d.ts.map +1 -0
- package/dist/player.d.ts +90 -0
- package/dist/player.d.ts.map +1 -0
- package/dist/plugins/ads.d.ts +311 -0
- package/dist/plugins/ads.d.ts.map +1 -0
- package/dist/plugins/analytics.d.ts +203 -0
- package/dist/plugins/analytics.d.ts.map +1 -0
- package/dist/plugins/end-screen.d.ts +184 -0
- package/dist/plugins/end-screen.d.ts.map +1 -0
- package/dist/plugins/index.d.ts +13 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +20 -0
- package/dist/plugins/index.js.map +9 -0
- package/dist/plugins/skip-segments.d.ts +159 -0
- package/dist/plugins/skip-segments.d.ts.map +1 -0
- package/dist/plugins/watermark.d.ts +157 -0
- package/dist/plugins/watermark.d.ts.map +1 -0
- package/dist/providers/base.d.ts +56 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/dash.d.ts +94 -0
- package/dist/providers/dash.d.ts.map +1 -0
- package/dist/providers/hls.d.ts +182 -0
- package/dist/providers/hls.d.ts.map +1 -0
- package/dist/providers/html5.d.ts +64 -0
- package/dist/providers/html5.d.ts.map +1 -0
- package/dist/providers/index.d.ts +41 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/vimeo.d.ts +157 -0
- package/dist/providers/vimeo.d.ts.map +1 -0
- package/dist/providers/youtube.d.ts +134 -0
- package/dist/providers/youtube.d.ts.map +1 -0
- package/dist/stx/index.d.ts +59 -0
- package/dist/stx/index.d.ts.map +1 -0
- package/dist/stx/index.js +480 -0
- package/dist/stx/index.js.map +11 -0
- package/dist/types.d.ts +724 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/ui/components.d.ts +121 -0
- package/dist/ui/components.d.ts.map +1 -0
- package/dist/ui/index.d.ts +9 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +16 -0
- package/dist/ui/index.js.map +9 -0
- package/package.json +109 -0
- package/src/cdn.test.ts +10 -0
- package/src/cdn.ts +60 -0
- package/src/config.ts +25 -0
- package/src/core/audio-gain.ts +171 -0
- package/src/core/caption-styles.ts +568 -0
- package/src/core/chapters.ts +551 -0
- package/src/core/error-overlay.ts +354 -0
- package/src/core/events.ts +552 -0
- package/src/core/features.ts +287 -0
- package/src/core/i18n.ts +590 -0
- package/src/core/index.ts +249 -0
- package/src/core/live.ts +472 -0
- package/src/core/media-session.ts +137 -0
- package/src/core/orientation.ts +57 -0
- package/src/core/popover.ts +143 -0
- package/src/core/remote-playback.ts +661 -0
- package/src/core/settings-menu.ts +630 -0
- package/src/core/spinner.ts +310 -0
- package/src/core/state.ts +476 -0
- package/src/core/text-tracks.ts +599 -0
- package/src/core/themes.ts +607 -0
- package/src/core/thumbnails.ts +457 -0
- package/src/core/tooltips.ts +370 -0
- package/src/elements/base.ts +2 -0
- package/src/elements/index.ts +82 -0
- package/src/elements/media-airplay-button.ts +78 -0
- package/src/elements/media-error-display.ts +47 -0
- package/src/elements/media-fullscreen-button.ts +72 -0
- package/src/elements/media-live-button.ts +65 -0
- package/src/elements/media-mute-button.ts +69 -0
- package/src/elements/media-pip-button.ts +66 -0
- package/src/elements/media-play-button.ts +69 -0
- package/src/elements/media-playback-rate-button.ts +59 -0
- package/src/elements/media-preview-time.ts +75 -0
- package/src/elements/media-progress-bar.ts +200 -0
- package/src/elements/media-remote-playback-button.ts +108 -0
- package/src/elements/media-seek-button.ts +50 -0
- package/src/elements/media-settings-menu.ts +302 -0
- package/src/elements/media-time-display.ts +99 -0
- package/src/elements/media-time-group.ts +41 -0
- package/src/elements/media-time-separator.ts +35 -0
- package/src/elements/media-volume-slider.ts +140 -0
- package/src/elements/utils.ts +82 -0
- package/src/elements/video-player.ts +175 -0
- package/src/elements/video-skin.ts +127 -0
- package/src/features/captions.ts +56 -0
- package/src/features/create-player.ts +91 -0
- package/src/features/fullscreen.ts +44 -0
- package/src/features/gestures.ts +44 -0
- package/src/features/index.ts +44 -0
- package/src/features/keyboard.ts +89 -0
- package/src/features/pip.ts +47 -0
- package/src/features/playback.ts +68 -0
- package/src/features/quality.ts +19 -0
- package/src/features/types.ts +28 -0
- package/src/features/volume.ts +46 -0
- package/src/index.ts +635 -0
- package/src/layouts/index.ts +672 -0
- package/src/player.ts +821 -0
- package/src/plugins/ads.ts +1045 -0
- package/src/plugins/analytics.ts +673 -0
- package/src/plugins/end-screen.ts +793 -0
- package/src/plugins/index.ts +61 -0
- package/src/plugins/skip-segments.ts +537 -0
- package/src/plugins/watermark.ts +537 -0
- package/src/providers/base.ts +197 -0
- package/src/providers/dash.ts +588 -0
- package/src/providers/hls.ts +624 -0
- package/src/providers/html5.ts +473 -0
- package/src/providers/index.ts +97 -0
- package/src/providers/vimeo.ts +533 -0
- package/src/providers/youtube.ts +542 -0
- package/src/stx/index.ts +606 -0
- package/src/types.ts +874 -0
- package/src/ui/components.ts +910 -0
- package/src/ui/index.ts +29 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ts-video-player <media-volume-slider> Custom Element
|
|
3
|
+
*
|
|
4
|
+
* Volume control slider. Hidden when volume is unsupported (iOS Safari).
|
|
5
|
+
*
|
|
6
|
+
* @module elements/media-volume-slider
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolvePlayer } from './utils'
|
|
10
|
+
|
|
11
|
+
import { HTMLElementBase } from './base'
|
|
12
|
+
|
|
13
|
+
export class MediaVolumeSlider extends HTMLElementBase {
|
|
14
|
+
private _cleanups: Array<() => void> = []
|
|
15
|
+
private _isDragging = false
|
|
16
|
+
|
|
17
|
+
connectedCallback(): void {
|
|
18
|
+
if (!this.shadowRoot) {
|
|
19
|
+
const shadow = this.attachShadow({ mode: 'open' })
|
|
20
|
+
shadow.innerHTML = `
|
|
21
|
+
<style>
|
|
22
|
+
:host { display: inline-flex; align-items: center; width: 80px; }
|
|
23
|
+
:host([hidden]) { display: none; }
|
|
24
|
+
.container {
|
|
25
|
+
position: relative; width: 100%; height: 20px;
|
|
26
|
+
cursor: pointer; touch-action: none;
|
|
27
|
+
}
|
|
28
|
+
.track {
|
|
29
|
+
position: absolute; top: 50%; left: 0; right: 0;
|
|
30
|
+
height: 4px; background: rgba(255,255,255,0.3);
|
|
31
|
+
border-radius: 2px; transform: translateY(-50%);
|
|
32
|
+
}
|
|
33
|
+
.fill {
|
|
34
|
+
position: absolute; top: 0; left: 0;
|
|
35
|
+
height: 100%; background: #fff;
|
|
36
|
+
border-radius: 2px; width: 100%;
|
|
37
|
+
}
|
|
38
|
+
.thumb {
|
|
39
|
+
position: absolute; top: 50%; left: 100%;
|
|
40
|
+
width: 12px; height: 12px; background: #fff;
|
|
41
|
+
border-radius: 50%; transform: translate(-50%, -50%);
|
|
42
|
+
transition: transform 0.1s;
|
|
43
|
+
}
|
|
44
|
+
.container:hover .thumb { transform: translate(-50%, -50%) scale(1.2); }
|
|
45
|
+
</style>
|
|
46
|
+
<div class="container" part="container" role="slider"
|
|
47
|
+
aria-valuemin="0" aria-valuemax="100" aria-valuenow="100"
|
|
48
|
+
aria-label="Volume" tabindex="0">
|
|
49
|
+
<div class="track" part="track">
|
|
50
|
+
<div class="fill" part="fill"></div>
|
|
51
|
+
<div class="thumb" part="thumb"></div>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
queueMicrotask(() => this.attach())
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
disconnectedCallback(): void {
|
|
61
|
+
this._cleanups.forEach((fn) => fn())
|
|
62
|
+
this._cleanups = []
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private attach(): void {
|
|
66
|
+
const player = resolvePlayer(this)
|
|
67
|
+
if (!player) return
|
|
68
|
+
|
|
69
|
+
const container = this.shadowRoot!.querySelector('.container') as HTMLElement
|
|
70
|
+
const track = this.shadowRoot!.querySelector('.track') as HTMLElement
|
|
71
|
+
const fill = this.shadowRoot!.querySelector('.fill') as HTMLElement
|
|
72
|
+
const thumb = this.shadowRoot!.querySelector('.thumb') as HTMLElement
|
|
73
|
+
|
|
74
|
+
const setFromEvent = (e: PointerEvent) => {
|
|
75
|
+
const rect = track.getBoundingClientRect()
|
|
76
|
+
const volume = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
|
77
|
+
player.setVolume(volume)
|
|
78
|
+
if (volume > 0 && player.state.muted) player.setMuted(false)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
82
|
+
this._isDragging = true
|
|
83
|
+
container.setPointerCapture(e.pointerId)
|
|
84
|
+
setFromEvent(e)
|
|
85
|
+
}
|
|
86
|
+
const onPointerMove = (e: PointerEvent) => {
|
|
87
|
+
if (!this._isDragging) return
|
|
88
|
+
setFromEvent(e)
|
|
89
|
+
}
|
|
90
|
+
const onPointerUp = (e: PointerEvent) => {
|
|
91
|
+
if (!this._isDragging) return
|
|
92
|
+
this._isDragging = false
|
|
93
|
+
container.releasePointerCapture(e.pointerId)
|
|
94
|
+
}
|
|
95
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
96
|
+
let volume = player.state.volume
|
|
97
|
+
const step = 0.05
|
|
98
|
+
|
|
99
|
+
switch (e.key) {
|
|
100
|
+
case 'ArrowRight':
|
|
101
|
+
case 'ArrowUp': volume = Math.min(1, volume + step); break
|
|
102
|
+
case 'ArrowLeft':
|
|
103
|
+
case 'ArrowDown': volume = Math.max(0, volume - step); break
|
|
104
|
+
default: return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
e.preventDefault()
|
|
108
|
+
player.setVolume(volume)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
container.addEventListener('pointerdown', onPointerDown)
|
|
112
|
+
container.addEventListener('pointermove', onPointerMove)
|
|
113
|
+
container.addEventListener('pointerup', onPointerUp)
|
|
114
|
+
container.addEventListener('keydown', onKeyDown)
|
|
115
|
+
|
|
116
|
+
this._cleanups.push(
|
|
117
|
+
() => container.removeEventListener('pointerdown', onPointerDown),
|
|
118
|
+
() => container.removeEventListener('pointermove', onPointerMove),
|
|
119
|
+
() => container.removeEventListener('pointerup', onPointerUp),
|
|
120
|
+
() => container.removeEventListener('keydown', onKeyDown),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const unsub = player.subscribe((state: any) => {
|
|
124
|
+
if (state.volumeAvailability === 'unsupported') {
|
|
125
|
+
this.setAttribute('hidden', '')
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
this.removeAttribute('hidden')
|
|
129
|
+
|
|
130
|
+
const vol = state.muted ? 0 : state.volume
|
|
131
|
+
const percent = vol * 100
|
|
132
|
+
|
|
133
|
+
fill.style.width = `${percent}%`
|
|
134
|
+
thumb.style.left = `${percent}%`
|
|
135
|
+
container.setAttribute('aria-valuenow', String(Math.round(percent)))
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
this._cleanups.push(unsub)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ts-video-player Element Utilities
|
|
3
|
+
*
|
|
4
|
+
* Shared helpers for custom elements.
|
|
5
|
+
*
|
|
6
|
+
* @module elements/utils
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Format time in seconds to MM:SS or HH:MM:SS
|
|
11
|
+
*/
|
|
12
|
+
export function formatTime(seconds: number, guide = 0): string {
|
|
13
|
+
if (!isFinite(seconds) || isNaN(seconds)) return '0:00'
|
|
14
|
+
|
|
15
|
+
const s = Math.floor(seconds)
|
|
16
|
+
const h = Math.floor(s / 3600)
|
|
17
|
+
const m = Math.floor((s % 3600) / 60)
|
|
18
|
+
const sec = s % 60
|
|
19
|
+
|
|
20
|
+
const showHours = h > 0 || guide >= 3600
|
|
21
|
+
|
|
22
|
+
if (showHours) {
|
|
23
|
+
return `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return `${m}:${sec.toString().padStart(2, '0')}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Format time as a human-readable phrase for screen readers.
|
|
31
|
+
* e.g., "1 minute, 30 seconds"
|
|
32
|
+
*/
|
|
33
|
+
export function formatTimePhrase(seconds: number): string {
|
|
34
|
+
if (!isFinite(seconds) || isNaN(seconds)) return '0 seconds'
|
|
35
|
+
|
|
36
|
+
const s = Math.floor(seconds)
|
|
37
|
+
const h = Math.floor(s / 3600)
|
|
38
|
+
const m = Math.floor((s % 3600) / 60)
|
|
39
|
+
const sec = s % 60
|
|
40
|
+
|
|
41
|
+
const parts: string[] = []
|
|
42
|
+
if (h > 0) parts.push(`${h} ${h === 1 ? 'hour' : 'hours'}`)
|
|
43
|
+
if (m > 0) parts.push(`${m} ${m === 1 ? 'minute' : 'minutes'}`)
|
|
44
|
+
if (sec > 0 || parts.length === 0) parts.push(`${sec} ${sec === 1 ? 'second' : 'seconds'}`)
|
|
45
|
+
|
|
46
|
+
return parts.join(', ')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Convert seconds to ISO 8601 duration string.
|
|
51
|
+
* e.g., 90 -> "PT1M30S"
|
|
52
|
+
*/
|
|
53
|
+
export function toISODuration(seconds: number): string {
|
|
54
|
+
if (!isFinite(seconds) || isNaN(seconds)) return 'PT0S'
|
|
55
|
+
|
|
56
|
+
const s = Math.floor(seconds)
|
|
57
|
+
const h = Math.floor(s / 3600)
|
|
58
|
+
const m = Math.floor((s % 3600) / 60)
|
|
59
|
+
const sec = s % 60
|
|
60
|
+
|
|
61
|
+
let result = 'PT'
|
|
62
|
+
if (h > 0) result += `${h}H`
|
|
63
|
+
if (m > 0) result += `${m}M`
|
|
64
|
+
if (sec > 0 || result === 'PT') result += `${sec}S`
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the player instance from an element.
|
|
71
|
+
* Uses `for` attribute to find by ID, or walks up the DOM to find `<video-player>`.
|
|
72
|
+
*/
|
|
73
|
+
export function resolvePlayer(el: HTMLElement): any | null {
|
|
74
|
+
const forId = el.getAttribute('for')
|
|
75
|
+
if (forId) {
|
|
76
|
+
const target = document.getElementById(forId)
|
|
77
|
+
return (target as any)?.player ?? null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const host = el.closest('video-player')
|
|
81
|
+
return (host as any)?.player ?? null
|
|
82
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ts-video-player <video-player> Custom Element
|
|
3
|
+
*
|
|
4
|
+
* The root element that wraps the Player class.
|
|
5
|
+
*
|
|
6
|
+
* @module elements/video-player
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Player } from '../player'
|
|
10
|
+
import type { DRMConfig, PlayerOptions, PlayerEventMap, Src, TextTrackSource } from '../types'
|
|
11
|
+
|
|
12
|
+
const OBSERVED_ATTRS = ['src', 'poster', 'autoplay', 'loop', 'muted', 'controls', 'volume', 'playback-rate', 'preload', 'playsinline', 'crossorigin', 'controlslist', 'disable-picture-in-picture'] as const
|
|
13
|
+
|
|
14
|
+
import { HTMLElementBase } from './base'
|
|
15
|
+
|
|
16
|
+
export class VideoPlayerElement extends HTMLElementBase {
|
|
17
|
+
private _player: Player | null = null
|
|
18
|
+
private _container: HTMLElement | null = null
|
|
19
|
+
private _disconnectId = 0
|
|
20
|
+
|
|
21
|
+
static get observedAttributes(): string[] {
|
|
22
|
+
return [...OBSERVED_ATTRS]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get player(): Player | null {
|
|
26
|
+
return this._player
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
connectedCallback(): void {
|
|
30
|
+
this._disconnectId++
|
|
31
|
+
if (this._player) return
|
|
32
|
+
|
|
33
|
+
// Create shadow DOM
|
|
34
|
+
const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open' })
|
|
35
|
+
|
|
36
|
+
// Container
|
|
37
|
+
this._container = document.createElement('div')
|
|
38
|
+
this._container.setAttribute('part', 'container')
|
|
39
|
+
this._container.style.cssText = 'position:relative;width:100%;height:100%;'
|
|
40
|
+
|
|
41
|
+
// Slot for child media elements (buttons, etc.)
|
|
42
|
+
const slot = document.createElement('slot')
|
|
43
|
+
this._container.appendChild(slot)
|
|
44
|
+
|
|
45
|
+
shadow.appendChild(this._container)
|
|
46
|
+
|
|
47
|
+
// Build options from attributes
|
|
48
|
+
const options = this.buildOptions()
|
|
49
|
+
|
|
50
|
+
// Create player
|
|
51
|
+
this._player = new Player(this._container, options)
|
|
52
|
+
|
|
53
|
+
// Forward player events as DOM events
|
|
54
|
+
this.forwardEvents()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
disconnectedCallback(): void {
|
|
58
|
+
if (this.hasAttribute('keep-alive')) return
|
|
59
|
+
const disconnectId = ++this._disconnectId
|
|
60
|
+
queueMicrotask(() => {
|
|
61
|
+
if (this.isConnected || disconnectId !== this._disconnectId) return
|
|
62
|
+
this._player?.destroy()
|
|
63
|
+
this._player = null
|
|
64
|
+
this._container = null
|
|
65
|
+
this.shadowRoot?.replaceChildren()
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
|
70
|
+
if (!this._player) return
|
|
71
|
+
|
|
72
|
+
switch (name) {
|
|
73
|
+
case 'src':
|
|
74
|
+
if (value) this._player.setSrc(value)
|
|
75
|
+
break
|
|
76
|
+
case 'muted':
|
|
77
|
+
this._player.setMuted(value !== null)
|
|
78
|
+
break
|
|
79
|
+
case 'volume':
|
|
80
|
+
if (value !== null) this._player.setVolume(parseFloat(value))
|
|
81
|
+
break
|
|
82
|
+
case 'playback-rate':
|
|
83
|
+
if (value !== null) this._player.setPlaybackRate(parseFloat(value))
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private buildOptions(): PlayerOptions {
|
|
89
|
+
const options: PlayerOptions = {}
|
|
90
|
+
|
|
91
|
+
const config = this.readSourceConfig()
|
|
92
|
+
if (config.sources?.length) options.src = config.sources
|
|
93
|
+
if (config.tracks?.length) options.tracks = config.tracks
|
|
94
|
+
|
|
95
|
+
const src = this.getAttribute('src')
|
|
96
|
+
if (src && !options.src) options.src = config.drm ? { src, type: 'application/dash+xml', drm: config.drm } : src
|
|
97
|
+
|
|
98
|
+
const poster = this.getAttribute('poster')
|
|
99
|
+
if (poster) options.poster = poster
|
|
100
|
+
|
|
101
|
+
const title = this.getAttribute('title')
|
|
102
|
+
if (title) options.title = title
|
|
103
|
+
|
|
104
|
+
if (this.hasAttribute('autoplay')) options.autoplay = true
|
|
105
|
+
if (this.hasAttribute('loop')) options.loop = true
|
|
106
|
+
if (this.hasAttribute('muted')) options.muted = true
|
|
107
|
+
|
|
108
|
+
const volume = this.getAttribute('volume')
|
|
109
|
+
if (volume) options.volume = parseFloat(volume)
|
|
110
|
+
|
|
111
|
+
const rate = this.getAttribute('playback-rate')
|
|
112
|
+
if (rate) options.playbackRate = parseFloat(rate)
|
|
113
|
+
|
|
114
|
+
const preload = this.getAttribute('preload')
|
|
115
|
+
if (preload) options.preload = preload as PlayerOptions['preload']
|
|
116
|
+
|
|
117
|
+
if (this.hasAttribute('controls')) options.controls = true
|
|
118
|
+
options.playsinline = this.getAttribute('playsinline') !== 'false'
|
|
119
|
+
|
|
120
|
+
const crossorigin = this.getAttribute('crossorigin')
|
|
121
|
+
if (crossorigin !== null) options.crossorigin = crossorigin as PlayerOptions['crossorigin']
|
|
122
|
+
|
|
123
|
+
const controlsList = this.getAttribute('controlslist')
|
|
124
|
+
if (controlsList) options.controlsList = controlsList.split(/\s+/) as PlayerOptions['controlsList']
|
|
125
|
+
options.disablePictureInPicture = this.hasAttribute('disable-picture-in-picture')
|
|
126
|
+
|
|
127
|
+
return options
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private readSourceConfig(): { sources?: Src[], drm?: DRMConfig, tracks?: TextTrackSource[] } {
|
|
131
|
+
const script = this.querySelector('script[type="application/json"][data-media-config]')
|
|
132
|
+
const value = this.getAttribute('data-media-config')?.trim() || script?.textContent?.trim()
|
|
133
|
+
if (!value) return {}
|
|
134
|
+
if (value.length > 65_536) throw new TypeError('Media player configuration is too large')
|
|
135
|
+
try {
|
|
136
|
+
const parsed = JSON.parse(value) as { sources?: unknown, drm?: unknown, tracks?: unknown }
|
|
137
|
+
if (parsed.sources !== undefined && (!Array.isArray(parsed.sources) || parsed.sources.some(source => !source || typeof source !== 'object' || typeof (source as { src?: unknown }).src !== 'string'))) {
|
|
138
|
+
throw new TypeError('Media player sources are invalid')
|
|
139
|
+
}
|
|
140
|
+
const trackKinds = new Set(['subtitles', 'captions', 'descriptions', 'chapters', 'metadata'])
|
|
141
|
+
if (parsed.tracks !== undefined && (!Array.isArray(parsed.tracks) || parsed.tracks.some(track => !track || typeof track !== 'object' || typeof (track as { src?: unknown }).src !== 'string' || !trackKinds.has(String((track as { kind?: unknown }).kind))))) {
|
|
142
|
+
throw new TypeError('Media player tracks are invalid')
|
|
143
|
+
}
|
|
144
|
+
return { sources: parsed.sources as Src[] | undefined, drm: parsed.drm as DRMConfig | undefined, tracks: parsed.tracks as TextTrackSource[] | undefined }
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error instanceof TypeError) throw error
|
|
148
|
+
throw new TypeError('Media player configuration is invalid JSON')
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private forwardEvents(): void {
|
|
153
|
+
if (!this._player) return
|
|
154
|
+
|
|
155
|
+
const events: (keyof PlayerEventMap)[] = [
|
|
156
|
+
'play', 'pause', 'playing', 'waiting', 'ended',
|
|
157
|
+
'timeupdate', 'durationchange', 'volumechange',
|
|
158
|
+
'fullscreenchange', 'pipchange', 'error',
|
|
159
|
+
'loadstart', 'loadeddata', 'loadedmetadata',
|
|
160
|
+
'canplay', 'canplaythrough', 'seeking', 'seeked',
|
|
161
|
+
'ratechange', 'qualitychange', 'statechange',
|
|
162
|
+
'availabilitychange',
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
for (const event of events) {
|
|
166
|
+
this._player.on(event, ((...args: unknown[]) => {
|
|
167
|
+
this.dispatchEvent(new CustomEvent(event, {
|
|
168
|
+
detail: args.length === 1 ? args[0] : args,
|
|
169
|
+
bubbles: true,
|
|
170
|
+
composed: true,
|
|
171
|
+
}))
|
|
172
|
+
}) as any)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ts-video-player <video-skin> Custom Element
|
|
3
|
+
*
|
|
4
|
+
* Adaptive skin wrapper that provides a default layout for player controls.
|
|
5
|
+
* Adjusts visibility of controls based on available features.
|
|
6
|
+
*
|
|
7
|
+
* Follows the v10 layered model:
|
|
8
|
+
* <video-player> ← Behavior
|
|
9
|
+
* <video-skin> ← Appearance (this element)
|
|
10
|
+
* <media-*> ← UI controls
|
|
11
|
+
* </video-skin>
|
|
12
|
+
* </video-player>
|
|
13
|
+
*
|
|
14
|
+
* @module elements/video-skin
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { resolvePlayer } from './utils'
|
|
18
|
+
|
|
19
|
+
import { HTMLElementBase } from './base'
|
|
20
|
+
|
|
21
|
+
export class VideoSkin extends HTMLElementBase {
|
|
22
|
+
private _cleanups: Array<() => void> = []
|
|
23
|
+
|
|
24
|
+
connectedCallback(): void {
|
|
25
|
+
if (!this.shadowRoot) {
|
|
26
|
+
const shadow = this.attachShadow({ mode: 'open' })
|
|
27
|
+
shadow.innerHTML = `
|
|
28
|
+
<style>
|
|
29
|
+
:host {
|
|
30
|
+
display: flex;
|
|
31
|
+
flex-direction: column;
|
|
32
|
+
position: absolute;
|
|
33
|
+
inset: 0;
|
|
34
|
+
z-index: 1;
|
|
35
|
+
pointer-events: none;
|
|
36
|
+
color: white;
|
|
37
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
38
|
+
font-size: 14px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Click-to-play overlay */
|
|
42
|
+
.overlay {
|
|
43
|
+
flex: 1;
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
justify-content: center;
|
|
47
|
+
pointer-events: auto;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Bottom controls bar */
|
|
51
|
+
.controls {
|
|
52
|
+
display: flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
gap: 4px;
|
|
55
|
+
padding: 8px 12px;
|
|
56
|
+
background: linear-gradient(transparent, rgba(0,0,0,0.7));
|
|
57
|
+
pointer-events: auto;
|
|
58
|
+
transition: opacity 0.3s, visibility 0.3s;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
:host([controls-hidden]) .controls {
|
|
62
|
+
opacity: 0;
|
|
63
|
+
visibility: hidden;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* Spacer pushes right-aligned controls */
|
|
67
|
+
.spacer {
|
|
68
|
+
flex: 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* Slots for left/center/right control groups */
|
|
72
|
+
::slotted(*) {
|
|
73
|
+
pointer-events: auto;
|
|
74
|
+
}
|
|
75
|
+
</style>
|
|
76
|
+
<div class="overlay" part="overlay">
|
|
77
|
+
<slot name="overlay"></slot>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="controls" part="controls">
|
|
80
|
+
<slot name="left"></slot>
|
|
81
|
+
<slot name="center"></slot>
|
|
82
|
+
<div class="spacer"></div>
|
|
83
|
+
<slot name="right"></slot>
|
|
84
|
+
<slot></slot>
|
|
85
|
+
</div>
|
|
86
|
+
`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
queueMicrotask(() => this.attach())
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
disconnectedCallback(): void {
|
|
93
|
+
this._cleanups.forEach((fn) => fn())
|
|
94
|
+
this._cleanups = []
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private attach(): void {
|
|
98
|
+
const player = resolvePlayer(this)
|
|
99
|
+
if (!player) return
|
|
100
|
+
|
|
101
|
+
// Sync controls visibility
|
|
102
|
+
const unsub = player.subscribe((state: any) => {
|
|
103
|
+
if (state.controlsVisible) {
|
|
104
|
+
this.removeAttribute('controls-hidden')
|
|
105
|
+
} else {
|
|
106
|
+
this.setAttribute('controls-hidden', '')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Propagate feature availability as data attributes for CSS adaptation
|
|
110
|
+
this.dataset.fullscreen = state.fullscreenAvailability
|
|
111
|
+
this.dataset.pip = state.pipAvailability
|
|
112
|
+
this.dataset.volume = state.volumeAvailability
|
|
113
|
+
this.dataset.captions = String(state.textTracks.length > 0)
|
|
114
|
+
this.dataset.qualities = String(state.qualities.length > 0)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Click overlay to toggle play
|
|
118
|
+
const overlay = this.shadowRoot!.querySelector('.overlay')!
|
|
119
|
+
const onOverlayClick = () => player.togglePlay()
|
|
120
|
+
overlay.addEventListener('click', onOverlayClick)
|
|
121
|
+
|
|
122
|
+
this._cleanups.push(
|
|
123
|
+
unsub,
|
|
124
|
+
() => overlay.removeEventListener('click', onOverlayClick),
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Captions feature — text track management.
|
|
3
|
+
*
|
|
4
|
+
* @module features/captions
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Feature, FeatureContext } from './types'
|
|
8
|
+
import type { TextTrack } from '../types'
|
|
9
|
+
|
|
10
|
+
export const captions: Feature = {
|
|
11
|
+
name: 'captions',
|
|
12
|
+
stateKeys: ['textTracks'],
|
|
13
|
+
|
|
14
|
+
setup(ctx: FeatureContext) {
|
|
15
|
+
const { store, getMediaElement } = ctx
|
|
16
|
+
|
|
17
|
+
const syncTracks = () => {
|
|
18
|
+
const media = getMediaElement()
|
|
19
|
+
if (!media) return
|
|
20
|
+
|
|
21
|
+
const tracks: TextTrack[] = []
|
|
22
|
+
const textTracks = media.textTracks
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < textTracks.length; i++) {
|
|
25
|
+
const track = textTracks[i]
|
|
26
|
+
tracks.push({
|
|
27
|
+
id: String(i),
|
|
28
|
+
kind: track.kind as TextTrack['kind'],
|
|
29
|
+
label: track.label || `Track ${i + 1}`,
|
|
30
|
+
language: track.language || '',
|
|
31
|
+
mode: track.mode as TextTrack['mode'],
|
|
32
|
+
cues: [],
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
store.set('textTracks', tracks)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const media = getMediaElement()
|
|
40
|
+
if (media) {
|
|
41
|
+
media.textTracks.addEventListener('change', syncTracks)
|
|
42
|
+
media.textTracks.addEventListener('addtrack', syncTracks)
|
|
43
|
+
media.textTracks.addEventListener('removetrack', syncTracks)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
syncTracks()
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
const m = getMediaElement()
|
|
50
|
+
if (!m) return
|
|
51
|
+
m.textTracks.removeEventListener('change', syncTracks)
|
|
52
|
+
m.textTracks.removeEventListener('addtrack', syncTracks)
|
|
53
|
+
m.textTracks.removeEventListener('removetrack', syncTracks)
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable Player Factory
|
|
3
|
+
*
|
|
4
|
+
* Creates a lightweight player with only the features you need.
|
|
5
|
+
*
|
|
6
|
+
* @module features/create-player
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Feature, FeatureContext } from './types'
|
|
10
|
+
import type { PlayerState } from '../types'
|
|
11
|
+
import { StateStore } from '../core/state'
|
|
12
|
+
|
|
13
|
+
export interface ComposablePlayer {
|
|
14
|
+
/** The container element */
|
|
15
|
+
el: HTMLElement
|
|
16
|
+
/** Current state */
|
|
17
|
+
readonly state: PlayerState
|
|
18
|
+
/** Subscribe to state changes */
|
|
19
|
+
subscribe: StateStore['subscribe']
|
|
20
|
+
/** Get the media element */
|
|
21
|
+
getMediaElement: () => HTMLMediaElement | null
|
|
22
|
+
/** Destroy the player and all features */
|
|
23
|
+
destroy: () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ComposablePlayerOptions {
|
|
27
|
+
/** Features to enable */
|
|
28
|
+
features: Feature[]
|
|
29
|
+
/** Initial state overrides */
|
|
30
|
+
state?: Partial<PlayerState>
|
|
31
|
+
/** Player options */
|
|
32
|
+
options?: Record<string, unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a composable player with only the specified features.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* import { createComposablePlayer } from 'ts-video-player/features'
|
|
41
|
+
* import { playback, volume, fullscreen } from 'ts-video-player/features'
|
|
42
|
+
*
|
|
43
|
+
* const player = createComposablePlayer(container, {
|
|
44
|
+
* features: [playback, volume, fullscreen],
|
|
45
|
+
* })
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function createComposablePlayer(
|
|
49
|
+
container: HTMLElement,
|
|
50
|
+
opts: ComposablePlayerOptions,
|
|
51
|
+
): ComposablePlayer {
|
|
52
|
+
const store = new StateStore(opts.state)
|
|
53
|
+
const cleanups: Array<() => void> = []
|
|
54
|
+
|
|
55
|
+
let mediaElement: HTMLMediaElement | null = null
|
|
56
|
+
|
|
57
|
+
const getMediaElement = (): HTMLMediaElement | null => {
|
|
58
|
+
if (!mediaElement) {
|
|
59
|
+
mediaElement = container.querySelector('video, audio')
|
|
60
|
+
}
|
|
61
|
+
return mediaElement
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ctx: FeatureContext = {
|
|
65
|
+
container,
|
|
66
|
+
store,
|
|
67
|
+
options: opts.options || {},
|
|
68
|
+
getMediaElement,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Setup all features
|
|
72
|
+
for (const feature of opts.features) {
|
|
73
|
+
const cleanup = feature.setup(ctx)
|
|
74
|
+
if (cleanup) cleanups.push(cleanup)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
el: container,
|
|
79
|
+
get state() {
|
|
80
|
+
return store.getState()
|
|
81
|
+
},
|
|
82
|
+
subscribe: store.subscribe.bind(store),
|
|
83
|
+
getMediaElement,
|
|
84
|
+
destroy() {
|
|
85
|
+
for (const cleanup of cleanups) cleanup()
|
|
86
|
+
cleanups.length = 0
|
|
87
|
+
store.reset()
|
|
88
|
+
mediaElement = null
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|