|
|
|
|
@@ -147,6 +147,7 @@ App.videos = App.videos || {};
|
|
|
|
|
const imageRoutes = new Map(); // host -> winning route; absent = unknown
|
|
|
|
|
const imageRacing = new Set(); // hosts with a race already deciding
|
|
|
|
|
const imageWaiting = new Map(); // host -> images held until it decides
|
|
|
|
|
let thumbSeq = 0; // generation, so stale work can be dropped
|
|
|
|
|
|
|
|
|
|
// A page of cards is built in one go, so every thumbnail from a host is
|
|
|
|
|
// attached before the first one has come back. Sending them all down the
|
|
|
|
|
@@ -188,7 +189,13 @@ App.videos = App.videos || {};
|
|
|
|
|
// and put on only once there is an image to caption -- otherwise every card
|
|
|
|
|
// spells out its own title over the placeholder while the host is being
|
|
|
|
|
// decided, and permanently for an item that has no thumbnail at all.
|
|
|
|
|
const showThumbnail = function(img, url) {
|
|
|
|
|
// `token` is the generation of the attachThumbnail call that started this
|
|
|
|
|
// work. A card can be recycled while its thumbnail is still being decided,
|
|
|
|
|
// and the callbacks that eventually fire still hold the old element -- so
|
|
|
|
|
// anything arriving for a generation the element has moved past is dropped
|
|
|
|
|
// rather than painted onto whatever video the card now shows.
|
|
|
|
|
const showThumbnail = function(img, url, token) {
|
|
|
|
|
if (token !== undefined && img.dataset.thumbToken !== token) return;
|
|
|
|
|
if (img.dataset.alt !== undefined) {
|
|
|
|
|
img.alt = img.dataset.alt;
|
|
|
|
|
delete img.dataset.alt;
|
|
|
|
|
@@ -198,9 +205,22 @@ App.videos = App.videos || {};
|
|
|
|
|
|
|
|
|
|
// Last resort on a route that normally works: one expired or missing image
|
|
|
|
|
// shouldn't be left broken just because its host is fine in general.
|
|
|
|
|
const attachProxyFallback = function(img, proxyUrl) {
|
|
|
|
|
const attachProxyFallback = function(img, proxyUrl, token) {
|
|
|
|
|
if (!proxyUrl) return;
|
|
|
|
|
img.addEventListener('error', () => { showThumbnail(img, proxyUrl); }, { once: true });
|
|
|
|
|
// Held on the element so detachThumbnail can take it off again. On the
|
|
|
|
|
// happy path it never fires and `once` never collects it, so a pooled
|
|
|
|
|
// image would otherwise accumulate one closure per mount it has served.
|
|
|
|
|
detachProxyFallback(img);
|
|
|
|
|
const onError = () => { showThumbnail(img, proxyUrl, token); };
|
|
|
|
|
img._thumbFallback = onError;
|
|
|
|
|
img.addEventListener('error', onError, { once: true });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const detachProxyFallback = function(img) {
|
|
|
|
|
if (img && img._thumbFallback) {
|
|
|
|
|
img.removeEventListener('error', img._thumbFallback);
|
|
|
|
|
img._thumbFallback = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Releases the images held for `host`. `route` is the winner, or null when
|
|
|
|
|
@@ -213,11 +233,11 @@ App.videos = App.videos || {};
|
|
|
|
|
imageWaiting.delete(host);
|
|
|
|
|
waiting.forEach((entry) => {
|
|
|
|
|
if (route === IMAGE_PROXY) {
|
|
|
|
|
showThumbnail(entry.img, entry.proxyUrl);
|
|
|
|
|
showThumbnail(entry.img, entry.proxyUrl, entry.token);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (route !== IMAGE_DIRECT) attachProxyFallback(entry.img, entry.proxyUrl);
|
|
|
|
|
showThumbnail(entry.img, entry.directUrl);
|
|
|
|
|
if (route !== IMAGE_DIRECT) attachProxyFallback(entry.img, entry.proxyUrl, entry.token);
|
|
|
|
|
showThumbnail(entry.img, entry.directUrl, entry.token);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@@ -238,7 +258,7 @@ App.videos = App.videos || {};
|
|
|
|
|
// pointed at the first one home. Racing on the visible element instead
|
|
|
|
|
// would abort the loser -- and the loser is the request that answers the
|
|
|
|
|
// second question.
|
|
|
|
|
const raceThumbnail = function(img, directUrl, proxyUrl, host) {
|
|
|
|
|
const raceThumbnail = function(img, directUrl, proxyUrl, host, token) {
|
|
|
|
|
let shown = false;
|
|
|
|
|
let outstanding = 2;
|
|
|
|
|
let routeFinal = false; // the direct verdict is in; nothing can revise it
|
|
|
|
|
@@ -250,7 +270,7 @@ App.videos = App.videos || {};
|
|
|
|
|
const show = function(url) {
|
|
|
|
|
if (shown) return;
|
|
|
|
|
shown = true;
|
|
|
|
|
showThumbnail(img, url); // a cache hit; the probe has the bytes
|
|
|
|
|
showThumbnail(img, url, token); // a cache hit; the probe has the bytes
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Records the host's route and lets go of everything held for it. The
|
|
|
|
|
@@ -278,11 +298,11 @@ App.videos = App.videos || {};
|
|
|
|
|
if (shown) return;
|
|
|
|
|
shown = true;
|
|
|
|
|
if (imageRoutes.get(host) === IMAGE_PROXY) {
|
|
|
|
|
showThumbnail(img, proxyUrl);
|
|
|
|
|
showThumbnail(img, proxyUrl, token);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
attachProxyFallback(img, proxyUrl);
|
|
|
|
|
showThumbnail(img, directUrl);
|
|
|
|
|
attachProxyFallback(img, proxyUrl, token);
|
|
|
|
|
showThumbnail(img, directUrl, token);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const decide = function() {
|
|
|
|
|
@@ -383,9 +403,13 @@ App.videos = App.videos || {};
|
|
|
|
|
const proxyUrl = App.videos.buildImageProxyUrl(directUrl);
|
|
|
|
|
const host = imageHostOf(directUrl);
|
|
|
|
|
const route = imageRoutes.get(host);
|
|
|
|
|
// Every attach is a new generation, so work started for a previous one
|
|
|
|
|
// stops being able to touch this element.
|
|
|
|
|
const token = String(++thumbSeq);
|
|
|
|
|
img.dataset.thumbToken = token;
|
|
|
|
|
|
|
|
|
|
if (route === IMAGE_PROXY) {
|
|
|
|
|
showThumbnail(img, proxyUrl || directUrl);
|
|
|
|
|
showThumbnail(img, proxyUrl || directUrl, token);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (imageRacing.has(host)) {
|
|
|
|
|
@@ -393,18 +417,28 @@ App.videos = App.videos || {};
|
|
|
|
|
// guessing: guessing wrong costs this image a whole failed request
|
|
|
|
|
// before it even asks the route that was about to be proven.
|
|
|
|
|
const waiting = imageWaiting.get(host) || [];
|
|
|
|
|
waiting.push({ img: img, directUrl: directUrl, proxyUrl: proxyUrl });
|
|
|
|
|
waiting.push({ img: img, directUrl: directUrl, proxyUrl: proxyUrl, token: token });
|
|
|
|
|
imageWaiting.set(host, waiting);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (route === IMAGE_DIRECT || !host || !proxyUrl) {
|
|
|
|
|
// Known good, or nothing to race against: take the provider and keep
|
|
|
|
|
// the proxy as this image's own fallback.
|
|
|
|
|
attachProxyFallback(img, proxyUrl);
|
|
|
|
|
showThumbnail(img, directUrl);
|
|
|
|
|
attachProxyFallback(img, proxyUrl, token);
|
|
|
|
|
showThumbnail(img, directUrl, token);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
raceThumbnail(img, directUrl, proxyUrl, host);
|
|
|
|
|
raceThumbnail(img, directUrl, proxyUrl, host, token);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Voids whatever is still in flight for this element's thumbnail. Its
|
|
|
|
|
// generation moves on, so a race that settles later, or a proxy fallback
|
|
|
|
|
// that fires later, finds a token that no longer matches and does nothing.
|
|
|
|
|
App.videos.detachThumbnail = function(img) {
|
|
|
|
|
if (!img) return;
|
|
|
|
|
img.dataset.thumbToken = String(++thumbSeq);
|
|
|
|
|
delete img.dataset.alt;
|
|
|
|
|
detachProxyFallback(img);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Each channel in a group sends back a different number of videos per
|
|
|
|
|
@@ -710,132 +744,268 @@ App.videos = App.videos || {};
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Builds a fully-wired video card element for `v`. Kept separate from
|
|
|
|
|
// mounting so the virtualizer can create a card the moment it needs to be
|
|
|
|
|
// on screen and throw it away once it scrolls out of the window.
|
|
|
|
|
App.videos.buildCard = function(v, options) {
|
|
|
|
|
const card = document.createElement('div');
|
|
|
|
|
card.className = 'video-card';
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
// Cards
|
|
|
|
|
//
|
|
|
|
|
// A card is built once and then reused: the virtualizer keeps a pool of
|
|
|
|
|
// them and rebinds one to a new video rather than constructing another
|
|
|
|
|
// (see acquire/release in App.virtualGrid). Two things follow from that,
|
|
|
|
|
// and both are load-bearing.
|
|
|
|
|
//
|
|
|
|
|
// Nothing on a card may close over the video it is currently showing --
|
|
|
|
|
// the card outlives the video. Every interaction is therefore handled by
|
|
|
|
|
// one delegated listener per event type on the grid, which resolves the
|
|
|
|
|
// video from the card under the pointer (see bindGridDelegation).
|
|
|
|
|
//
|
|
|
|
|
// And every card has the same shape whatever video it shows: the optional
|
|
|
|
|
// parts -- live badge, uploader, duration, tags -- are always present and
|
|
|
|
|
// hidden when unused, so any pooled card fits any video.
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
const CARD_TEMPLATE_HTML = `
|
|
|
|
|
<span class="live-badge" hidden>● LIVE</span>
|
|
|
|
|
<button class="favorite-btn" type="button" data-action="favorite" aria-pressed="false" aria-label="Add to favorites">♡</button>
|
|
|
|
|
<button class="video-menu-btn" type="button" data-action="menu" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
|
|
|
|
<div class="video-menu" role="menu">
|
|
|
|
|
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
|
|
|
|
|
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="video-thumb">
|
|
|
|
|
<img alt="" loading="lazy" decoding="async">
|
|
|
|
|
<div class="video-loading" aria-hidden="true">
|
|
|
|
|
<div class="video-loading-spinner"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="video-uploader uploader-link" type="button" data-action="uploader" hidden></button>
|
|
|
|
|
<span class="video-duration" hidden></span>
|
|
|
|
|
</div>
|
|
|
|
|
<h4 class="video-title"><span class="video-title-text"></span></h4>
|
|
|
|
|
<div class="video-tags" hidden></div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
// Parsed once. Cloning this is roughly six times cheaper than asking the
|
|
|
|
|
// parser to read the same markup again for every card.
|
|
|
|
|
let cardTemplate = null;
|
|
|
|
|
|
|
|
|
|
// The parts bindCard writes to, found once when the card is created rather
|
|
|
|
|
// than looked up again on every rebind. Searching the subtree seven times
|
|
|
|
|
// per mount was most of what rebinding cost.
|
|
|
|
|
const cardRefs = function(card) {
|
|
|
|
|
if (!card._refs) {
|
|
|
|
|
card._refs = {
|
|
|
|
|
live: card.querySelector('.live-badge'),
|
|
|
|
|
favorite: card.querySelector('.favorite-btn'),
|
|
|
|
|
title: card.querySelector('.video-title-text'),
|
|
|
|
|
uploader: card.querySelector('.video-uploader'),
|
|
|
|
|
duration: card.querySelector('.video-duration'),
|
|
|
|
|
tags: card.querySelector('.video-tags'),
|
|
|
|
|
img: card.querySelector('img')
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return card._refs;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const createCardShell = function() {
|
|
|
|
|
if (!cardTemplate) {
|
|
|
|
|
cardTemplate = document.createElement('template');
|
|
|
|
|
cardTemplate.innerHTML = `<div class="video-card">${CARD_TEMPLATE_HTML}</div>`;
|
|
|
|
|
}
|
|
|
|
|
const card = cardTemplate.content.firstElementChild.cloneNode(true);
|
|
|
|
|
cardRefs(card);
|
|
|
|
|
return card;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Tag buttons are the only part whose *count* varies, so they are adjusted
|
|
|
|
|
// rather than rebuilt: usually the card already has the right number.
|
|
|
|
|
const bindTags = function(container, tags) {
|
|
|
|
|
const list = Array.isArray(tags) ? tags.filter((tag) => tag) : [];
|
|
|
|
|
container.hidden = list.length === 0;
|
|
|
|
|
while (container.childElementCount > list.length) {
|
|
|
|
|
container.removeChild(container.lastElementChild);
|
|
|
|
|
}
|
|
|
|
|
while (container.childElementCount < list.length) {
|
|
|
|
|
const button = document.createElement('button');
|
|
|
|
|
button.className = 'video-tag';
|
|
|
|
|
button.type = 'button';
|
|
|
|
|
button.dataset.action = 'tag';
|
|
|
|
|
container.appendChild(button);
|
|
|
|
|
}
|
|
|
|
|
// Only the label is written: the delegated handler reads the tag off
|
|
|
|
|
// the button's own text. Writing it a second time into a data attribute
|
|
|
|
|
// cost more than everything else in a rebind put together -- dataset is
|
|
|
|
|
// a proxy, and this runs once per tag per card.
|
|
|
|
|
list.forEach((tag, index) => {
|
|
|
|
|
const button = container.children[index];
|
|
|
|
|
if (button.textContent !== tag) button.textContent = tag;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Points an existing card at `v`. This is the whole per-mount cost.
|
|
|
|
|
App.videos.bindCard = function(card, v) {
|
|
|
|
|
const refs = cardRefs(card);
|
|
|
|
|
card.dataset.videoId = v.id;
|
|
|
|
|
const durationText = App.videos.formatDuration(v.duration);
|
|
|
|
|
cardVideo.set(card, v);
|
|
|
|
|
|
|
|
|
|
refs.live.hidden = !v.isLive;
|
|
|
|
|
|
|
|
|
|
const favoriteKey = App.favorites.getKey(v);
|
|
|
|
|
refs.favorite.dataset.favKey = favoriteKey || '';
|
|
|
|
|
refs.favorite.dataset.favUrl = v.url || '';
|
|
|
|
|
// By either identity: a favorite imported from a backup is keyed by its
|
|
|
|
|
// URL, not by the id this card carries.
|
|
|
|
|
App.favorites.setButtonState(refs.favorite, !!favoriteKey && App.favorites.has(v));
|
|
|
|
|
|
|
|
|
|
refs.title.textContent = v.title || '';
|
|
|
|
|
|
|
|
|
|
const uploaderText = v.uploader || '';
|
|
|
|
|
const tags = Array.isArray(v.tags) ? v.tags.filter(tag => tag) : [];
|
|
|
|
|
const tagsMarkup = tags.length
|
|
|
|
|
? `<div class="video-tags">${tags.map(tag => `<button class="video-tag" type="button" data-tag="${tag}">${tag}</button>`).join('')}</div>`
|
|
|
|
|
: '';
|
|
|
|
|
const liveBadge = v.isLive ? '<span class="live-badge">● LIVE</span>' : '';
|
|
|
|
|
card.innerHTML = `
|
|
|
|
|
${liveBadge}
|
|
|
|
|
<button class="favorite-btn" type="button" aria-pressed="false" aria-label="Add to favorites" data-fav-key="${favoriteKey || ''}" data-fav-url="${v.url || ''}">♡</button>
|
|
|
|
|
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
|
|
|
|
<div class="video-menu" role="menu">
|
|
|
|
|
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
|
|
|
|
|
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="video-thumb">
|
|
|
|
|
<img alt="${v.title}" loading="lazy" decoding="async">
|
|
|
|
|
<div class="video-loading" aria-hidden="true">
|
|
|
|
|
<div class="video-loading-spinner"></div>
|
|
|
|
|
</div>
|
|
|
|
|
${uploaderText ? `<button class="video-uploader uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button>` : ''}
|
|
|
|
|
${durationText ? `<span class="video-duration">${durationText}</span>` : ''}
|
|
|
|
|
</div>
|
|
|
|
|
<h4 class="video-title"><span class="video-title-text">${v.title}</span></h4>
|
|
|
|
|
${tagsMarkup}
|
|
|
|
|
`;
|
|
|
|
|
const thumb = card.querySelector('img');
|
|
|
|
|
refs.uploader.hidden = !uploaderText;
|
|
|
|
|
refs.uploader.textContent = uploaderText;
|
|
|
|
|
refs.uploader.dataset.uploader = uploaderText;
|
|
|
|
|
|
|
|
|
|
const durationText = App.videos.formatDuration(v.duration);
|
|
|
|
|
refs.duration.hidden = !durationText;
|
|
|
|
|
refs.duration.textContent = durationText;
|
|
|
|
|
|
|
|
|
|
bindTags(refs.tags, v.tags);
|
|
|
|
|
|
|
|
|
|
// Set before attachThumbnail, which holds the caption back until there
|
|
|
|
|
// is an image to caption.
|
|
|
|
|
refs.img.alt = v.title || '';
|
|
|
|
|
return card;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// A card ready to show `v`, thumbnail and all.
|
|
|
|
|
App.videos.buildCard = function(v, options) {
|
|
|
|
|
const card = App.videos.bindCard(createCardShell(), v);
|
|
|
|
|
// The layout probe (see shapeHeight) needs the card's shape, never its
|
|
|
|
|
// pixels: it measures against the CSS 16:9 placeholder and is removed in
|
|
|
|
|
// the same frame, so loading a thumbnail for it -- let alone racing one
|
|
|
|
|
// -- would be pure waste.
|
|
|
|
|
if (!(options && options.skipThumbnail)) {
|
|
|
|
|
App.videos.attachThumbnail(thumb, v.thumb);
|
|
|
|
|
App.videos.attachThumbnail(cardRefs(card).img, v.thumb);
|
|
|
|
|
}
|
|
|
|
|
const favoriteBtn = card.querySelector('.favorite-btn');
|
|
|
|
|
if (favoriteBtn && favoriteKey) {
|
|
|
|
|
// By either identity: a favorite imported from a backup is keyed
|
|
|
|
|
// by its URL, not by the id this card carries.
|
|
|
|
|
App.favorites.setButtonState(favoriteBtn, App.favorites.has(v));
|
|
|
|
|
favoriteBtn.onclick = (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
App.favorites.toggle(v);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const titleWrap = card.querySelector('.video-title');
|
|
|
|
|
const titleText = card.querySelector('.video-title-text');
|
|
|
|
|
if (titleWrap && titleText) {
|
|
|
|
|
card.addEventListener('focusin', () => {
|
|
|
|
|
card.dataset.titleFocused = '1';
|
|
|
|
|
updateTitleActive(card);
|
|
|
|
|
});
|
|
|
|
|
card.addEventListener('focusout', () => {
|
|
|
|
|
card.dataset.titleFocused = '0';
|
|
|
|
|
updateTitleActive(card);
|
|
|
|
|
});
|
|
|
|
|
if (titleEnv.useHoverFocus) {
|
|
|
|
|
card.addEventListener('mouseenter', () => {
|
|
|
|
|
card.dataset.titleHovered = '1';
|
|
|
|
|
updateTitleActive(card);
|
|
|
|
|
});
|
|
|
|
|
card.addEventListener('mouseleave', () => {
|
|
|
|
|
card.dataset.titleHovered = '0';
|
|
|
|
|
updateTitleActive(card);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// On touch devices the marquee observer is attached lazily by
|
|
|
|
|
// measureTitle (called on mount), and only for overflowing titles.
|
|
|
|
|
}
|
|
|
|
|
const uploaderBtn = card.querySelector('.uploader-link');
|
|
|
|
|
if (uploaderBtn) {
|
|
|
|
|
uploaderBtn.onclick = (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
const uploader = uploaderBtn.dataset.uploader || uploaderBtn.textContent || '';
|
|
|
|
|
App.videos.handleSearch(uploader);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const tagButtons = card.querySelectorAll('.video-tag');
|
|
|
|
|
if (tagButtons.length) {
|
|
|
|
|
tagButtons.forEach((tagBtn) => {
|
|
|
|
|
tagBtn.onclick = (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
const tag = tagBtn.dataset.tag || tagBtn.textContent || '';
|
|
|
|
|
App.videos.handleSearch(tag);
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const menuBtn = card.querySelector('.video-menu-btn');
|
|
|
|
|
return card;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Returns a card to a state where it shows nothing and remembers nothing,
|
|
|
|
|
// ready to be bound to another video. Anything left behind here surfaces as
|
|
|
|
|
// one video's content on another video's card.
|
|
|
|
|
App.videos.resetCard = function(card) {
|
|
|
|
|
card.classList.remove('is-loading', 'is-title-active', 'is-revealing', 'is-previewing');
|
|
|
|
|
delete card.dataset.videoId;
|
|
|
|
|
delete card.dataset.titleFocused;
|
|
|
|
|
delete card.dataset.titleHovered;
|
|
|
|
|
delete card.dataset.titlePrimary;
|
|
|
|
|
// The player stamps this to recognise the card it was opened from; a
|
|
|
|
|
// recycled card must stop answering to it.
|
|
|
|
|
delete card.dataset.playerToken;
|
|
|
|
|
|
|
|
|
|
const menu = card.querySelector('.video-menu');
|
|
|
|
|
const showInfoBtn = card.querySelector('.video-menu-item[data-action="info"]');
|
|
|
|
|
const downloadBtn = card.querySelector('.video-menu-item[data-action="download"]');
|
|
|
|
|
if (menuBtn && menu) {
|
|
|
|
|
menuBtn.onclick = (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
App.videos.toggleMenu(menu, menuBtn);
|
|
|
|
|
};
|
|
|
|
|
if (menu) menu.classList.remove('open');
|
|
|
|
|
const menuBtn = card.querySelector('.video-menu-btn');
|
|
|
|
|
if (menuBtn) menuBtn.setAttribute('aria-expanded', 'false');
|
|
|
|
|
|
|
|
|
|
const titleWrap = card.querySelector('.video-title');
|
|
|
|
|
if (titleWrap) titleWrap.classList.remove('has-marquee');
|
|
|
|
|
const titleText = card.querySelector('.video-title-text');
|
|
|
|
|
if (titleText) {
|
|
|
|
|
titleText.style.removeProperty('--marquee-distance');
|
|
|
|
|
titleText.style.removeProperty('--marquee-duration');
|
|
|
|
|
}
|
|
|
|
|
if (showInfoBtn) {
|
|
|
|
|
showInfoBtn.onclick = (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
App.ui.openInfo(v);
|
|
|
|
|
App.videos.closeAllMenus();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// The hover preview (enhance.js) parks a <video> inside the card.
|
|
|
|
|
const preview = card.querySelector('.card-preview');
|
|
|
|
|
if (preview) {
|
|
|
|
|
try { preview.pause(); } catch (err) { /* ignore */ }
|
|
|
|
|
preview.remove();
|
|
|
|
|
}
|
|
|
|
|
if (downloadBtn) {
|
|
|
|
|
downloadBtn.onclick = (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
App.videos.downloadVideo(v);
|
|
|
|
|
App.videos.closeAllMenus();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const img = cardRefs(card).img;
|
|
|
|
|
if (img) {
|
|
|
|
|
// Voids anything still in flight for the old thumbnail -- a pending
|
|
|
|
|
// race, a proxy fallback -- so it cannot land on the next video.
|
|
|
|
|
App.videos.detachThumbnail(img);
|
|
|
|
|
if (img._revealHandler) {
|
|
|
|
|
img.removeEventListener('load', img._revealHandler);
|
|
|
|
|
img._revealHandler = null;
|
|
|
|
|
}
|
|
|
|
|
img.classList.remove('is-loaded');
|
|
|
|
|
img.style.removeProperty('aspect-ratio');
|
|
|
|
|
img.removeAttribute('src');
|
|
|
|
|
img.alt = '';
|
|
|
|
|
}
|
|
|
|
|
card.onclick = () => {
|
|
|
|
|
return card;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// One listener per event type, on the grid, for every card in it.
|
|
|
|
|
let gridDelegated = false;
|
|
|
|
|
|
|
|
|
|
const cardFor = function(target) {
|
|
|
|
|
const card = target && target.closest ? target.closest('.video-card') : null;
|
|
|
|
|
return card && cardVideo.has(card) ? card : null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const bindGridDelegation = function() {
|
|
|
|
|
if (gridDelegated) return;
|
|
|
|
|
const grid = document.getElementById('video-grid');
|
|
|
|
|
if (!grid) return;
|
|
|
|
|
gridDelegated = true;
|
|
|
|
|
|
|
|
|
|
grid.addEventListener('click', (event) => {
|
|
|
|
|
const card = cardFor(event.target);
|
|
|
|
|
if (!card) return;
|
|
|
|
|
const v = cardVideo.get(card);
|
|
|
|
|
const actionEl = event.target.closest('[data-action]');
|
|
|
|
|
const action = actionEl && card.contains(actionEl) ? actionEl.dataset.action : '';
|
|
|
|
|
if (action) {
|
|
|
|
|
// The document-level handler closes every open menu; stopping
|
|
|
|
|
// here is what lets the menu this click just opened stay open.
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
}
|
|
|
|
|
switch (action) {
|
|
|
|
|
case 'favorite':
|
|
|
|
|
App.favorites.toggle(v);
|
|
|
|
|
return;
|
|
|
|
|
case 'uploader':
|
|
|
|
|
App.videos.handleSearch(actionEl.dataset.uploader || actionEl.textContent || '');
|
|
|
|
|
return;
|
|
|
|
|
case 'tag':
|
|
|
|
|
App.videos.handleSearch(actionEl.textContent || '');
|
|
|
|
|
return;
|
|
|
|
|
case 'menu':
|
|
|
|
|
App.videos.toggleMenu(card.querySelector('.video-menu'), actionEl);
|
|
|
|
|
return;
|
|
|
|
|
case 'info':
|
|
|
|
|
App.videos.closeAllMenus();
|
|
|
|
|
App.ui.openInfo(v);
|
|
|
|
|
return;
|
|
|
|
|
case 'download':
|
|
|
|
|
App.videos.closeAllMenus();
|
|
|
|
|
App.videos.downloadVideo(v);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (card.classList.contains('is-loading')) return;
|
|
|
|
|
card.classList.add('is-loading');
|
|
|
|
|
App.player.open(v, { originEl: card });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const setTitleFlag = function(event, flag, value) {
|
|
|
|
|
const card = cardFor(event.target);
|
|
|
|
|
if (!card) return;
|
|
|
|
|
// pointerover/pointerout also fire while moving between a card's own
|
|
|
|
|
// children; only a crossing of the card's own edge counts.
|
|
|
|
|
if (event.relatedTarget && card.contains(event.relatedTarget)) return;
|
|
|
|
|
card.dataset[flag] = value;
|
|
|
|
|
updateTitleActive(card);
|
|
|
|
|
};
|
|
|
|
|
cardVideo.set(card, v);
|
|
|
|
|
card.addEventListener('pointerenter', () => App.videos.ensureFormats(v), { once: true });
|
|
|
|
|
return card;
|
|
|
|
|
|
|
|
|
|
grid.addEventListener('focusin', (event) => setTitleFlag(event, 'titleFocused', '1'));
|
|
|
|
|
grid.addEventListener('focusout', (event) => setTitleFlag(event, 'titleFocused', '0'));
|
|
|
|
|
if (titleEnv.useHoverFocus) {
|
|
|
|
|
// mouseenter/mouseleave don't bubble, so they can't be delegated;
|
|
|
|
|
// pointerover/pointerout do, and the containment check above turns
|
|
|
|
|
// them into the same thing.
|
|
|
|
|
grid.addEventListener('pointerover', (event) => setTitleFlag(event, 'titleHovered', '1'));
|
|
|
|
|
grid.addEventListener('pointerout', (event) => setTitleFlag(event, 'titleHovered', '0'));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Appends a freshly-loaded page of videos. The card DOM is *not* built here;
|
|
|
|
|
@@ -1139,19 +1309,61 @@ App.videos = App.videos || {};
|
|
|
|
|
card.style.width = l.width + 'px';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Cards are pooled, not thrown away: building one costs ~250us of
|
|
|
|
|
// parsing and allocation, rebinding an existing one ~4us, and scrolling
|
|
|
|
|
// a grid does nothing but mount and unmount. Bounded at roughly twice
|
|
|
|
|
// the mounted window so a long session can't grow the pool without end.
|
|
|
|
|
const pool = [];
|
|
|
|
|
const POOL_MAX = 60;
|
|
|
|
|
let builtCards = 0;
|
|
|
|
|
let recycledCards = 0;
|
|
|
|
|
|
|
|
|
|
const acquire = function(v) {
|
|
|
|
|
const card = pool.pop();
|
|
|
|
|
if (!card) {
|
|
|
|
|
builtCards++;
|
|
|
|
|
return App.videos.buildCard(v);
|
|
|
|
|
}
|
|
|
|
|
recycledCards++;
|
|
|
|
|
App.videos.bindCard(card, v);
|
|
|
|
|
App.videos.attachThumbnail(cardRefs(card).img, v.thumb);
|
|
|
|
|
return card;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const release = function(card) {
|
|
|
|
|
resolveObserver.unobserve(card);
|
|
|
|
|
if (titleObserver) {
|
|
|
|
|
titleObserver.unobserve(card);
|
|
|
|
|
titleVisibility.delete(card);
|
|
|
|
|
}
|
|
|
|
|
if (card._revealAnim) {
|
|
|
|
|
card.removeEventListener('animationend', card._revealAnim);
|
|
|
|
|
card._revealAnim = null;
|
|
|
|
|
}
|
|
|
|
|
card.remove();
|
|
|
|
|
cardVideo.delete(card);
|
|
|
|
|
App.videos.resetCard(card);
|
|
|
|
|
if (pool.length < POOL_MAX) pool.push(card);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const mount = function(i) {
|
|
|
|
|
if (mounted.has(i)) return;
|
|
|
|
|
const v = state.loadedVideos[i];
|
|
|
|
|
const l = layout[i];
|
|
|
|
|
if (!v || !l) return;
|
|
|
|
|
const card = App.videos.buildCard(v);
|
|
|
|
|
const card = acquire(v);
|
|
|
|
|
place(card, l);
|
|
|
|
|
// Entrance animation only the first time an index appears, so cards
|
|
|
|
|
// don't re-animate every time they scroll back into the window.
|
|
|
|
|
if (!revealed.has(i)) {
|
|
|
|
|
revealed.add(i);
|
|
|
|
|
card.classList.add('is-revealing');
|
|
|
|
|
card.addEventListener('animationend', () => card.classList.remove('is-revealing'), { once: true });
|
|
|
|
|
// Kept so release can take it off again: a card recycled before
|
|
|
|
|
// the animation ends would otherwise carry the listener for the
|
|
|
|
|
// rest of its life.
|
|
|
|
|
const onRevealEnd = () => card.classList.remove('is-revealing');
|
|
|
|
|
card._revealAnim = onRevealEnd;
|
|
|
|
|
card.addEventListener('animationend', onRevealEnd, { once: true });
|
|
|
|
|
}
|
|
|
|
|
grid().appendChild(card);
|
|
|
|
|
mounted.set(i, card);
|
|
|
|
|
@@ -1160,6 +1372,11 @@ App.videos = App.videos || {};
|
|
|
|
|
const img = card.querySelector('img');
|
|
|
|
|
if (img) {
|
|
|
|
|
const reveal = () => {
|
|
|
|
|
// Release nulls this, so a reveal already queued for a frame
|
|
|
|
|
// that lands after the card was rebound does nothing --
|
|
|
|
|
// covers the requestAnimationFrame path, which unlike the
|
|
|
|
|
// load listener cannot simply be removed.
|
|
|
|
|
if (img._revealHandler !== reveal) return;
|
|
|
|
|
img.style.aspectRatio = 'auto';
|
|
|
|
|
img.classList.add('is-loaded');
|
|
|
|
|
// Remember the real aspect ratio so any later re-pack (see
|
|
|
|
|
@@ -1170,6 +1387,10 @@ App.videos = App.videos || {};
|
|
|
|
|
}
|
|
|
|
|
if (mounted.get(i) === card) correct(i);
|
|
|
|
|
};
|
|
|
|
|
// Held on the element so release can remove it: this closes
|
|
|
|
|
// over `v` and `i`, and a recycled image firing it again would
|
|
|
|
|
// file the new picture's dimensions under the old video's id.
|
|
|
|
|
img._revealHandler = reveal;
|
|
|
|
|
if (img.complete && img.naturalHeight > 0) requestAnimationFrame(reveal);
|
|
|
|
|
else img.addEventListener('load', reveal);
|
|
|
|
|
}
|
|
|
|
|
@@ -1181,14 +1402,8 @@ App.videos = App.videos || {};
|
|
|
|
|
const unmount = function(i) {
|
|
|
|
|
const card = mounted.get(i);
|
|
|
|
|
if (!card) return;
|
|
|
|
|
resolveObserver.unobserve(card);
|
|
|
|
|
if (titleObserver) {
|
|
|
|
|
titleObserver.unobserve(card);
|
|
|
|
|
titleVisibility.delete(card);
|
|
|
|
|
}
|
|
|
|
|
cardVideo.delete(card);
|
|
|
|
|
card.remove();
|
|
|
|
|
mounted.delete(i);
|
|
|
|
|
release(card);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Mounts cards intersecting the viewport window, unmounts the rest.
|
|
|
|
|
@@ -1202,12 +1417,21 @@ App.videos = App.videos || {};
|
|
|
|
|
const end = viewTop + vh * (1 + OVERSCAN);
|
|
|
|
|
let anchorIndex = -1;
|
|
|
|
|
let anchorTop = Infinity;
|
|
|
|
|
// Everything leaving the window is released before anything entering
|
|
|
|
|
// it is mounted, so the cards just freed are available to the cards
|
|
|
|
|
// arriving. Mounting first -- which is what walking the list in
|
|
|
|
|
// index order does when scrolling up -- finds the pool empty and
|
|
|
|
|
// builds a card it was about to be handed.
|
|
|
|
|
const entering = [];
|
|
|
|
|
for (let i = 0; i < layout.length; i++) {
|
|
|
|
|
const l = layout[i];
|
|
|
|
|
if (!l) continue;
|
|
|
|
|
const visible = l.top < end && (l.top + l.height) > start;
|
|
|
|
|
if (visible) mount(i);
|
|
|
|
|
else if (mounted.has(i)) unmount(i);
|
|
|
|
|
if (visible) {
|
|
|
|
|
if (!mounted.has(i)) entering.push(i);
|
|
|
|
|
} else if (mounted.has(i)) {
|
|
|
|
|
unmount(i);
|
|
|
|
|
}
|
|
|
|
|
// Topmost card still crossing the viewport top: the reader's
|
|
|
|
|
// place in the list, kept fresh on every scroll pass (see
|
|
|
|
|
// lastAnchor).
|
|
|
|
|
@@ -1216,6 +1440,7 @@ App.videos = App.videos || {};
|
|
|
|
|
anchorIndex = i;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (let k = 0; k < entering.length; k++) mount(entering[k]);
|
|
|
|
|
// Frozen while a resize settles: the browser moves the scroll
|
|
|
|
|
// position itself during a rotation, and tracking that would replace
|
|
|
|
|
// the reader's real place with wherever the browser landed.
|
|
|
|
|
@@ -1359,6 +1584,7 @@ App.videos = App.videos || {};
|
|
|
|
|
|
|
|
|
|
const ensureInit = function() {
|
|
|
|
|
if (!cols) measureMetrics();
|
|
|
|
|
bindGridDelegation();
|
|
|
|
|
if (initialized) return;
|
|
|
|
|
initialized = true;
|
|
|
|
|
settleWidth = window.innerWidth || 0;
|
|
|
|
|
@@ -1377,6 +1603,8 @@ App.videos = App.videos || {};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const reset = function() {
|
|
|
|
|
// Releases rather than discards: a new result set is about to mount
|
|
|
|
|
// its own cards, and these are exactly the right shape for it.
|
|
|
|
|
mounted.forEach((card, i) => unmount(i));
|
|
|
|
|
mounted.clear();
|
|
|
|
|
revealed.clear(); // new result set should animate in again
|
|
|
|
|
@@ -1384,7 +1612,7 @@ App.videos = App.videos || {};
|
|
|
|
|
colBottoms = [];
|
|
|
|
|
colItems = [];
|
|
|
|
|
const el = grid();
|
|
|
|
|
if (el) { el.innerHTML = ''; el.style.height = '0px'; }
|
|
|
|
|
if (el) { el.style.height = '0px'; }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Removes a single video's card from the grid: its DOM element is
|
|
|
|
|
@@ -1399,7 +1627,11 @@ App.videos = App.videos || {};
|
|
|
|
|
relayout();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return { ensureInit, packFrom, update: scheduleUpdate, relayout, removeVideo, reset, isNearEnd };
|
|
|
|
|
const stats = function() {
|
|
|
|
|
return { built: builtCards, recycled: recycledCards, pooled: pool.length };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return { ensureInit, packFrom, update: scheduleUpdate, relayout, removeVideo, reset, isNearEnd, stats };
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
App.videos.updateLoadMoreState = function() {
|
|
|
|
|
|