diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/js/config/defaults.js | 3 | ||||
-rw-r--r-- | src/js/controls.js | 17 | ||||
-rw-r--r-- | src/js/fullscreen.js | 2 | ||||
-rw-r--r-- | src/js/html5.js | 70 | ||||
-rw-r--r-- | src/js/listeners.js | 7 | ||||
-rw-r--r-- | src/js/plugins/ads.js | 2 | ||||
-rw-r--r-- | src/js/plugins/vimeo.js | 8 | ||||
-rw-r--r-- | src/js/plugins/youtube.js | 6 | ||||
-rw-r--r-- | src/js/plyr.d.ts | 560 | ||||
-rw-r--r-- | src/js/plyr.js | 14 | ||||
-rw-r--r-- | src/js/ui.js | 1 | ||||
-rw-r--r-- | src/js/utils/events.js | 4 |
12 files changed, 641 insertions, 53 deletions
diff --git a/src/js/config/defaults.js b/src/js/config/defaults.js index c50a8900..969c78d1 100644 --- a/src/js/config/defaults.js +++ b/src/js/config/defaults.js @@ -70,6 +70,8 @@ const defaults = { quality: { default: 576, options: [4320, 2880, 2160, 1440, 1080, 720, 576, 480, 360, 240], + forced: false, + onChange: null, }, // Set loops @@ -164,6 +166,7 @@ const defaults = { frameTitle: 'Player for {title}', captions: 'Captions', settings: 'Settings', + pip: 'PIP', menuBack: 'Go back to previous menu', speed: 'Speed', normal: 'Normal', diff --git a/src/js/controls.js b/src/js/controls.js index 76b15dda..7993dcb8 100644 --- a/src/js/controls.js +++ b/src/js/controls.js @@ -139,10 +139,7 @@ const controls = { // Create hidden text label createLabel(key, attr = {}) { const text = i18n.get(key, this.config); - - const attributes = Object.assign({}, attr, { - class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' '), - }); + const attributes = { ...attr, class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' '),}; return createElement('span', attributes, text); }, @@ -402,7 +399,8 @@ const controls = { // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143 bindMenuItemShortcuts(menuItem, type) { // Navigate through menus via arrow keys and space - on( + on.call( + this, menuItem, 'keydown keyup', event => { @@ -452,7 +450,7 @@ const controls = { // Enter will fire a `click` event but we still need to manage focus // So we bind to keyup which fires after and set focus here - on(menuItem, 'keyup', event => { + on.call(this, menuItem, 'keyup', event => { if (event.which !== 13) { return; } @@ -1463,7 +1461,7 @@ const controls = { bindMenuItemShortcuts.call(this, menuItem, type); // Show menu on click - on(menuItem, 'click', () => { + on.call(this, menuItem, 'click', () => { showMenuPanel.call(this, type, false); }); @@ -1515,7 +1513,8 @@ const controls = { ); // Go back via keyboard - on( + on.call( + this, pane, 'keydown', event => { @@ -1535,7 +1534,7 @@ const controls = { ); // Go back via button click - on(backButton, 'click', () => { + on.call(this, backButton, 'click', () => { showMenuPanel.call(this, 'home', false); }); diff --git a/src/js/fullscreen.js b/src/js/fullscreen.js index 4de8da88..7ae3ff17 100644 --- a/src/js/fullscreen.js +++ b/src/js/fullscreen.js @@ -228,7 +228,7 @@ class Fullscreen { } else if (!Fullscreen.native || this.forceFallback) { toggleFallback.call(this, true); } else if (!this.prefix) { - this.target.requestFullscreen(); + this.target.requestFullscreen({ navigationUI: "hide" }); } else if (!is.empty(this.prefix)) { this.target[`${this.prefix}Request${this.property}`](); } diff --git a/src/js/html5.js b/src/js/html5.js index b03e9c26..a0825cf6 100644 --- a/src/js/html5.js +++ b/src/js/html5.js @@ -30,6 +30,11 @@ const html5 = { // Get quality levels getQualityOptions() { + // Whether we're forcing all options (e.g. for streaming) + if (this.config.quality.forced) { + return this.config.quality.options; + } + // Get sizes from <source> elements return html5.getSources .call(this) @@ -60,36 +65,41 @@ const html5 = { return source && Number(source.getAttribute('size')); }, set(input) { - // Get sources - const sources = html5.getSources.call(player); - // Get first match for requested size - const source = sources.find(s => Number(s.getAttribute('size')) === input); - - // No matching source found - if (!source) { - return; - } - - // Get current state - const { currentTime, paused, preload, readyState } = player.media; - - // Set new source - player.media.src = source.getAttribute('src'); - - // Prevent loading if preload="none" and the current source isn't loaded (#1044) - if (preload !== 'none' || readyState) { - // Restore time - player.once('loadedmetadata', () => { - player.currentTime = currentTime; - - // Resume playing - if (!paused) { - player.play(); - } - }); - - // Load new source - player.media.load(); + // If we're using an an external handler... + if (player.config.quality.forced && is.function(player.config.quality.onChange)) { + player.config.quality.onChange(input); + } else { + // Get sources + const sources = html5.getSources.call(player); + // Get first match for requested size + const source = sources.find(s => Number(s.getAttribute('size')) === input); + + // No matching source found + if (!source) { + return; + } + + // Get current state + const { currentTime, paused, preload, readyState } = player.media; + + // Set new source + player.media.src = source.getAttribute('src'); + + // Prevent loading if preload="none" and the current source isn't loaded (#1044) + if (preload !== 'none' || readyState) { + // Restore time + player.once('loadedmetadata', () => { + player.currentTime = currentTime; + + // Resume playing + if (!paused) { + player.play(); + } + }); + + // Load new source + player.media.load(); + } } // Trigger change event diff --git a/src/js/listeners.js b/src/js/listeners.js index c5076ff3..643b7dd3 100644 --- a/src/js/listeners.js +++ b/src/js/listeners.js @@ -390,6 +390,9 @@ class Listeners { if (player.isHTML5 && player.isVideo && player.config.resetOnEnd) { // Restart player.restart(); + + // Call pause otherwise IE11 will start playing the video again + player.pause(); } }); @@ -513,7 +516,7 @@ class Listeners { } // Only call default handler if not prevented in custom handler - if (returned && is.function(defaultHandler)) { + if (returned !== false && is.function(defaultHandler)) { defaultHandler.call(player, event); } } @@ -663,7 +666,7 @@ class Listeners { const code = event.keyCode ? event.keyCode : event.which; const attribute = 'play-on-seeked'; - if (is.keyboardEvent(event) && (code !== 39 && code !== 37)) { + if (is.keyboardEvent(event) && code !== 39 && code !== 37) { return; } diff --git a/src/js/plugins/ads.js b/src/js/plugins/ads.js index db55e499..6b4fca10 100644 --- a/src/js/plugins/ads.js +++ b/src/js/plugins/ads.js @@ -136,7 +136,7 @@ class Ads { cb: Date.now(), AV_WIDTH: 640, AV_HEIGHT: 480, - AV_CDIM2: this.publisherId, + AV_CDIM2: config.publisherId, }; const base = 'https://go.aniview.com/api/adserver6/vast/'; diff --git a/src/js/plugins/vimeo.js b/src/js/plugins/vimeo.js index 91019abf..8df5ad15 100644 --- a/src/js/plugins/vimeo.js +++ b/src/js/plugins/vimeo.js @@ -335,6 +335,14 @@ const vimeo = { } }); + player.embed.on('bufferstart', () => { + triggerEvent.call(player, player.media, 'waiting'); + }); + + player.embed.on('bufferend', () => { + triggerEvent.call(player, player.media, 'playing'); + }); + player.embed.on('play', () => { assurePlaybackState.call(player, true); triggerEvent.call(player, player.media, 'playing'); diff --git a/src/js/plugins/youtube.js b/src/js/plugins/youtube.js index 31d22bb4..ba5d8de9 100644 --- a/src/js/plugins/youtube.js +++ b/src/js/plugins/youtube.js @@ -416,6 +416,12 @@ const youtube = { break; + case 3: + // Trigger waiting event to add loading classes to container as the video buffers. + triggerEvent.call(player, player.media, 'waiting'); + + break; + default: break; } diff --git a/src/js/plyr.d.ts b/src/js/plyr.d.ts new file mode 100644 index 00000000..4f64898f --- /dev/null +++ b/src/js/plyr.d.ts @@ -0,0 +1,560 @@ +// Type definitions for plyr 3.5 +// Project: https://plyr.io +// Definitions by: ondratra <https://github.com/ondratra> +// TypeScript Version: 3.0 + +export = Plyr; +export as namespace Plyr; + + +declare class Plyr { + /** + * Setup a new instance + */ + static setup(targets: NodeList | HTMLElement | HTMLElement[] | string, options?: Plyr.Options): Plyr[]; + + /** + * Check for support + * @param mediaType + * @param provider + * @param playsInline Whether the player has the playsinline attribute (only applicable to iOS 10+) + */ + static supported(mediaType?: Plyr.MediaType, provider?: Plyr.Provider, playsInline?: boolean): Plyr.Support; + + constructor(targets: NodeList | HTMLElement | HTMLElement[] | string, options?: Plyr.Options); + + /** + * Indicates if the current player is HTML5. + */ + readonly isHTML5: boolean; + + /** + * Indicates if the current player is an embedded player. + */ + readonly isEmbed: boolean; + + /** + * Indicates if the current player is playing. + */ + readonly playing: boolean; + + /** + * Indicates if the current player is paused. + */ + readonly paused: boolean; + + /** + * Indicates if the current player is stopped. + */ + readonly stopped: boolean; + + /** + * Indicates if the current player has finished playback. + */ + readonly ended: boolean; + + /** + * Returns a float between 0 and 1 indicating how much of the media is buffered + */ + readonly buffered: number; + + /** + * Gets or sets the currentTime for the player. The setter accepts a float in seconds. + */ + currentTime: number; + + /** + * Indicates if the current player is seeking. + */ + readonly seeking: boolean; + + /** + * Returns the duration for the current media. + */ + readonly duration: number; + + /** + * Gets or sets the volume for the player. The setter accepts a float between 0 and 1. + */ + volume: number; + + /** + * Gets or sets the muted state of the player. The setter accepts a boolean. + */ + muted: boolean; + + /** + * Indicates if the current media has an audio track. + */ + readonly hasAudio: boolean; + + /** + * Gets or sets the speed for the player. The setter accepts a value in the options specified in your config. Generally the minimum should be 0.5. + */ + speed: number; + + /** + * Gets or sets the quality for the player. The setter accepts a value from the options specified in your config. + * Remarks: YouTube only. HTML5 will follow. + */ + quality: string; + + /** + * Gets or sets the current loop state of the player. + */ + loop: boolean; + + /** + * Gets or sets the current source for the player. + */ + source: Plyr.SourceInfo; + + /** + * Gets or sets the current poster image URL for the player. + */ + poster: string; + + /** + * Gets or sets the autoplay state of the player. + */ + autoplay: boolean; + + /** + * Gets or sets the caption track by index. 1 means the track is missing or captions is not active + */ + currentTrack: number; + + /** + * Gets or sets the preferred captions language for the player. The setter accepts an ISO twoletter language code. Support for the languages is dependent on the captions you include. + * If your captions don't have any language data, or if you have multiple tracks with the same language, you may want to use currentTrack instead. + */ + language: string; + + /** + * Gets or sets the picture-in-picture state of the player. This currently only supported on Safari 10+ on MacOS Sierra+ and iOS 10+. + */ + pip: boolean; + + readonly fullscreen: Plyr.FullscreenControl; + + /** + * Start playback. + * For HTML5 players, play() will return a Promise in some browsers - WebKit and Mozilla according to MDN at time of writing. + */ + play(): Promise<void> | void; + + /** + * Pause playback. + */ + pause(): void; + + /** + * Toggle playback, if no parameters are passed, it will toggle based on current status. + */ + togglePlay(toggle?: boolean): boolean; + + /** + * Stop playback and reset to start. + */ + stop(): void; + + /** + * Restart playback. + */ + restart(): void; + + /** + * Rewind playback by the specified seek time. If no parameter is passed, the default seek time will be used. + */ + rewind(seekTime?: number): void; + + /** + * Fast forward by the specified seek time. If no parameter is passed, the default seek time will be used. + */ + forward(seekTime?: number): void; + + /** + * Increase volume by the specified step. If no parameter is passed, the default step will be used. + */ + increaseVolume(step?: number): void; + + /** + * Increase volume by the specified step. If no parameter is passed, the default step will be used. + */ + decreaseVolume(step?: number): void; + + /** + * Toggle captions display. If no parameter is passed, it will toggle based on current status. + */ + toggleCaptions(toggle?: boolean): void; + + /** + * Trigger the airplay dialog on supported devices. + */ + airplay(): void; + + /** + * Toggle the controls (video only). Takes optional truthy value to force it on/off. + */ + toggleControls(toggle: boolean): void; + + /** + * Add an event listener for the specified event. + */ + on(event: Plyr.StandardEvent | Plyr.Html5Event | Plyr.YoutubeEvent, callback: (this: this, event: Plyr.PlyrEvent) => void): void; + + /** + * Add an event listener for the specified event once. + */ + once(event: Plyr.StandardEvent | Plyr.Html5Event | Plyr.YoutubeEvent, callback: (this: this, event: Plyr.PlyrEvent) => void): void; + + /** + * Remove an event listener for the specified event. + */ + off(event: Plyr.StandardEvent | Plyr.Html5Event | Plyr.YoutubeEvent, callback: (this: this, event: Plyr.PlyrEvent) => void): void; + + /** + * Check support for a mime type. + */ + supports(type: string): boolean; + + /** + * Destroy lib instance + */ + destroy(): void; +} + +declare namespace Plyr { + type MediaType = "audio" | "video"; + type Provider = "html5" | "youtube" | "vimeo"; + type StandardEvent = "progress" | "playing" | "play" | "pause" | "timeupdate" | "volumechange" | "seeking" | "seeked" | "ratechange" | "ended" | "enterfullscreen" | "exitfullscreen" + | "captionsenabled" | "captionsdisabled" | "languagechange" | "controlshidden" | "controlsshown" | "ready"; + type Html5Event = "loadstart" | "loadeddata" | "loadedmetadata" | "canplay" | "canplaythrough" | "stalled" | "waiting" | "emptied" | "cuechange" | "error"; + type YoutubeEvent = "statechange" | "qualitychange" | "qualityrequested"; + + interface FullscreenControl { + /** + * Indicates if the current player is in fullscreen mode. + */ + readonly active: boolean; + + /** + * Indicates if the current player has fullscreen enabled. + */ + readonly enabled: boolean; + + /** + * Enter fullscreen. If fullscreen is not supported, a fallback ""full window/viewport"" is used instead. + */ + enter(): void; + + /** + * Exit fullscreen. + */ + exit(): void; + + /** + * Toggle fullscreen. + */ + toggle(): void; + } + + interface Options { + /** + * Completely disable Plyr. This would allow you to do a User Agent check or similar to programmatically enable or disable Plyr for a certain UA. Example below. + */ + enabled?: boolean; + + /** + * Display debugging information in the console + */ + debug?: boolean; + + /** + * If a function is passed, it is assumed your method will return either an element or HTML string for the controls. Three arguments will be passed to your function; + * id (the unique id for the player), seektime (the seektime step in seconds), and title (the media title). See controls.md for more info on how the html needs to be structured. + * Defaults to ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'] + */ + controls?: string[] | ((id: string, seektime: number, title: string) => unknown) | Element; + + /** + * If you're using the default controls are used then you can specify which settings to show in the menu + * Defaults to ['captions', 'quality', 'speed', 'loop'] + */ + settings?: string[]; + + /** + * Used for internationalization (i18n) of the text within the UI. + */ + i18n?: any; + + /** + * Load the SVG sprite specified as the iconUrl option (if a URL). If false, it is assumed you are handling sprite loading yourself. + */ + loadSprite?: boolean; + + /** + * Specify a URL or path to the SVG sprite. See the SVG section for more info. + */ + iconUrl?: string; + + /** + * Specify the id prefix for the icons used in the default controls (e.g. plyr-play would be plyr). + * This is to prevent clashes if you're using your own SVG sprite but with the default controls. + * Most people can ignore this option. + */ + iconPrefix?: string; + + /** + * Specify a URL or path to a blank video file used to properly cancel network requests. + */ + blankUrl?: string; + + /** + * Autoplay the media on load. This is generally advised against on UX grounds. It is also disabled by default in some browsers. + * If the autoplay attribute is present on a <video> or <audio> element, this will be automatically set to true. + */ + autoplay?: boolean; + + /** + * Only allow one player playing at once. + */ + autopause?: boolean; + + /** + * The time, in seconds, to seek when a user hits fast forward or rewind. + */ + seekTime?: number; + + /** + * A number, between 0 and 1, representing the initial volume of the player. + */ + volume?: number; + + /** + * Whether to start playback muted. If the muted attribute is present on a <video> or <audio> element, this will be automatically set to true. + */ + muted?: boolean; + + /** + * Click (or tap) of the video container will toggle play/pause. + */ + clickToPlay?: boolean; + + /** + * Disable right click menu on video to help as very primitive obfuscation to prevent downloads of content. + */ + disableContextMenu?: boolean; + + /** + * Hide video controls automatically after 2s of no mouse or focus movement, on control element blur (tab out), on playback start or entering fullscreen. + * As soon as the mouse is moved, a control element is focused or playback is paused, the controls reappear instantly. + */ + hideControls?: boolean; + + /** + * Reset the playback to the start once playback is complete. + */ + resetOnEnd?: boolean; + + /** + * Enable keyboard shortcuts for focused players only or globally + */ + keyboard?: KeyboardOptions; + + /** + * controls: Display control labels as tooltips on :hover & :focus (by default, the labels are screen reader only). + * seek: Display a seek tooltip to indicate on click where the media would seek to. + */ + tooltips?: TooltipOptions; + + /** + * Specify a custom duration for media. + */ + duration?: number; + + /** + * Displays the duration of the media on the metadataloaded event (on startup) in the current time display. + * This will only work if the preload attribute is not set to none (or is not set at all) and you choose not to display the duration (see controls option). + */ + displayDuration?: boolean; + + /** + * Display the current time as a countdown rather than an incremental counter. + */ + invertTime?: boolean; + + /** + * Allow users to click to toggle the above. + */ + toggleInvert?: boolean; + + /** + * Allows binding of event listeners to the controls before the default handlers. See the defaults.js for available listeners. + * If your handler prevents default on the event (event.preventDefault()), the default handler will not fire. + */ + listeners?: {[key: string]: (error: PlyrEvent) => void}; + + /** + * active: Toggles if captions should be active by default. language: Sets the default language to load (if available). 'auto' uses the browser language. + * update: Listen to changes to tracks and update menu. This is needed for some streaming libraries, but can result in unselectable language options). + */ + captions?: CaptionOptions; + + /** + * enabled: Toggles whether fullscreen should be enabled. fallback: Allow fallback to a full-window solution. + * iosNative: whether to use native iOS fullscreen when entering fullscreen (no custom controls) + */ + fullscreen?: FullScreenOptions; + + /** + * The aspect ratio you want to use for embedded players. + */ + ratio?: string; + + /** + * enabled: Allow use of local storage to store user settings. key: The key name to use. + */ + storage?: StorageOptions; + + /** + * selected: The default speed for playback. options: Options to display in the menu. Most browsers will refuse to play slower than 0.5. + */ + speed?: SpeedOptions; + + /** + * Currently only supported by YouTube. default is the default quality level, determined by YouTube. options are the options to display. + */ + quality?: QualityOptions; + + /** + * active: Whether to loop the current video. If the loop attribute is present on a <video> or <audio> element, + * this will be automatically set to true This is an object to support future functionality. + */ + loop?: LoopOptions; + + /** + * enabled: Whether to enable vi.ai ads. publisherId: Your unique vi.ai publisher ID. + */ + ads?: AdOptions; + } + + interface QualityOptions { + default: string; + options: string[]; + } + + interface LoopOptions { + active: boolean; + } + + interface AdOptions { + enabled: boolean; + publisherId: string; + } + + interface SpeedOptions { + selected: number; + options: number[]; + } + + interface KeyboardOptions { + focused?: boolean; + global?: boolean; + } + + interface TooltipOptions { + controls?: boolean; + seek?: boolean; + } + + interface FullScreenOptions { + enabled?: boolean; + fallback?: boolean; + allowAudio?: boolean; + } + + interface CaptionOptions { + active?: boolean; + language?: string; + update?: boolean; + } + + interface StorageOptions { + enabled?: boolean; + key?: string; + } + + interface SourceInfo { + /** + * Note: YouTube and Vimeo are currently not supported as audio sources. + */ + type: MediaType; + + /** + * Title of the new media. Used for the aria-label attribute on the play button, and outer container. YouTube and Vimeo are populated automatically. + */ + title?: string; + + /** + * This is an array of sources. For HTML5 media, the properties of this object are mapped directly to HTML attributes so more can be added to the object if required. + */ + sources: Source[]; + + /** + * The URL for the poster image (HTML5 video only). + */ + poster?: string; + + /** + * An array of track objects. Each element in the array is mapped directly to a track element and any keys mapped directly to HTML attributes so as in the example above, + * it will render as <track kind="captions" label="English" srclang="en" src="https://cdn.selz.com/plyr/1.0/example_captions_en.vtt" default> and similar for the French version. + * Booleans are converted to HTML5 value-less attributes. + */ + tracks?: Track[]; + } + + interface Source { + /** + * The URL of the media file (or YouTube/Vimeo URL). + */ + src: string; + /** + * The MIME type of the media file (if HTML5). + */ + type?: string; + provider?: Provider; + size?: number; + } + + type TrackKind = "subtitles" | "captions" | "descriptions" | "chapters" | "metadata"; + interface Track { + /** + * Indicates how the text track is meant to be used + */ + kind: TrackKind; + /** + * Indicates a user-readable title for the track + */ + label: string; + /** + * The language of the track text data. It must be a valid BCP 47 language tag. If the kind attribute is set to subtitles, then srclang must be defined. + */ + srcLang?: string; + /** + * The URL of the track (.vtt file). + */ + src: string; + + default?: boolean; + } + + interface PlyrEvent extends CustomEvent { + readonly detail: { readonly plyr: Plyr; }; + } + + interface Support { + api: boolean; + ui: boolean; + } +} diff --git a/src/js/plyr.js b/src/js/plyr.js index f30d334a..04f1b873 100644 --- a/src/js/plyr.js +++ b/src/js/plyr.js @@ -368,10 +368,10 @@ class Plyr { */ pause() { if (!this.playing || !is.function(this.media.pause)) { - return; + return null; } - this.media.pause(); + return this.media.pause(); } /** @@ -411,10 +411,10 @@ class Plyr { const toggle = is.boolean(input) ? input : !this.playing; if (toggle) { - this.play(); - } else { - this.pause(); + return this.play(); } + + return this.pause(); } /** @@ -441,7 +441,7 @@ class Plyr { * @param {Number} seekTime - how far to rewind in seconds. Defaults to the config.seekTime */ rewind(seekTime) { - this.currentTime = this.currentTime - (is.number(seekTime) ? seekTime : this.config.seekTime); + this.currentTime -= is.number(seekTime) ? seekTime : this.config.seekTime; } /** @@ -449,7 +449,7 @@ class Plyr { * @param {Number} seekTime - how far to fast forward in seconds. Defaults to the config.seekTime */ forward(seekTime) { - this.currentTime = this.currentTime + (is.number(seekTime) ? seekTime : this.config.seekTime); + this.currentTime += is.number(seekTime) ? seekTime : this.config.seekTime; } /** diff --git a/src/js/ui.js b/src/js/ui.js index 953ecba2..9febab8b 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -214,6 +214,7 @@ const ui = { // Set state Array.from(this.elements.buttons.play || []).forEach(target => { Object.assign(target, { pressed: this.playing }); + target.setAttribute('aria-label', i18n.get(this.playing ? 'pause' : 'play', this.config)); }); // Only update controls on non timeupdate events diff --git a/src/js/utils/events.js b/src/js/utils/events.js index 87c35d26..31571b2d 100644 --- a/src/js/utils/events.js +++ b/src/js/utils/events.js @@ -90,9 +90,7 @@ export function triggerEvent(element, type = '', bubbles = false, detail = {}) { // Create and dispatch the event const event = new CustomEvent(type, { bubbles, - detail: Object.assign({}, detail, { - plyr: this, - }), + detail: { ...detail, plyr: this,}, }); // Dispatch the event |