ts-video-player 0.1.6 → 0.1.8
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/dist/cdn.min.js +70 -50
- package/dist/{chunk-fvz33zn4.js → chunk-hvjcmbfn.js} +765 -114
- package/dist/core/i18n.d.ts +24 -0
- package/dist/elements/index.js +2 -2
- package/dist/elements/utils.d.ts +16 -2
- package/dist/index.js +28 -419
- package/dist/player.d.ts +12 -0
- package/dist/providers/vimeo.d.ts +6 -0
- package/dist/providers/youtube.d.ts +6 -0
- package/package.json +1 -1
- package/src/core/i18n.ts +173 -1
- package/src/elements/media-airplay-button.ts +2 -2
- package/src/elements/media-fullscreen-button.ts +3 -3
- package/src/elements/media-live-button.ts +2 -2
- package/src/elements/media-mute-button.ts +3 -3
- package/src/elements/media-pip-button.ts +3 -2
- package/src/elements/media-play-button.ts +3 -3
- package/src/elements/media-playback-rate-button.ts +3 -3
- package/src/elements/media-progress-bar.ts +3 -3
- package/src/elements/media-remote-playback-button.ts +2 -2
- package/src/elements/media-seek-button.ts +2 -3
- package/src/elements/media-settings-menu.ts +14 -14
- package/src/elements/media-time-display.ts +2 -2
- package/src/elements/media-volume-slider.ts +2 -2
- package/src/elements/utils.ts +40 -6
- package/src/elements/video-skin.ts +21 -1
- package/src/player.ts +76 -18
- package/src/providers/vimeo.ts +15 -3
- package/src/providers/youtube.ts +27 -5
package/src/elements/utils.ts
CHANGED
|
@@ -6,6 +6,39 @@
|
|
|
6
6
|
* @module elements/utils
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { TranslationKey } from '../core/i18n'
|
|
10
|
+
import { englishStrings, getI18n } from '../core/i18n'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The language a control speaks: the nearest `lang` attribute above it,
|
|
14
|
+
* through shadow roots (`<html lang="de">`, or `lang` on the `<video-player>`
|
|
15
|
+
* itself), then the browser's. A German page gets German button names and
|
|
16
|
+
* screen-reader announcements without configuring the player.
|
|
17
|
+
*/
|
|
18
|
+
export function languageOf(el: Node | null | undefined): string {
|
|
19
|
+
let node: Node | null | undefined = el
|
|
20
|
+
while (node) {
|
|
21
|
+
const tagged = (node as Element).closest?.('[lang]')?.getAttribute('lang')
|
|
22
|
+
if (tagged) return tagged.toLowerCase()
|
|
23
|
+
const root = node.getRootNode?.()
|
|
24
|
+
node = root && (root as ShadowRoot).host ? (root as ShadowRoot).host : null
|
|
25
|
+
}
|
|
26
|
+
if (typeof navigator !== 'undefined' && navigator.language) return navigator.language.toLowerCase()
|
|
27
|
+
return 'en'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A control's words in its language (languageOf): the built-in translations
|
|
32
|
+
* plus any an app added with getI18n().addTranslations(), English for a
|
|
33
|
+
* language or key that has none.
|
|
34
|
+
*/
|
|
35
|
+
export function label(el: Node | null | undefined, key: TranslationKey, params?: Record<string, string | number>): string {
|
|
36
|
+
const lang = languageOf(el)
|
|
37
|
+
const i18n = getI18n()
|
|
38
|
+
const text = i18n.getTranslations(lang)?.[key] || i18n.getTranslations(lang.split('-')[0]!)?.[key] || englishStrings[key] || key
|
|
39
|
+
return params ? text.replace(/\{(\w+)\}/g, (match, name) => (params[name] !== undefined ? String(params[name]) : match)) : text
|
|
40
|
+
}
|
|
41
|
+
|
|
9
42
|
/**
|
|
10
43
|
* Format time in seconds to MM:SS or HH:MM:SS
|
|
11
44
|
*/
|
|
@@ -28,10 +61,11 @@ export function formatTime(seconds: number, guide = 0): string {
|
|
|
28
61
|
|
|
29
62
|
/**
|
|
30
63
|
* Format time as a human-readable phrase for screen readers.
|
|
31
|
-
* e.g., "1 minute, 30 seconds"
|
|
64
|
+
* e.g., "1 minute, 30 seconds", in the language of `el` when one is given.
|
|
32
65
|
*/
|
|
33
|
-
export function formatTimePhrase(seconds: number): string {
|
|
34
|
-
|
|
66
|
+
export function formatTimePhrase(seconds: number, el?: Node | null): string {
|
|
67
|
+
const say = (n: number, one: TranslationKey, other: TranslationKey): string => label(el, n === 1 ? one : other, { n })
|
|
68
|
+
if (!isFinite(seconds) || isNaN(seconds)) return say(0, 'secondOne', 'secondOther')
|
|
35
69
|
|
|
36
70
|
const s = Math.floor(seconds)
|
|
37
71
|
const h = Math.floor(s / 3600)
|
|
@@ -39,9 +73,9 @@ export function formatTimePhrase(seconds: number): string {
|
|
|
39
73
|
const sec = s % 60
|
|
40
74
|
|
|
41
75
|
const parts: string[] = []
|
|
42
|
-
if (h > 0) parts.push(
|
|
43
|
-
if (m > 0) parts.push(
|
|
44
|
-
if (sec > 0 || parts.length === 0) parts.push(
|
|
76
|
+
if (h > 0) parts.push(say(h, 'hourOne', 'hourOther'))
|
|
77
|
+
if (m > 0) parts.push(say(m, 'minuteOne', 'minuteOther'))
|
|
78
|
+
if (sec > 0 || parts.length === 0) parts.push(say(sec, 'secondOne', 'secondOther'))
|
|
45
79
|
|
|
46
80
|
return parts.join(', ')
|
|
47
81
|
}
|
|
@@ -36,6 +36,7 @@ export class VideoSkin extends HTMLElementBase {
|
|
|
36
36
|
color: white;
|
|
37
37
|
font-family: system-ui, -apple-system, sans-serif;
|
|
38
38
|
font-size: 14px;
|
|
39
|
+
container-type: inline-size;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/* Click-to-play overlay */
|
|
@@ -63,6 +64,25 @@ export class VideoSkin extends HTMLElementBase {
|
|
|
63
64
|
visibility: hidden;
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/* The time and the progress bar. One row with the buttons on a
|
|
68
|
+
wide player; a phone-width one gives the progress bar a row of
|
|
69
|
+
its own, where it would otherwise be squeezed to a dot between
|
|
70
|
+
the buttons. */
|
|
71
|
+
.center { display: contents; }
|
|
72
|
+
@container (max-width: 520px) {
|
|
73
|
+
.controls { flex-wrap: wrap; row-gap: 0; padding: 4px 10px 6px; }
|
|
74
|
+
.center { display: flex; order: -1; flex: 1 0 100%; align-items: center; gap: 10px; min-width: 0; font-size: 12px; }
|
|
75
|
+
/* Speed is in the settings menu too; one button fewer keeps the
|
|
76
|
+
rest on one row. */
|
|
77
|
+
::slotted(media-playback-rate-button) { display: none; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* A touch screen sets the volume with its own buttons, and iOS
|
|
81
|
+
ignores a page's volume altogether: no slider there. */
|
|
82
|
+
@media (pointer: coarse) {
|
|
83
|
+
::slotted(media-volume-slider) { display: none; }
|
|
84
|
+
}
|
|
85
|
+
|
|
66
86
|
/* Spacer pushes right-aligned controls */
|
|
67
87
|
.spacer {
|
|
68
88
|
flex: 1;
|
|
@@ -78,7 +98,7 @@ export class VideoSkin extends HTMLElementBase {
|
|
|
78
98
|
</div>
|
|
79
99
|
<div class="controls" part="controls">
|
|
80
100
|
<slot name="left"></slot>
|
|
81
|
-
<slot name="center"></slot>
|
|
101
|
+
<div class="center" part="center"><slot name="center"></slot></div>
|
|
82
102
|
<div class="spacer"></div>
|
|
83
103
|
<slot name="right"></slot>
|
|
84
104
|
<slot></slot>
|
package/src/player.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { createMediaSession, type MediaSessionOptions } from './core/media-sessi
|
|
|
22
22
|
import { lockOrientation, unlockOrientation } from './core/orientation'
|
|
23
23
|
import { defaultLoaders, findSourceCandidates } from './providers'
|
|
24
24
|
import { embedCover } from './core/cover'
|
|
25
|
+
import { label } from './elements/utils'
|
|
25
26
|
|
|
26
27
|
// =============================================================================
|
|
27
28
|
// Player Class
|
|
@@ -44,6 +45,12 @@ export class Player implements IPlayer {
|
|
|
44
45
|
private _cover: HTMLButtonElement | null = null
|
|
45
46
|
private _coverDismissed = false
|
|
46
47
|
private _coverTimer: ReturnType<typeof setTimeout> | null = null
|
|
48
|
+
/**
|
|
49
|
+
* A play asked for before there was a provider to ask: a tap on the cover
|
|
50
|
+
* while the source was still loading. Kept and honoured once the source has
|
|
51
|
+
* loaded, so one tap plays the video rather than one tap per loading step.
|
|
52
|
+
*/
|
|
53
|
+
private _playRequested = false
|
|
47
54
|
|
|
48
55
|
/**
|
|
49
56
|
* Create a new player instance
|
|
@@ -105,8 +112,8 @@ export class Player implements IPlayer {
|
|
|
105
112
|
this._el.classList.add('ts-video-player')
|
|
106
113
|
this._el.setAttribute('tabindex', '0')
|
|
107
114
|
this._el.setAttribute('role', 'region')
|
|
108
|
-
this._el.setAttribute('aria-roledescription',
|
|
109
|
-
this._el.setAttribute('aria-label', this._options.title ||
|
|
115
|
+
this._el.setAttribute('aria-roledescription', label(this._el, 'videoRole'))
|
|
116
|
+
this._el.setAttribute('aria-label', this._options.title || label(this._el, 'videoPlayer'))
|
|
110
117
|
this._el.setAttribute('aria-keyshortcuts', 'Space K M F I C ArrowLeft ArrowRight J L ArrowUp ArrowDown Home End 0 1 2 3 4 5 6 7 8 9')
|
|
111
118
|
|
|
112
119
|
// Create inner structure
|
|
@@ -152,27 +159,46 @@ export class Player implements IPlayer {
|
|
|
152
159
|
const cover = document.createElement('button')
|
|
153
160
|
cover.type = 'button'
|
|
154
161
|
cover.className = 'ts-video-player__cover'
|
|
155
|
-
cover.setAttribute('aria-label',
|
|
162
|
+
cover.setAttribute('aria-label', this._options.title ? label(this._el, 'playTitle', { title: this._options.title }) : label(this._el, 'play'))
|
|
156
163
|
cover.hidden = true
|
|
157
164
|
cover.style.cssText = 'position:absolute;inset:0;z-index:2;display:none;align-items:center;justify-content:center;width:100%;height:100%;margin:0;padding:0;border:0;border-radius:inherit;background:#000 center/cover no-repeat;cursor:pointer;-webkit-tap-highlight-color:transparent'
|
|
158
165
|
const button = document.createElement('span')
|
|
159
166
|
button.setAttribute('aria-hidden', 'true')
|
|
160
167
|
button.style.cssText = 'display:flex;align-items:center;justify-content:center;width:64px;height:64px;border-radius:50%;background:rgba(255,255,255,0.92);box-shadow:0 4px 18px rgba(0,0,0,0.35)'
|
|
161
168
|
const triangle = document.createElement('span')
|
|
169
|
+
triangle.className = 'ts-video-player__cover-play'
|
|
162
170
|
triangle.style.cssText = 'width:22px;height:24px;margin-left:5px;background:#0f172a;clip-path:polygon(0 0,100% 50%,0 100%)'
|
|
163
171
|
button.appendChild(triangle)
|
|
164
172
|
cover.appendChild(button)
|
|
165
173
|
cover.addEventListener('click', (event) => {
|
|
166
174
|
event.stopPropagation()
|
|
175
|
+
// The embed can take a few seconds to load on a phone: the tap is
|
|
176
|
+
// answered at once, with the play button turning into a spinner.
|
|
177
|
+
if (!cover.dataset.loading) {
|
|
178
|
+
cover.dataset.loading = ''
|
|
179
|
+
triangle.style.display = 'none'
|
|
180
|
+
const ring = document.createElement('span')
|
|
181
|
+
ring.className = 'ts-video-player__cover-spinner'
|
|
182
|
+
ring.style.cssText = 'width:26px;height:26px;border-radius:50%;border:3px solid rgba(15,23,42,0.2);border-top-color:#0f172a'
|
|
183
|
+
button.appendChild(ring)
|
|
184
|
+
ring.animate?.([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { duration: 800, iterations: Infinity })
|
|
185
|
+
}
|
|
167
186
|
void this.play().catch(() => {})
|
|
168
187
|
// Some browsers only let a tap inside the embed start it. If this one
|
|
169
|
-
// did not, get out of the way so the embed's own button can.
|
|
188
|
+
// did not, get out of the way so the embed's own button can. Only once
|
|
189
|
+
// the embed is ready, though: before then nothing could have started,
|
|
190
|
+
// and lifting the cover early showed the embed half-loaded.
|
|
170
191
|
if (this._coverTimer) clearTimeout(this._coverTimer)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
this.
|
|
174
|
-
|
|
175
|
-
|
|
192
|
+
let waits = 0
|
|
193
|
+
const settle = () => {
|
|
194
|
+
this._coverTimer = setTimeout(() => {
|
|
195
|
+
if (this.state.started) return
|
|
196
|
+
if (!this.state.canPlay && ++waits < 10) return settle()
|
|
197
|
+
this._coverDismissed = true
|
|
198
|
+
this.updateCover(this.state)
|
|
199
|
+
}, 2000)
|
|
200
|
+
}
|
|
201
|
+
settle()
|
|
176
202
|
})
|
|
177
203
|
this._el.appendChild(cover)
|
|
178
204
|
this._cover = cover
|
|
@@ -190,6 +216,13 @@ export class Player implements IPlayer {
|
|
|
190
216
|
const background = image ? `url("${image.replace(/["\\]/g, '')}")` : 'none'
|
|
191
217
|
if (cover.style.backgroundImage !== background) cover.style.backgroundImage = background
|
|
192
218
|
}
|
|
219
|
+
// Back to a play button once it is no longer waiting on a tap's play.
|
|
220
|
+
if (!show && cover.dataset.loading !== undefined) {
|
|
221
|
+
delete cover.dataset.loading
|
|
222
|
+
cover.querySelector('.ts-video-player__cover-spinner')?.remove()
|
|
223
|
+
const triangle = cover.querySelector<HTMLElement>('.ts-video-player__cover-play')
|
|
224
|
+
if (triangle) triangle.style.display = ''
|
|
225
|
+
}
|
|
193
226
|
// `hidden` alone would not do it: the inline display wins over it.
|
|
194
227
|
cover.hidden = !show
|
|
195
228
|
cover.style.display = show ? 'flex' : 'none'
|
|
@@ -431,7 +464,11 @@ export class Player implements IPlayer {
|
|
|
431
464
|
}
|
|
432
465
|
|
|
433
466
|
// Update state. A new source has not played yet, so its cover returns.
|
|
467
|
+
// With autoplay it starts once loaded: a player that follows a chosen
|
|
468
|
+
// video (`src` set again on <video-player>) plays each one it is given,
|
|
469
|
+
// not only the first.
|
|
434
470
|
this._coverDismissed = false
|
|
471
|
+
this._playRequested = !!this._options.autoplay
|
|
435
472
|
this._store.batch({
|
|
436
473
|
sources,
|
|
437
474
|
src: sources[0],
|
|
@@ -498,6 +535,10 @@ export class Player implements IPlayer {
|
|
|
498
535
|
mediaType: loader.mediaType(candidate.src),
|
|
499
536
|
providerType: loader.type,
|
|
500
537
|
})
|
|
538
|
+
if (this._playRequested) {
|
|
539
|
+
this._playRequested = false
|
|
540
|
+
void this.startRequestedPlay(this._provider)
|
|
541
|
+
}
|
|
501
542
|
return
|
|
502
543
|
}
|
|
503
544
|
catch (error) {
|
|
@@ -554,13 +595,13 @@ export class Player implements IPlayer {
|
|
|
554
595
|
provider.on('play', () => {
|
|
555
596
|
this._store.batch({ paused: false, ended: false })
|
|
556
597
|
this._events.emit('play')
|
|
557
|
-
this.announce('
|
|
598
|
+
this.announce(label(this._el, 'playing'))
|
|
558
599
|
})
|
|
559
600
|
|
|
560
601
|
provider.on('pause', () => {
|
|
561
602
|
this._store.set('paused', true)
|
|
562
603
|
this._events.emit('pause')
|
|
563
|
-
this.announce('
|
|
604
|
+
this.announce(label(this._el, 'paused'))
|
|
564
605
|
})
|
|
565
606
|
|
|
566
607
|
provider.on('playing', () => {
|
|
@@ -597,10 +638,10 @@ export class Player implements IPlayer {
|
|
|
597
638
|
this._store.batch({ volume, muted })
|
|
598
639
|
this._events.emit('volumechange', volume, muted)
|
|
599
640
|
if (muted) {
|
|
600
|
-
this.announce('
|
|
641
|
+
this.announce(label(this._el, 'muted'))
|
|
601
642
|
// eslint-disable-next-line style/brace-style
|
|
602
643
|
} else {
|
|
603
|
-
this.announce(
|
|
644
|
+
this.announce(label(this._el, 'volumeLevel', { percent: Math.round(volume * 100) }))
|
|
604
645
|
}
|
|
605
646
|
})
|
|
606
647
|
|
|
@@ -620,7 +661,7 @@ export class Player implements IPlayer {
|
|
|
620
661
|
provider.on('ended', () => {
|
|
621
662
|
this._store.batch({ ended: true, playing: false, playbackState: 'ended' })
|
|
622
663
|
this._events.emit('ended')
|
|
623
|
-
this.announce('
|
|
664
|
+
this.announce(label(this._el, 'ended'))
|
|
624
665
|
|
|
625
666
|
// Handle loop
|
|
626
667
|
if (this.state.loop) {
|
|
@@ -632,7 +673,7 @@ export class Player implements IPlayer {
|
|
|
632
673
|
provider.on('error', (error) => {
|
|
633
674
|
this._store.batch({ loadingState: 'error', error })
|
|
634
675
|
this._events.emit('error', error)
|
|
635
|
-
this.announce(
|
|
676
|
+
this.announce(label(this._el, 'errorMessage', { message: error.message }))
|
|
636
677
|
})
|
|
637
678
|
|
|
638
679
|
provider.on('statechange', (state) => {
|
|
@@ -648,7 +689,7 @@ export class Player implements IPlayer {
|
|
|
648
689
|
provider.on('fullscreenchange', (fullscreen) => {
|
|
649
690
|
this._store.set('fullscreen', fullscreen)
|
|
650
691
|
this._events.emit('fullscreenchange', fullscreen)
|
|
651
|
-
this.announce(fullscreen ? '
|
|
692
|
+
this.announce(label(this._el, fullscreen ? 'enteredFullscreen' : 'exitedFullscreen'))
|
|
652
693
|
})
|
|
653
694
|
|
|
654
695
|
provider.on('pipchange', (pip) => {
|
|
@@ -666,12 +707,29 @@ export class Player implements IPlayer {
|
|
|
666
707
|
|
|
667
708
|
// === Playback ===
|
|
668
709
|
|
|
669
|
-
|
|
670
|
-
|
|
710
|
+
/**
|
|
711
|
+
* Play what was asked for before the source was ready. A browser that
|
|
712
|
+
* refuses sound without a tap (code 1) gets it muted instead, as autoplay
|
|
713
|
+
* is everywhere, and the mute button turns it up.
|
|
714
|
+
*/
|
|
715
|
+
private async startRequestedPlay(provider: Provider): Promise<void> {
|
|
716
|
+
await provider.play().catch(() => {})
|
|
717
|
+
if (this._provider !== provider || this.state.error?.code !== 1 || this.state.muted) return
|
|
718
|
+
this._store.set('error', null)
|
|
719
|
+
this.setMuted(true)
|
|
720
|
+
await provider.play().catch(() => {})
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async play(): Promise<void> {
|
|
724
|
+
if (!this._provider) {
|
|
725
|
+
this._playRequested = true
|
|
726
|
+
return
|
|
727
|
+
}
|
|
671
728
|
await this._provider.play()
|
|
672
729
|
}
|
|
673
730
|
|
|
674
731
|
pause(): void {
|
|
732
|
+
this._playRequested = false
|
|
675
733
|
this._provider?.pause()
|
|
676
734
|
}
|
|
677
735
|
|
package/src/providers/vimeo.ts
CHANGED
|
@@ -114,6 +114,7 @@ interface VimeoPlayer {
|
|
|
114
114
|
pause(): Promise<void>
|
|
115
115
|
unload(): Promise<void>
|
|
116
116
|
destroy(): Promise<void>
|
|
117
|
+
loadVideo(id: number | string | { id: number, h?: string }): Promise<number>
|
|
117
118
|
getCurrentTime(): Promise<number>
|
|
118
119
|
setCurrentTime(seconds: number): Promise<number>
|
|
119
120
|
getDuration(): Promise<number>
|
|
@@ -240,10 +241,14 @@ export class VimeoProvider extends BaseProvider {
|
|
|
240
241
|
return
|
|
241
242
|
}
|
|
242
243
|
|
|
243
|
-
//
|
|
244
|
+
// A player already here loads the next video in place. Destroying it and
|
|
245
|
+
// making a new one on the same element never became ready again: the SDK
|
|
246
|
+
// keeps its player per element, so a second video (or the same one
|
|
247
|
+
// chosen again) stayed "loading" for good.
|
|
244
248
|
if (this.player) {
|
|
245
|
-
await this.player.
|
|
246
|
-
this.
|
|
249
|
+
await this.player.loadVideo(parseInt(parsed.videoId, 10))
|
|
250
|
+
await this.announceLoaded()
|
|
251
|
+
return
|
|
247
252
|
}
|
|
248
253
|
|
|
249
254
|
// Build player options
|
|
@@ -281,6 +286,13 @@ export class VimeoProvider extends BaseProvider {
|
|
|
281
286
|
// Setup event listeners
|
|
282
287
|
this.attachVimeoEvents()
|
|
283
288
|
|
|
289
|
+
await this.announceLoaded()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** What a loaded video tells the player: ready, its length, qualities and shape. */
|
|
293
|
+
private async announceLoaded(): Promise<void> {
|
|
294
|
+
if (!this.player) return
|
|
295
|
+
|
|
284
296
|
// Emit ready
|
|
285
297
|
this.events.emit('loadedmetadata')
|
|
286
298
|
this.events.emit('canplay')
|
package/src/providers/youtube.ts
CHANGED
|
@@ -192,6 +192,12 @@ export class YouTubeProvider extends BaseProvider {
|
|
|
192
192
|
private endTime?: number
|
|
193
193
|
private timeUpdateInterval: ReturnType<typeof setInterval> | null = null
|
|
194
194
|
private lastState = -1
|
|
195
|
+
/**
|
|
196
|
+
* A play asked for before YouTube's player could take it. Its methods only
|
|
197
|
+
* exist after its ready event, and the video may still be pending then, so
|
|
198
|
+
* the play waits for both and the video loads playing.
|
|
199
|
+
*/
|
|
200
|
+
private pendingPlay = false
|
|
195
201
|
|
|
196
202
|
get mediaElement(): HTMLIFrameElement | null {
|
|
197
203
|
return this.iframe
|
|
@@ -303,6 +309,7 @@ export class YouTubeProvider extends BaseProvider {
|
|
|
303
309
|
this.player?.destroy()
|
|
304
310
|
this.player = null
|
|
305
311
|
this.playerReady = false
|
|
312
|
+
this.pendingPlay = false
|
|
306
313
|
this.iframe = null
|
|
307
314
|
}
|
|
308
315
|
|
|
@@ -324,8 +331,13 @@ export class YouTubeProvider extends BaseProvider {
|
|
|
324
331
|
|
|
325
332
|
// Load pending video if any
|
|
326
333
|
if (this.pendingVideoId) {
|
|
327
|
-
|
|
334
|
+
const videoId = this.pendingVideoId
|
|
328
335
|
this.pendingVideoId = null
|
|
336
|
+
this.loadVideo(videoId, this.startTime, this.endTime)
|
|
337
|
+
}
|
|
338
|
+
else if (this.pendingPlay) {
|
|
339
|
+
this.pendingPlay = false
|
|
340
|
+
this.player?.playVideo()
|
|
329
341
|
}
|
|
330
342
|
}
|
|
331
343
|
|
|
@@ -340,6 +352,9 @@ export class YouTubeProvider extends BaseProvider {
|
|
|
340
352
|
this.events.emit('loadstart')
|
|
341
353
|
break
|
|
342
354
|
case PlayerState.PLAYING:
|
|
355
|
+
// A video loaded playing never passes through CUED, where the
|
|
356
|
+
// duration is otherwise announced.
|
|
357
|
+
if (this.getDuration() > 0) this.events.emit('durationchange', this.getDuration())
|
|
343
358
|
this.events.emit('play')
|
|
344
359
|
this.events.emit('playing')
|
|
345
360
|
this.events.emit('statechange', { paused: false, playing: true, waiting: false })
|
|
@@ -415,7 +430,8 @@ export class YouTubeProvider extends BaseProvider {
|
|
|
415
430
|
private loadVideo(videoId: string, startTime?: number, endTime?: number): void {
|
|
416
431
|
if (!this.player) return
|
|
417
432
|
|
|
418
|
-
if (this.options.autoplay) {
|
|
433
|
+
if (this.options.autoplay || this.pendingPlay) {
|
|
434
|
+
this.pendingPlay = false
|
|
419
435
|
this.player.loadVideoById({
|
|
420
436
|
videoId,
|
|
421
437
|
startSeconds: startTime,
|
|
@@ -433,15 +449,21 @@ export class YouTubeProvider extends BaseProvider {
|
|
|
433
449
|
// === Playback ===
|
|
434
450
|
|
|
435
451
|
async play(): Promise<void> {
|
|
436
|
-
this.player
|
|
452
|
+
if (!this.player || !this.playerReady || this.pendingVideoId) {
|
|
453
|
+
this.pendingPlay = true
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
this.player.playVideo()
|
|
437
457
|
}
|
|
438
458
|
|
|
439
459
|
pause(): void {
|
|
440
|
-
this.
|
|
460
|
+
this.pendingPlay = false
|
|
461
|
+
if (this.playerReady) this.player?.pauseVideo()
|
|
441
462
|
}
|
|
442
463
|
|
|
443
464
|
stop(): void {
|
|
444
|
-
this.
|
|
465
|
+
this.pendingPlay = false
|
|
466
|
+
if (this.playerReady) this.player?.stopVideo()
|
|
445
467
|
}
|
|
446
468
|
|
|
447
469
|
// === Seeking ===
|