Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/youtube-video-element/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,48 @@ test('t parameter - case insensitive', async function (t) {
t.equal(startParam, '171', 'start parameter is set from uppercase T parameter');
});

test('destroys the player when disconnected', async function (t) {
const video = await createVideoElement();
await video.loadComplete;

t.ok(video.api, 'has a player once loaded');
t.ok(video.shadowRoot.querySelector('iframe'), 'has an iframe once loaded');

video.remove();

t.equal(video.api, null, 'the player reference is released on disconnect');
t.equal(video.isLoaded, false, 'the element is no longer marked loaded');
t.equal(
video.shadowRoot.querySelector('iframe'),
null,
'destroy() removed the iframe from the shadow root'
);
});

test('creates a new player when reconnected', async function (t) {
const video = await createVideoElement();
await video.loadComplete;

const firstApi = video.api;
video.remove();
document.body.append(video);

await video.loadComplete;

t.ok(video.api, 'has a player again after reconnecting');
t.ok(video.api !== firstApi, 'a new player was created, not the destroyed one');
t.ok(video.shadowRoot.querySelector('iframe'), 'the iframe was rebuilt');
});

test('disconnecting before the player is ready does not throw', async function (t) {
const video = await createVideoElement();
// Do not await loadComplete: the API may still be loading.
video.remove();

t.equal(video.api, null, 'no player is left behind');
t.ok(true, 'disconnecting mid-load did not throw');
});

function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Expand Down
68 changes: 61 additions & 7 deletions packages/youtube-video-element/youtube-video-element.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ class YoutubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement
loadComplete = new PublicPromise();
#loadRequested;
#hasLoaded;
#wasDisconnected = false;
#loadId = 0;
#timers = [];
#readyState = 0;
#seeking = false;
#seekComplete;
Expand All @@ -172,6 +175,12 @@ class YoutubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement
async load() {
if (this.#loadRequested) return;

// Identifies this load attempt. `load()` awaits the API script, so the
// element can be disconnected (or asked to load again) while this one is
// still in flight; those bump #loadId and this attempt bails out below
// instead of attaching a player nothing will ever destroy.
const loadId = ++this.#loadId;

if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
}
Expand All @@ -193,12 +202,13 @@ class YoutubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement

let oldApi = this.api;
this.api = null;
// A new player is always constructed below, so the previous one is never
// reused. Release it rather than just dropping the reference, otherwise it
// stays in the API's registries holding its (now discarded) iframe.
// Removes the <iframe> containing the player.
oldApi?.destroy();

if (!this.src) {
// Removes the <iframe> containing the player.
oldApi?.destroy();
return;
}
if (!this.src) return;

this.#textTracksVideo = document.createElement('video');
this.textTracks = this.#textTracksVideo.textTracks;
Expand All @@ -223,6 +233,10 @@ class YoutubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement
}

const YT = await loadScript(API_URL, API_GLOBAL, API_GLOBAL_READY);

// Superseded by a disconnect or a newer load() while awaiting the API.
if (loadId !== this.#loadId) return;

Comment thread
cursor[bot] marked this conversation as resolved.
this.api = new YT.Player(iframe, {
events: {
onReady: () => {
Expand Down Expand Up @@ -328,8 +342,14 @@ class YoutubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement

await this.loadComplete;

// Superseded while awaiting loadComplete; don't start pollers for a player
// that is already gone.
if (loadId !== this.#loadId) return;

this.#clearTimers();

let lastCurrentTime = 0;
setInterval(() => {
this.#timers.push(setInterval(() => {
const diff = Math.abs(this.currentTime - lastCurrentTime);
const bufferedEnd = this.buffered.end(this.buffered.length - 1);
if (this.seeking && bufferedEnd > 0.1) {
Expand All @@ -341,7 +361,7 @@ class YoutubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement
this.dispatchEvent(new Event('seeking'));
}
lastCurrentTime = this.currentTime;
}, 50);
}, 50));

let lastBufferedEnd;
const progressInterval = setInterval(() => {
Expand All @@ -355,6 +375,40 @@ class YoutubeVideoElement extends MediaPlayedRangesMixin(globalThis.HTMLElement
this.dispatchEvent(new Event('progress'));
}
}, 100);
this.#timers.push(progressInterval);
}

#clearTimers() {
for (const id of this.#timers) clearInterval(id);
this.#timers = [];
}

connectedCallback() {
Comment thread
cursor[bot] marked this conversation as resolved.
// `load()` is only triggered by attributeChangedCallback, so an element that
// is moved in the DOM (disconnected and reconnected without its attributes
// changing) has to ask for a new player itself.
if (this.#wasDisconnected) {
this.#wasDisconnected = false;
this.load();
}
super.connectedCallback?.();
}

disconnectedCallback() {
this.#wasDisconnected = true;
this.#loadId++;
this.#clearTimers();
this.#loadRequested = null;
this.#hasLoaded = null;
this.isLoaded = false;
this.loadComplete = new PublicPromise();
// The YouTube iframe API holds a reference to every player it creates, so
// dropping the element is not enough to release the <iframe>: it stays
// detached but reachable from window.YT. destroy() removes the iframe and
// deregisters the player.
this.api?.destroy?.();
this.api = null;
super.disconnectedCallback?.();
}

async attributeChangedCallback(attrName, oldValue, newValue) {
Expand Down