window.App = window.App || {}; App.videos = App.videos || {}; (function() { const state = App.state; const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) App.videos.loadVideos(); }, { threshold: 1.0 }); const titleEnv = { useHoverFocus: window.matchMedia('(hover: hover) and (pointer: fine)').matches }; const titleVisibility = new Map(); let titleObserver = null; if (!titleEnv.useHoverFocus) { titleObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { titleVisibility.set(entry.target, entry.intersectionRatio || 0); } else { titleVisibility.delete(entry.target); entry.target.dataset.titlePrimary = '0'; updateTitleActive(entry.target); } }); let topCard = null; let topRatio = 0; titleVisibility.forEach((ratio, card) => { if (ratio > topRatio) { topRatio = ratio; topCard = card; } }); titleVisibility.forEach((ratio, card) => { card.dataset.titlePrimary = card === topCard && ratio >= 0.55 ? '1' : '0'; updateTitleActive(card); }); }, { threshold: [0, 0.25, 0.55, 0.8, 1.0] }); } App.videos.observeSentinel = function() { const sentinel = document.getElementById('sentinel'); if (sentinel) { observer.observe(sentinel); } }; const updateTitleActive = function(card) { const titleWrap = card && card.querySelector('.video-title'); if (!titleWrap || !titleWrap.classList.contains('has-marquee')) { if (card) card.classList.remove('is-title-active'); return; } const hovered = card.dataset.titleHovered === '1'; const focused = card.dataset.titleFocused === '1'; const primary = card.dataset.titlePrimary === '1'; const active = titleEnv.useHoverFocus ? (hovered || focused) : (focused || primary); card.classList.toggle('is-title-active', active); }; const measureTitle = function(card) { if (!card) return; const titleWrap = card.querySelector('.video-title'); const titleText = card.querySelector('.video-title-text'); if (!titleWrap || !titleText) return; if (App.marquee.measure(titleWrap, titleText)) { // Only marquee cards need the scroll-position observer that picks the // centered card to animate; observing every card made scrolling a // large grid needlessly expensive. if (titleObserver) titleObserver.observe(card); } else { card.classList.remove('is-title-active'); if (titleObserver) { titleObserver.unobserve(card); titleVisibility.delete(card); } } updateTitleActive(card); }; let titleMeasureRaf = null; const scheduleTitleMeasure = function() { if (titleMeasureRaf) return; titleMeasureRaf = requestAnimationFrame(() => { titleMeasureRaf = null; document.querySelectorAll('.video-card').forEach((card) => { measureTitle(card); }); // The virtualizer re-packs and remounts on resize via its own // listener; nothing else to do here. }); }; window.addEventListener('resize', scheduleTitleMeasure); App.videos.formatDuration = function(seconds) { if (!seconds || seconds <= 0) return ''; const totalSeconds = Math.floor(seconds); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const secs = totalSeconds % 60; if (hours > 0) { return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; } if (minutes > 0) { return `${minutes}:${String(secs).padStart(2, '0')}`; } return `${secs}`; }; App.videos.buildImageProxyUrl = function(imageUrl) { if (!imageUrl) return ''; try { return `/api/image?url=${encodeURIComponent(imageUrl)}`; } catch (err) { return ''; } }; // --------------------------------------------------------------------- // Thumbnails // // A thumbnail can come from two places: the provider's own CDN, or our // /api/image proxy. Neither is reliably the faster one -- the CDN is a hop // closer, but plenty of them hotlink-block or rate-limit, and learning that // used to cost a whole failed request before the proxy was even asked. That // serial retry is the wait worth removing. // // So the first thumbnail from a host is raced: both requests go out at once // and whichever answers first is the one displayed. The winner is then // remembered per host -- hotlink and CORS policy are origin-level, the same // assumption the direct-playability probe below makes -- so every later // thumbnail from that host goes straight down the route that already // worked. One race per host, not one per image: racing every card would // double the image traffic of a whole grid to learn something we already // know by the second card. // --------------------------------------------------------------------- const IMAGE_DIRECT = 'direct'; const IMAGE_PROXY = 'proxy'; 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 // optimistic route is how the old serial retry hurt: on a host that blocks // us, each card paid its own failed request before asking the proxy. So // while a host is being decided the rest of its images wait for the answer // -- at most as long as the fastest route takes -- and then load once, the // right way round. If the race somehow stalls they go anyway. const RACE_PATIENCE_MS = 2500; // Direct is the route we'd rather settle on, so when the proxy comes home // first the held images give direct this much longer to answer before // committing to the proxy. Long enough that a provider merely a little // slower than same-origin still wins its hosts; short enough that one which // hangs -- the case this whole thing exists for -- doesn't hold up a grid. const DIRECT_GRACE_MS = 200; const imageHostOf = function(url) { try { return new URL(url, window.location.href).host; } catch (err) { return ''; } }; // Where a thumbnail from this host should be fetched from. Answers with the // provider while the host is still unknown -- the optimistic route, and the // one a race starts on anyway. App.videos.thumbnailUrl = function(url) { if (!url) return ''; return imageRoutes.get(imageHostOf(url)) === IMAGE_PROXY ? (App.videos.buildImageProxyUrl(url) || url) : url; }; // A src-less counts as "unavailable", and the browser paints its alt // text across the thumbnail box. Since a thumbnail now waits for its host's // route before it gets a src, the caption is held back in a data attribute // 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. // `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; } img.src = url; }; // 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, token) { if (!proxyUrl) return; // Checked here too, not just in showThumbnail: this *replaces* whatever // fallback the image currently has, so a call arriving for a generation // the element has moved past would take away the live one and leave a // dead one -- the recycled card's thumbnail would then have no fallback // at all if it failed. if (token !== undefined && img.dataset.thumbToken !== token) return; // 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 // the race told us nothing (both routes failed, or it stalled) -- in which // case they take the optimistic route with the proxy behind it, exactly as // an undecided host used to. const releaseWaiting = function(host, route) { const waiting = imageWaiting.get(host); if (!waiting) return; imageWaiting.delete(host); waiting.forEach((entry) => { if (route === IMAGE_PROXY) { showThumbnail(entry.img, entry.proxyUrl, entry.token); return; } if (route !== IMAGE_DIRECT) attachProxyFallback(entry.img, entry.proxyUrl, entry.token); showThumbnail(entry.img, entry.directUrl, entry.token); }); }; // Two questions, and they don't have the same answer: // // which is quicker *now* -> what this image should display // does direct work at all -> what the host is remembered as // // The proxy often wins the first question purely because it's same-origin: // the browser already holds that connection, while the provider costs a // fresh DNS lookup and TLS handshake. That says nothing about the provider, // and pinning a host to the proxy over it would push every thumbnail on the // page through our own server for no reason. So speed decides the pixels, // and capability decides the memory: direct is remembered whenever it works // at all, because it costs no server hop. // // Both routes are therefore fetched off-screen, and the visible image is // 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, token) { let shown = false; let outstanding = 2; let routeFinal = false; // the direct verdict is in; nothing can revise it let abandoned = false; // took too long; a later race owns the host now let directSettled = false; let patience = null; let grace = null; const show = function(url) { if (shown) return; shown = true; 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 // direct verdict is final; a route taken because direct was too slow to // wait for is not, so a direct probe that comes home late still upgrades // the host rather than leaving it on the proxy for the whole session. // // Crucially this happens the moment direct answers, not when both probes // have finished: every thumbnail attached in the meantime is queued on // exactly this answer, and making them wait on the *other* probe too // leaves them blank for no reason. const settleRoute = function(route, final) { if (abandoned || routeFinal) return; routeFinal = !!final; if (final && patience) { clearTimeout(patience); patience = null; } imageRoutes.set(host, route); imageRacing.delete(host); releaseWaiting(host, route); }; // Nothing usable came home in time. Show whatever the host settled on -- // broken rather than blank, as it would have been without any of this -- // and keep the proxy behind an untested direct. const giveUp = function() { if (shown) return; shown = true; if (imageRoutes.get(host) === IMAGE_PROXY) { showThumbnail(img, proxyUrl, token); return; } attachProxyFallback(img, proxyUrl, token); showThumbnail(img, directUrl, token); }; const decide = function() { if (--outstanding > 0) return; if (patience) { clearTimeout(patience); patience = null; } if (grace) { clearTimeout(grace); grace = null; } giveUp(); // no-op if either route came home }; // Off-screen, and low priority: neither may take bandwidth from anything // the reader is already looking at. const newProbe = function() { const image = new Image(); image.decoding = 'async'; image.fetchPriority = 'low'; return image; }; const directProbe = newProbe(); directProbe.referrerPolicy = 'no-referrer'; // The direct verdict alone decides the route -- either way round. One // probe discovers the host's answer and every held image acts on it, // instead of each rediscovering it at the cost of its own request. directProbe.onload = function() { directSettled = true; if (grace) { clearTimeout(grace); grace = null; } show(directUrl); settleRoute(IMAGE_DIRECT, true); decide(); }; directProbe.onerror = function() { directSettled = true; if (grace) { clearTimeout(grace); grace = null; } // Direct is out for this host, so the proxy is the answer even if it // hasn't reported yet -- there is nothing else left to be. settleRoute(IMAGE_PROXY, true); decide(); }; const proxyProbe = newProbe(); proxyProbe.onload = function() { show(proxyUrl); // first one home gets the pixels on screen // Don't strand anything behind a provider that may never answer: // give it the grace window, then take the route that works. Marked // provisional, so a slow-but-working provider still wins its host // when it finally reports. if (!directSettled && !grace) { grace = setTimeout(function() { grace = null; settleRoute(IMAGE_PROXY, false); }, DIRECT_GRACE_MS); } decide(); }; proxyProbe.onerror = function() { decide(); }; imageRacing.add(host); // A probe that never answers -- a hung connection rather than a refused // one -- must not leave the host mid-race forever, with every later // thumbnail queueing behind a decision that will never come. Once that // happens this race stops touching the shared maps entirely: the next // thumbnail starts a fresh one, and a late answer here must not reach in // and overwrite what *that* race decides. patience = setTimeout(function() { patience = null; if (grace) { clearTimeout(grace); grace = null; } if (!routeFinal) { abandoned = true; imageRacing.delete(host); releaseWaiting(host, imageRoutes.get(host) || null); } giveUp(); }, RACE_PATIENCE_MS); directProbe.src = directUrl; proxyProbe.src = proxyUrl; }; // Points `img` at `url` by whichever route is known to work for its host, // racing the two the first time that host is seen. App.videos.attachThumbnail = function(img, url) { const directUrl = url || (img && img.dataset.thumb) || ''; if (!img) return; // Held back until there is an image to caption -- see showThumbnail. An // item with no thumbnail keeps an empty alt: the card's own title sits // directly beneath the box, so there is nothing for it to add. if (img.alt && img.dataset.alt === undefined) { img.dataset.alt = img.alt; img.alt = ''; } if (!directUrl) return; // A cross-origin Referer is what most hotlink protection keys on, and an // image needs none. Sending none is what lets the direct route work at // all on a fair number of providers -- and the direct route is the one // that costs us no server hop. img.referrerPolicy = 'no-referrer'; 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, token); return; } if (imageRacing.has(host)) { // A race is already deciding for this host. Wait for it rather than // 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, 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, token); showThumbnail(img, directUrl, token); return; } 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 // page, so a small per-channel count keeps any one channel from // dominating a single interleaved batch. const GROUP_CHANNEL_PAGE_SIZE = 4; // Fetches one page from every channel in a group and zips the results // together round-robin so the feed alternates between sources instead of // running through one channel's videos before moving to the next. const fetchGroupBatch = async function(session, signal) { const group = session.channel; const searchInput = document.getElementById('search-input'); const query = searchInput ? searchInput.value : ""; // Honor the per-group "Channels" multi-select filter so disabled // channels are skipped. Falls back to the full group when nothing is // selected (e.g. older sessions without the filter). const selectedChannels = session.options ? session.options[App.session.GROUP_CHANNELS_OPTION_ID] : null; const enabledIds = (Array.isArray(selectedChannels) && selectedChannels.length > 0 ? selectedChannels.map((opt) => opt.id) : group.channelIds).filter((id) => group.channelIds.includes(id)); const signature = enabledIds.join(','); if (!state.groupCursors || state.groupCursors.groupId !== group.id || state.groupCursors.query !== query || state.groupCursors.signature !== signature) { state.groupCursors = { groupId: group.id, query: query, signature: signature, channels: enabledIds.map((id) => ({ id, page: 1, hasNextPage: true })) }; } const active = state.groupCursors.channels.filter((cursor) => cursor.hasNextPage); if (active.length === 0) { return { items: [], hasNextPage: false }; } const results = await Promise.all(active.map(async (cursor) => { const response = await fetch('/api/videos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel: cursor.id, query: query || "", page: cursor.page, perPage: GROUP_CHANNEL_PAGE_SIZE, server: session.server }), signal: signal }); const data = await response.json(); const items = data && Array.isArray(data.items) ? data.items : []; cursor.page++; cursor.hasNextPage = items.length > 0 && (data && data.pageInfo ? data.pageInfo.hasNextPage !== false : true); return items; })); const interleaved = []; const maxLen = results.reduce((max, items) => Math.max(max, items.length), 0); for (let i = 0; i < maxLen; i++) { results.forEach((items) => { if (items[i]) interleaved.push(items[i]); }); } return { items: interleaved, hasNextPage: state.groupCursors.channels.some((cursor) => cursor.hasNextPage) }; }; const fetchChannelBatch = async function(session, signal) { const searchInput = document.getElementById('search-input'); const query = searchInput ? searchInput.value : ""; const body = { channel: session.channel.id, query: query || "", page: state.currentPage, perPage: state.perPage, server: session.server }; Object.entries(session.options).forEach(([key, value]) => { if (Array.isArray(value)) { body[key] = value.map((entry) => entry.id).join(", "); } else if (value && value.id) { body[key] = value.id; } }); const response = await fetch('/api/videos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: signal }); const videos = await response.json(); state.currentPage++; return { items: videos && Array.isArray(videos.items) ? videos.items : [], hasNextPage: videos && videos.pageInfo ? videos.pageInfo.hasNextPage !== false : true }; }; const fetchNextBatch = function(session, signal) { return session.channel.isGroup ? fetchGroupBatch(session, signal) : fetchChannelBatch(session, signal); }; // --------------------------------------------------------------------- // Next-page prefetch // // The page after the visible one is fetched, and its thumbnails decoded, // as soon as the current one renders -- but it is held back until the // reader reaches the second-to-last row (see the virtualizer's update()). // So the cards that come into view are complete on arrival instead of // shimmering their way in while the request is still on the wire. // --------------------------------------------------------------------- const prefetch = { batch: null, // fetched + warmed, waiting for the reader inFlight: null, // promise of the fetch currently running controller: null }; // One slow thumbnail must not hold a whole page back. const WARM_TIMEOUT_MS = 8000; const idle = function(fn) { if (typeof requestIdleCallback === 'function') requestIdleCallback(fn, { timeout: 1000 }); else setTimeout(fn, 0); }; const warmThumbnails = function(items) { // Warm down the route the host has already settled on -- warming a URL // the cards won't ask for would leave them waiting anyway. const urls = (items || []) .map((v) => v && App.videos.thumbnailUrl(v.thumb)) .filter(Boolean); if (!urls.length) return Promise.resolve(); const loads = urls.map((url) => new Promise((resolve) => { // Off-screen and not needed yet: low priority, started when the // browser is idle, so warming a page the reader hasn't reached // never competes with the cards (or requests) in front of them. idle(() => { const img = new Image(); img.decoding = 'async'; img.fetchPriority = 'low'; // Same terms the card and the probe fetch on. Warming with a // Referer the real request won't send would warm the wrong // thing: on a hotlink-protecting host it earns a 403, which is // both a wasted warm and a cached refusal the probe may then be // handed -- pinning a host to the proxy that works direct. img.referrerPolicy = 'no-referrer'; img.onload = resolve; img.onerror = resolve; // the card's own retry handles failures img.src = url; }); })); return Promise.race([ Promise.all(loads), new Promise((resolve) => setTimeout(resolve, WARM_TIMEOUT_MS)) ]); }; const commitBatch = function(batch) { if (!batch) return; App.videos.renderVideos({ items: batch.items }); state.hasNextPage = batch.hasNextPage; App.videos.updateLoadMoreState(); App.videos.ensureViewportFilled(); App.videos.prefetchNextBatch(); // stay one page ahead again }; App.videos.prefetchNextBatch = function() { if (prefetch.batch || prefetch.inFlight) return Promise.resolve(); if (!state.hasNextPage) return Promise.resolve(); const session = App.storage.getSession(); if (!session || !session.channel) return Promise.resolve(); prefetch.controller = new AbortController(); const signal = prefetch.controller.signal; prefetch.inFlight = (async () => { try { const batch = await fetchNextBatch(session, signal); await warmThumbnails(batch.items); if (!signal.aborted) prefetch.batch = batch; } catch (err) { if (err.name !== 'AbortError') { console.error("Failed to prefetch videos:", err); } } finally { prefetch.inFlight = null; prefetch.controller = null; } })(); return prefetch.inFlight; }; // Throws away a video's resolved formats and asks the server again. Media // URLs are commonly signed with an expiry (`?secure=-`), so // formats resolved earlier -- in a long-open tab, or held in the session // cache -- eventually start returning 403 even though nothing is wrong with // the video itself. The player calls this once before giving up. App.videos.refreshFormats = function(video) { if (!video || typeof video !== 'object') return Promise.resolve(null); const cacheKey = video.id || video.url; if (cacheKey) metaCache.delete(cacheKey); video.meta = null; return App.videos.ensureFormats(video); }; // Called by the virtualizer once the reader is within two rows of the end. App.videos.releasePrefetched = function() { if (!prefetch.batch || state.isLoading) return false; const batch = prefetch.batch; prefetch.batch = null; commitBatch(batch); return true; }; // Drops anything in flight or held, for when the result set changes out // from under it (new search, channel, or filters). App.videos.resetPrefetch = function() { if (prefetch.controller) prefetch.controller.abort(); prefetch.batch = null; prefetch.inFlight = null; prefetch.controller = null; }; // Is the reader actually out of content? True when nothing is rendered yet, // when the page is too short to scroll, or when they're already in the last // two rows. Anything else -- the sentinel firing from a stale observation at // startup, most of all -- means the prefetched page stays held. const needsContentNow = function() { if (!state.loadedVideos.length) return true; const docHeight = document.documentElement.scrollHeight; if (docHeight <= (window.innerHeight || 0) + 120) return true; return !!(App.virtualGrid && App.virtualGrid.isNearEnd && App.virtualGrid.isNearEnd()); }; // Guards the whole of loadVideos, including the awaits before the fetch // starts. state.isLoading can't do this job: it is the UI's "a request is // out" signal and is only raised around the fetch itself, so every caller // that arrived while we were waiting on the prefetch would sail past it and // start a duplicate page load -- each of which renders, refills the // viewport, and calls back in here. On a short desktop page that amplifies // until the tab stops responding. let loadRunning = false; // Renders the next page: the prefetched one when it's ready (the common // case), otherwise fetched here and rendered as it lands. `opts.force` is for // callers that know they need content and can't be judged by where the grid // is scrolled -- the reels feed running out of slides, or the Load more // button being pressed. App.videos.loadVideos = async function(opts) { const force = !!(opts && opts.force); // The favorites grid pages out of localStorage, not the server, but // rides the same sentinel and load-more button to get there. if (App.favoritesView && App.favoritesView.isActive()) { App.favoritesView.loadNext(); return; } const session = App.storage.getSession(); if (!session || !session.channel) return; if (loadRunning || state.isLoading) return; loadRunning = true; try { if (prefetch.batch || prefetch.inFlight) { // A page is already here or on its way; revealing it before the // reader gets near the end would defeat the point of holding it. if (!force && !needsContentNow()) return; if (App.videos.releasePrefetched()) return; if (prefetch.inFlight) { // Wait for it rather than asking for the same page twice -- // the fetch advances the page cursor, so a second request // here would skip a page entirely. await prefetch.inFlight; if (App.videos.releasePrefetched()) return; } } if (!state.hasNextPage) return; state.isLoading = true; App.videos.updateLoadMoreState(); state.currentLoadController = new AbortController(); const batch = await fetchNextBatch(session, state.currentLoadController.signal); state.isLoading = false; commitBatch(batch); } catch (err) { if (err.name !== 'AbortError') { console.error("Failed to load videos:", err); } } finally { loadRunning = false; state.isLoading = false; state.currentLoadController = null; App.videos.updateLoadMoreState(); } }; // --------------------------------------------------------------------- // 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 = `

`; // 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 = `
${CARD_TEMPLATE_HTML}
`; } 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; 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 || ''; 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(cardRefs(card).img, v.thumb); } 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; // Added by favorites.toggle and normally taken off by animationend -- // which never fires here, because release() detaches the card first and // a detached element runs no animations. Left on, the next video this // card shows replays a "just favourited" pop nobody asked for. const favorite = cardRefs(card).favorite; if (favorite) favorite.classList.remove('just-favorited'); const menu = card.querySelector('.video-menu'); 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'); } // The hover preview (enhance.js) parks a