A page used to be requested at the moment the reader hit the bottom, so the cards that appeared were empty frames filling in as their thumbnails arrived. Now the next page is fetched as soon as the current one renders and its thumbnails are decoded off-screen (low priority, started on an idle callback, so warming never competes with what's on screen), then held until the second-to-last row comes into view -- at which point the cards appear already finished, and the page after that starts loading. The two loaders are split into fetch-a-batch and commit-a-batch so the prefetcher and the on-demand path share them; a held batch is dropped when the result set changes (search, channel, filters). Three things this surfaced, all handled: loadVideos awaited the in-flight prefetch before raising state.isLoading, so every caller that arrived meanwhile sailed past the guard and started a duplicate page load, each one re-filling the viewport and calling back in. On a short desktop page that amplified until the tab stopped responding. A guard now covers the whole call. The reveal test can't be "the topmost visible card is in the last two rows": a desktop viewport shows several rows at once, so the reader would reach the end of the list without it ever passing. It's now "the second-to-last row has come into view", measured against the layout. The reels feed pulls pages through the same entry point, where the grid's scroll position means nothing -- it (and the Load more button) now pass force, which skips the hold. Verified in headless Chrome: page 2 fetched and all 12 of its thumbnails warmed while it was still hidden, grid still at 12 cards; revealed on the second-to-last row (phone: viewport bottom 4337px vs row at 4335px; desktop 4-col: 1704px vs 1687px) with its images already decoded; feed paging, search reset, rotation, momentum and playback all still good. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
1517 lines
68 KiB
JavaScript
1517 lines
68 KiB
JavaScript
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) {
|
|
if (!card || !card.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;
|
|
const overflow = titleText.scrollWidth - titleWrap.clientWidth;
|
|
if (overflow > 4) {
|
|
card.classList.add('has-marquee');
|
|
const distance = overflow + 12;
|
|
titleText.style.setProperty('--marquee-distance', `${distance}px`);
|
|
// Drive the duration off the distance so every title scrolls at the
|
|
// same gentle speed (px/sec) instead of a fixed duration that made
|
|
// longer titles whip past. A floor keeps short titles from snapping.
|
|
const MARQUEE_SPEED = 28; // px per second
|
|
const MARQUEE_MIN_DURATION = 6; // seconds
|
|
const duration = Math.max(MARQUEE_MIN_DURATION, distance / MARQUEE_SPEED);
|
|
titleText.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
|
|
// 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('has-marquee', 'is-title-active');
|
|
titleText.style.removeProperty('--marquee-distance');
|
|
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 '';
|
|
}
|
|
};
|
|
|
|
App.videos.attachNoReferrerRetry = function(img) {
|
|
if (!img) return;
|
|
if (!img.dataset.originalSrc) {
|
|
img.dataset.originalSrc = img.currentSrc || img.src || '';
|
|
}
|
|
img.dataset.noReferrerRetry = '0';
|
|
img.addEventListener('error', () => {
|
|
if (img.dataset.noReferrerRetry === '1') return;
|
|
img.dataset.noReferrerRetry = '1';
|
|
img.referrerPolicy = 'no-referrer';
|
|
img.removeAttribute('crossorigin');
|
|
const original = img.dataset.originalSrc || img.currentSrc || img.src || '';
|
|
const proxyUrl = App.videos.buildImageProxyUrl(original);
|
|
if (proxyUrl) {
|
|
img.src = proxyUrl;
|
|
} else if (original) {
|
|
img.src = original;
|
|
}
|
|
});
|
|
};
|
|
|
|
// 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) {
|
|
const urls = (items || []).map((v) => v && 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';
|
|
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;
|
|
};
|
|
|
|
// 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);
|
|
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();
|
|
}
|
|
};
|
|
|
|
// 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) {
|
|
const favoritesSet = App.favorites.getSet();
|
|
const card = document.createElement('div');
|
|
card.className = 'video-card';
|
|
card.dataset.videoId = v.id;
|
|
const durationText = App.videos.formatDuration(v.duration);
|
|
const favoriteKey = App.favorites.getKey(v);
|
|
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 || ''}">♡</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 src="${v.thumb}" 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');
|
|
App.videos.attachNoReferrerRetry(thumb);
|
|
const favoriteBtn = card.querySelector('.favorite-btn');
|
|
if (favoriteBtn && favoriteKey) {
|
|
App.favorites.setButtonState(favoriteBtn, favoritesSet.has(favoriteKey));
|
|
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');
|
|
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 (showInfoBtn) {
|
|
showInfoBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
App.ui.showInfo(v);
|
|
App.videos.closeAllMenus();
|
|
};
|
|
}
|
|
if (downloadBtn) {
|
|
downloadBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
App.videos.downloadVideo(v);
|
|
App.videos.closeAllMenus();
|
|
};
|
|
}
|
|
card.onclick = () => {
|
|
if (card.classList.contains('is-loading')) return;
|
|
card.classList.add('is-loading');
|
|
App.player.open(v, { originEl: card });
|
|
};
|
|
cardVideo.set(card, v);
|
|
card.addEventListener('pointerenter', () => App.videos.resolveAndProbe(v), { once: true });
|
|
return card;
|
|
};
|
|
|
|
// Appends a freshly-loaded page of videos. The card DOM is *not* built here;
|
|
// we only grow the data buffer, extend the masonry layout for the new
|
|
// items, then let the virtualizer mount whatever currently falls inside the
|
|
// viewport window.
|
|
App.videos.renderVideos = function(videos) {
|
|
const grid = document.getElementById('video-grid');
|
|
if (!grid) return;
|
|
App.virtualGrid.ensureInit();
|
|
|
|
const items = videos && Array.isArray(videos.items) ? videos.items : [];
|
|
const startLen = state.loadedVideos.length;
|
|
items.forEach((v) => {
|
|
if (state.renderedVideoIds.has(v.id)) return;
|
|
state.renderedVideoIds.add(v.id);
|
|
state.loadedVideos.push(v);
|
|
});
|
|
if (state.loadedVideos.length > startLen) {
|
|
App.virtualGrid.packFrom(startLen);
|
|
}
|
|
App.virtualGrid.update();
|
|
|
|
if (App.feed && typeof App.feed.renderSlides === 'function') {
|
|
App.feed.renderSlides();
|
|
}
|
|
App.videos.ensureViewportFilled();
|
|
};
|
|
|
|
// Finds whichever rendered video card is closest to the viewport's
|
|
// vertical center, used to open feed mode on the video the user is
|
|
// currently looking at instead of always starting from the top.
|
|
App.videos.getFocusedVideoId = function() {
|
|
const grid = document.getElementById('video-grid');
|
|
if (!grid) return null;
|
|
const cards = Array.from(grid.querySelectorAll('.video-card'));
|
|
if (!cards.length) return null;
|
|
|
|
const viewportCenter = window.innerHeight / 2;
|
|
let best = null;
|
|
let bestDistance = Infinity;
|
|
cards.forEach((card) => {
|
|
const rect = card.getBoundingClientRect();
|
|
if (rect.bottom <= 0 || rect.top >= window.innerHeight) return;
|
|
const distance = Math.abs((rect.top + rect.height / 2) - viewportCenter);
|
|
if (distance < bestDistance) {
|
|
bestDistance = distance;
|
|
best = card;
|
|
}
|
|
});
|
|
|
|
return (best || cards[0]).dataset.videoId || null;
|
|
};
|
|
|
|
App.videos.handleSearch = function(value) {
|
|
if (typeof value === 'string') {
|
|
const searchInput = document.getElementById('search-input');
|
|
if (searchInput && searchInput.value !== value) {
|
|
searchInput.value = value;
|
|
}
|
|
// Keep the clear button in sync without re-dispatching an `input`
|
|
// event (which would re-trigger the debounced reload listener).
|
|
const clearBtn = document.getElementById('search-clear-btn');
|
|
if (searchInput && clearBtn) {
|
|
const hasValue = searchInput.value.trim().length > 0;
|
|
clearBtn.classList.toggle('is-visible', hasValue);
|
|
clearBtn.disabled = !hasValue;
|
|
}
|
|
}
|
|
// The held/in-flight page belongs to the old result set.
|
|
App.videos.resetPrefetch();
|
|
state.currentPage = 1;
|
|
state.hasNextPage = true;
|
|
state.renderedVideoIds.clear();
|
|
state.loadedVideos = [];
|
|
state.groupCursors = null;
|
|
App.virtualGrid.reset();
|
|
if (App.feed && typeof App.feed.reset === 'function') {
|
|
App.feed.reset();
|
|
}
|
|
App.videos.updateLoadMoreState();
|
|
App.videos.loadVideos();
|
|
};
|
|
|
|
App.videos.resetAndReload = function() {
|
|
if (state.currentLoadController) {
|
|
state.currentLoadController.abort();
|
|
state.currentLoadController = null;
|
|
state.isLoading = false;
|
|
}
|
|
// The held/in-flight page belongs to the old result set.
|
|
App.videos.resetPrefetch();
|
|
state.currentPage = 1;
|
|
state.hasNextPage = true;
|
|
state.renderedVideoIds.clear();
|
|
state.loadedVideos = [];
|
|
state.groupCursors = null;
|
|
App.virtualGrid.reset();
|
|
if (App.feed && typeof App.feed.reset === 'function') {
|
|
App.feed.reset();
|
|
}
|
|
App.videos.updateLoadMoreState();
|
|
App.videos.loadVideos();
|
|
};
|
|
|
|
App.videos.ensureViewportFilled = function() {
|
|
if (!state.hasNextPage || state.isLoading) return;
|
|
const grid = document.getElementById('video-grid');
|
|
if (!grid) return;
|
|
const docHeight = document.documentElement.scrollHeight;
|
|
if (docHeight <= window.innerHeight + 120) {
|
|
window.setTimeout(() => App.videos.loadVideos(), 0);
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Virtualized masonry grid
|
|
//
|
|
// The full set of loaded videos lives in state.loadedVideos. Only the cards
|
|
// whose computed position falls within the viewport (plus an overscan
|
|
// buffer) are kept in the DOM; the rest are unmounted. The container is
|
|
// given an explicit pixel height and every card is absolutely positioned at
|
|
// a precomputed (top,left), so:
|
|
// * the scrollbar and scroll position are identical to a fully-rendered
|
|
// grid, and
|
|
// * mounting/unmounting a card never moves any other card -- positions are
|
|
// assigned once and never change -- so there is no scroll jank.
|
|
//
|
|
// Thumbnails keep their natural aspect ratio (not cropped), so a card's real
|
|
// height isn't known until its image loads. We place each card with a 16:9
|
|
// estimate first, then once the image loads we correct that card's height
|
|
// and shift only the cards below it *in the same column* (each column is an
|
|
// independent vertical stack), so a correction never disturbs other columns
|
|
// or anything above it. Columns are assigned once and never change.
|
|
// ---------------------------------------------------------------------
|
|
App.virtualGrid = (function() {
|
|
const mounted = new Map(); // loadedVideos index -> card element
|
|
const revealed = new Set(); // indices that have played their entrance once
|
|
const layout = []; // index -> { top, left, width, height, col, posInCol }
|
|
const heightCache = new Map(); // shape signature -> estimated px height
|
|
const aspectCache = new Map(); // video id -> thumbnail width/height, once loaded
|
|
let colItems = []; // col -> ordered list of item indices in that column
|
|
let colBottoms = []; // running bottom y of each column
|
|
let cols = 0, colWidth = 0, gap = 16, padX = 24, padY = 24;
|
|
let initialized = false;
|
|
let rafPending = false;
|
|
let lastAnchor = null; // reader's place, refreshed on every scroll pass
|
|
let heldAnchor = null; // anchor frozen while a resize settles
|
|
const OVERSCAN = 1.2; // viewports of cards kept mounted off-screen
|
|
|
|
const grid = () => document.getElementById('video-grid');
|
|
|
|
const measureMetrics = function() {
|
|
const el = grid();
|
|
if (!el) return false;
|
|
const phone = window.matchMedia('(max-width: 480px)').matches;
|
|
const mobile = window.matchMedia('(max-width: 768px)').matches;
|
|
const large = window.matchMedia('(min-width: 1600px)').matches;
|
|
gap = mobile ? 12 : (large ? 24 : 16);
|
|
padX = mobile ? 16 : (large ? 48 : 24);
|
|
padY = mobile ? 16 : (large ? 32 : 24);
|
|
// We own positioning, so neutralize the CSS grid + padding and read
|
|
// the resulting content width (which still honors max-width:auto
|
|
// centering).
|
|
el.style.display = 'block';
|
|
el.style.position = 'relative';
|
|
el.style.padding = '0';
|
|
const inner = el.clientWidth - padX * 2;
|
|
if (inner <= 0) return false;
|
|
// Density toggle (see enhance.js / settings) tunes the minimum card
|
|
// width, so "compact" packs more columns at the same viewport width.
|
|
// The user's Card Size setting scales that minimum on top of density.
|
|
const cardScale = (App.storage && App.storage.getCardScale) ? App.storage.getCardScale() : 1;
|
|
const minCardW = (document.body.dataset.density === 'compact' ? 210 : 260) * cardScale;
|
|
// One card per row on phones, two on larger handsets/tablets, and a
|
|
// size-driven count on desktop.
|
|
cols = phone ? 1 : (mobile ? 2 : Math.max(1, Math.floor((inner + gap) / (minCardW + gap))));
|
|
colWidth = (inner - gap * (cols - 1)) / cols;
|
|
return true;
|
|
};
|
|
|
|
const signatureOf = function(v) {
|
|
const hasTags = Array.isArray(v.tags) && v.tags.some((t) => t);
|
|
return [Math.round(colWidth), v.isLive ? 1 : 0, hasTags ? 1 : 0,
|
|
v.uploader ? 1 : 0, (v.duration > 0) ? 1 : 0].join('|');
|
|
};
|
|
|
|
// Height of a card of `v`'s shape at the current column width, using the
|
|
// CSS 16:9 thumbnail placeholder (the image isn't loaded in the probe).
|
|
// Measured once per shape, then cached.
|
|
const shapeHeight = function(v) {
|
|
const sig = signatureOf(v);
|
|
const cached = heightCache.get(sig);
|
|
if (cached != null) return cached;
|
|
const el = grid();
|
|
if (!el) return 240;
|
|
const probe = App.videos.buildCard(v);
|
|
probe.style.position = 'absolute';
|
|
probe.style.visibility = 'hidden';
|
|
probe.style.left = '-99999px';
|
|
probe.style.top = '0';
|
|
probe.style.width = colWidth + 'px';
|
|
el.appendChild(probe);
|
|
const h = probe.getBoundingClientRect().height;
|
|
el.removeChild(probe);
|
|
heightCache.set(sig, h || 240);
|
|
return h || 240;
|
|
};
|
|
|
|
// Estimated height of item `v`. Once a thumbnail has loaded we know its
|
|
// real aspect ratio (recorded in aspectCache by mount()), which is
|
|
// column-width independent -- so we swap the shape probe's 16:9
|
|
// placeholder for the real thumbnail height instead of falling back to
|
|
// the 16:9 guess. That matters most on a re-pack (orientation change):
|
|
// without it every card above the viewport reverts to a guess, and the
|
|
// corrections trickling back in as images reload drag the page out from
|
|
// under the reader. The per-card correct() below still fixes whatever is
|
|
// left (title wrapping, tag rows).
|
|
const heightOf = function(v) {
|
|
const base = shapeHeight(v);
|
|
const aspect = (v && v.id != null) ? aspectCache.get(String(v.id)) : null;
|
|
if (!aspect) return base;
|
|
return Math.max(1, base - colWidth * 9 / 16 + colWidth / aspect);
|
|
};
|
|
|
|
const setContainerHeight = function() {
|
|
const el = grid();
|
|
if (!el) return;
|
|
const maxBottom = colBottoms.length ? Math.max.apply(null, colBottoms) : padY;
|
|
el.style.height = Math.max(0, maxBottom - gap + padY) + 'px';
|
|
};
|
|
|
|
// Assigns positions to items [start, end). Earlier items keep their
|
|
// positions because colBottoms carries forward unchanged. Each item is
|
|
// assigned to the currently-shortest column and stays there for good.
|
|
const packFrom = function(start) {
|
|
if (!cols) { if (!measureMetrics()) return; }
|
|
if (!colBottoms.length) colBottoms = new Array(cols).fill(padY);
|
|
if (!colItems.length) colItems = Array.from({ length: cols }, () => []);
|
|
for (let i = start; i < state.loadedVideos.length; i++) {
|
|
const v = state.loadedVideos[i];
|
|
const h = heightOf(v);
|
|
let col = 0;
|
|
for (let c = 1; c < cols; c++) {
|
|
if (colBottoms[c] < colBottoms[col]) col = c;
|
|
}
|
|
const top = colBottoms[col];
|
|
layout[i] = {
|
|
top,
|
|
left: padX + col * (colWidth + gap),
|
|
width: colWidth,
|
|
height: h,
|
|
col,
|
|
posInCol: colItems[col].length
|
|
};
|
|
colItems[col].push(i);
|
|
colBottoms[col] = top + h + gap;
|
|
}
|
|
setContainerHeight();
|
|
};
|
|
|
|
// Replaces item i's estimated height with its real (post-image-load)
|
|
// height and slides every card below it in the same column by the delta.
|
|
// Other columns and everything above are untouched -> no global reflow.
|
|
const correct = function(i) {
|
|
const card = mounted.get(i);
|
|
const l = layout[i];
|
|
if (!card || !l) return;
|
|
const real = card.getBoundingClientRect().height;
|
|
if (!real || Math.abs(real - l.height) < 1) return;
|
|
const delta = real - l.height;
|
|
l.height = real;
|
|
const list = colItems[l.col];
|
|
for (let k = l.posInCol + 1; k < list.length; k++) {
|
|
const j = list[k];
|
|
layout[j].top += delta;
|
|
const mc = mounted.get(j);
|
|
if (mc) mc.style.top = layout[j].top + 'px';
|
|
}
|
|
colBottoms[l.col] += delta;
|
|
setContainerHeight();
|
|
scheduleUpdate();
|
|
};
|
|
|
|
const place = function(card, l) {
|
|
card.style.position = 'absolute';
|
|
card.style.top = l.top + 'px';
|
|
card.style.left = l.left + 'px';
|
|
card.style.width = l.width + 'px';
|
|
};
|
|
|
|
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);
|
|
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 });
|
|
}
|
|
grid().appendChild(card);
|
|
mounted.set(i, card);
|
|
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
|
|
// its true aspect ratio, clear the shimmer, then correct the height.
|
|
const img = card.querySelector('img');
|
|
if (img) {
|
|
const reveal = () => {
|
|
img.style.aspectRatio = 'auto';
|
|
img.classList.add('is-loaded');
|
|
// Remember the real aspect ratio so any later re-pack (see
|
|
// heightOf) can place this card at its true height straight
|
|
// away instead of re-guessing 16:9.
|
|
if (v.id != null && img.naturalWidth && img.naturalHeight) {
|
|
aspectCache.set(String(v.id), img.naturalWidth / img.naturalHeight);
|
|
}
|
|
if (mounted.get(i) === card) correct(i);
|
|
};
|
|
if (img.complete && img.naturalHeight > 0) requestAnimationFrame(reveal);
|
|
else img.addEventListener('load', reveal);
|
|
}
|
|
// Marquee + direct-playability probe only matter for on-screen cards.
|
|
requestAnimationFrame(() => { if (mounted.get(i) === card) measureTitle(card); });
|
|
probeObserver.observe(card);
|
|
};
|
|
|
|
const unmount = function(i) {
|
|
const card = mounted.get(i);
|
|
if (!card) return;
|
|
probeObserver.unobserve(card);
|
|
if (titleObserver) {
|
|
titleObserver.unobserve(card);
|
|
titleVisibility.delete(card);
|
|
}
|
|
cardVideo.delete(card);
|
|
card.remove();
|
|
mounted.delete(i);
|
|
};
|
|
|
|
// Mounts cards intersecting the viewport window, unmounts the rest.
|
|
const update = function() {
|
|
const el = grid();
|
|
if (!el || !state.loadedVideos.length) return;
|
|
const rectTop = el.getBoundingClientRect().top; // container top vs viewport
|
|
const vh = window.innerHeight || 800;
|
|
const viewTop = -rectTop; // viewport top in container space
|
|
const start = viewTop - vh * OVERSCAN;
|
|
const end = viewTop + vh * (1 + OVERSCAN);
|
|
let anchorIndex = -1;
|
|
let anchorTop = Infinity;
|
|
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);
|
|
// Topmost card still crossing the viewport top: the reader's
|
|
// place in the list, kept fresh on every scroll pass (see
|
|
// lastAnchor).
|
|
if (l.top + l.height > viewTop && l.top < anchorTop) {
|
|
anchorTop = l.top;
|
|
anchorIndex = i;
|
|
}
|
|
}
|
|
// 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.
|
|
if (!heldAnchor) {
|
|
lastAnchor = anchorIndex >= 0 ? { index: anchorIndex, offsetTop: anchorTop - viewTop } : null;
|
|
}
|
|
// Hand the prefetched page over once the reader reaches the
|
|
// second-to-last row: it was fetched and its thumbnails decoded a
|
|
// page ago, so the new cards appear finished rather than loading.
|
|
if (App.videos.releasePrefetched && isNearEnd(viewTop, vh)) {
|
|
App.videos.releasePrefetched();
|
|
}
|
|
};
|
|
|
|
// True once the second-to-last row has come into view. Deliberately not
|
|
// "the topmost visible card is in the last two rows": a desktop viewport
|
|
// shows several rows at once, so by that measure the reader would hit the
|
|
// bottom of the list without the test ever passing.
|
|
const isNearEnd = function(viewTop, vh) {
|
|
const el = grid();
|
|
if (!el || !cols || !layout.length) return false;
|
|
if (viewTop == null) viewTop = -el.getBoundingClientRect().top;
|
|
if (vh == null) vh = window.innerHeight || 800;
|
|
let tailTop = Infinity;
|
|
for (let i = Math.max(0, layout.length - cols * 2); i < layout.length; i++) {
|
|
const l = layout[i];
|
|
if (l && l.top < tailTop) tailTop = l.top;
|
|
}
|
|
return tailTop !== Infinity && viewTop + vh >= tailTop;
|
|
};
|
|
|
|
const scheduleUpdate = function() {
|
|
if (rafPending) return;
|
|
rafPending = true;
|
|
requestAnimationFrame(() => { rafPending = false; update(); });
|
|
};
|
|
|
|
// Full re-pack + remount, used when the column geometry changes.
|
|
const relayout = function() {
|
|
if (!measureMetrics()) return;
|
|
// Every card is about to move, so the raw scroll position stops
|
|
// meaning anything: hold the reader's place and put them back on it
|
|
// once the new positions exist. Applies to every re-pack, not just
|
|
// rotation -- changing card size or density moves the grid too.
|
|
const anchor = heldAnchor || lastAnchor;
|
|
heightCache.clear(); // colWidth changed -> heights differ
|
|
mounted.forEach((card, i) => unmount(i));
|
|
layout.length = 0;
|
|
colBottoms = new Array(cols).fill(padY);
|
|
colItems = Array.from({ length: cols }, () => []);
|
|
packFrom(0);
|
|
restoreAnchor(anchor);
|
|
update();
|
|
};
|
|
|
|
// Scrolls so the anchored card sits at the same viewport offset it had
|
|
// before the re-pack, keeping the user's place across the layout change.
|
|
// The anchor is the topmost card crossing the viewport top (rather than
|
|
// the centred one), which is independent of the viewport height -- and
|
|
// that height has already changed by the time we hear about a rotation.
|
|
const restoreAnchor = function(anchor) {
|
|
if (!anchor) return;
|
|
const el = grid();
|
|
const l = layout[anchor.index];
|
|
if (!el || !l) return;
|
|
const gridTopDoc = el.getBoundingClientRect().top + window.scrollY;
|
|
const target = gridTopDoc + l.top - anchor.offsetTop;
|
|
const clamped = Math.max(0, Math.min(target,
|
|
Math.max(0, document.documentElement.scrollHeight - (window.innerHeight || 0))));
|
|
if (Math.abs(clamped - window.scrollY) > 1) window.scrollTo(0, clamped);
|
|
};
|
|
|
|
// A rotation re-packs every column, so the raw scrollTop afterwards
|
|
// points at a different video. Restoring the anchor once, in the frame
|
|
// after `resize`, isn't enough on a phone: the browser fires `resize`
|
|
// partway through the rotation animation (with metrics that are still
|
|
// changing) and then adjusts the scroll position itself *after* our
|
|
// handler has run, overwriting our restore. So we hold the anchor taken
|
|
// *before* the change and re-assert it across a short settling window,
|
|
// re-packing on each tick only if the column geometry actually moved.
|
|
// The window ends early the moment the user scrolls, so we never fight
|
|
// a real gesture.
|
|
const SETTLE_TICKS = [0, 60, 150, 300, 500];
|
|
let settleTimers = [];
|
|
|
|
const endSettle = function() {
|
|
settleTimers.forEach(clearTimeout);
|
|
settleTimers = [];
|
|
heldAnchor = null;
|
|
};
|
|
|
|
const settleTick = function() {
|
|
const prevCols = cols;
|
|
const prevWidth = colWidth;
|
|
if (measureMetrics() && (cols !== prevCols || Math.abs(colWidth - prevWidth) > 0.5)) {
|
|
relayout(); // re-packs and restores the anchor
|
|
}
|
|
if (heldAnchor) restoreAnchor(heldAnchor);
|
|
};
|
|
|
|
// A soft keyboard opening also fires `resize` (and moves the scroll
|
|
// position deliberately, to reveal the focused field): leave that alone.
|
|
const isTypingTarget = function() {
|
|
const el = document.activeElement;
|
|
if (!el) return false;
|
|
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable;
|
|
};
|
|
|
|
let settleWidth = 0;
|
|
|
|
const beginSettle = function(event) {
|
|
if (isTypingTarget()) return;
|
|
// Only a width change moves cards: their positions are absolute
|
|
// pixels in the grid's own space, so a height-only resize -- the
|
|
// mobile URL bar collapsing as you scroll, most of all -- leaves the
|
|
// layout (and therefore the scroll position) exactly right. Settling
|
|
// on those was actively harmful: a URL-bar resize landing mid-flick
|
|
// re-asserted an anchor captured before the flick and yanked the
|
|
// page back, killing the momentum scroll.
|
|
const width = window.innerWidth || 0;
|
|
const widthChanged = width !== settleWidth;
|
|
settleWidth = width;
|
|
const rotating = !!(event && event.type === 'orientationchange');
|
|
if (!widthChanged && !rotating) {
|
|
// One exception: a scrollbar appearing/disappearing changes the
|
|
// grid's inner width while window.innerWidth stays put. Re-pack
|
|
// if the columns really moved (relayout keeps the reader's
|
|
// place); otherwise leave the scroll completely alone.
|
|
const prevCols = cols;
|
|
const prevColWidth = colWidth;
|
|
if (measureMetrics() && (cols !== prevCols || Math.abs(colWidth - prevColWidth) > 0.5)) {
|
|
relayout();
|
|
}
|
|
return;
|
|
}
|
|
if (!heldAnchor) heldAnchor = lastAnchor;
|
|
settleTimers.forEach(clearTimeout);
|
|
settleTimers = SETTLE_TICKS.map((ms) => setTimeout(settleTick, ms));
|
|
settleTimers.push(setTimeout(endSettle, SETTLE_TICKS[SETTLE_TICKS.length - 1] + 100));
|
|
};
|
|
|
|
const ensureInit = function() {
|
|
if (!cols) measureMetrics();
|
|
if (initialized) return;
|
|
initialized = true;
|
|
settleWidth = window.innerWidth || 0;
|
|
window.addEventListener('scroll', scheduleUpdate, { passive: true });
|
|
// orientationchange fires first (before the viewport metrics change),
|
|
// which is exactly when the pre-rotation anchor is still valid.
|
|
window.addEventListener('orientationchange', beginSettle);
|
|
window.addEventListener('resize', beginSettle);
|
|
// Deliberately not on visualViewport: that also fires for pinch-zoom
|
|
// and keyboard insets, where pinning the scroll position would fight
|
|
// the user. A rotation always fires the window events above.
|
|
// Any real scroll gesture (including a pinch) ends the window early.
|
|
['wheel', 'touchmove', 'keydown'].forEach((evt) => {
|
|
window.addEventListener(evt, endSettle, { passive: true });
|
|
});
|
|
};
|
|
|
|
const reset = function() {
|
|
mounted.forEach((card, i) => unmount(i));
|
|
mounted.clear();
|
|
revealed.clear(); // new result set should animate in again
|
|
layout.length = 0;
|
|
colBottoms = [];
|
|
colItems = [];
|
|
const el = grid();
|
|
if (el) { el.innerHTML = ''; el.style.height = '0px'; }
|
|
};
|
|
|
|
// Removes a single video's card from the grid: its DOM element is
|
|
// unmounted directly (so it's gone even if the re-pack below can't run),
|
|
// then the remaining cards are re-packed against the now-shorter queue.
|
|
// The caller must have already removed the video from state.loadedVideos.
|
|
const removeVideo = function(videoId) {
|
|
const id = String(videoId);
|
|
mounted.forEach((card, i) => {
|
|
if (card.dataset.videoId === id) unmount(i);
|
|
});
|
|
relayout();
|
|
};
|
|
|
|
return { ensureInit, packFrom, update: scheduleUpdate, relayout, removeVideo, reset, isNearEnd };
|
|
})();
|
|
|
|
App.videos.updateLoadMoreState = function() {
|
|
const loadMoreBtn = document.getElementById('load-more-btn');
|
|
if (!loadMoreBtn) return;
|
|
loadMoreBtn.disabled = state.isLoading || !state.hasNextPage;
|
|
loadMoreBtn.style.display = state.hasNextPage ? 'flex' : 'none';
|
|
};
|
|
|
|
// Context menu helpers for per-card actions.
|
|
App.videos.closeAllMenus = function() {
|
|
document.querySelectorAll('.video-menu.open').forEach((menu) => {
|
|
menu.classList.remove('open');
|
|
});
|
|
document.querySelectorAll('.video-menu-btn[aria-expanded="true"]').forEach((btn) => {
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
});
|
|
};
|
|
|
|
App.videos.toggleMenu = function(menu, button) {
|
|
const isOpen = menu.classList.contains('open');
|
|
App.videos.closeAllMenus();
|
|
if (!isOpen) {
|
|
menu.classList.add('open');
|
|
if (button) {
|
|
button.setAttribute('aria-expanded', 'true');
|
|
}
|
|
}
|
|
};
|
|
|
|
App.videos.coerceNumber = function(value) {
|
|
if (value === null || value === undefined) return 0;
|
|
if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
|
|
if (typeof value === 'string') {
|
|
const parsed = parseFloat(value);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
const headerValue = function(headers, name) {
|
|
if (!headers) return '';
|
|
return headers[name] || headers[name.toLowerCase()] || '';
|
|
};
|
|
|
|
// Merge the resource-level (meta) and format-level http_headers into a single
|
|
// map so every upstream header the extractor attached (Referer, User-Agent,
|
|
// Cookie, etc.) can be relayed to the stream proxy. Format-level headers win
|
|
// on conflict since they describe the specific media URL.
|
|
const mergeHeaders = function(metaHeaders, fmtHeaders) {
|
|
const merged = {};
|
|
[metaHeaders, fmtHeaders].forEach((headers) => {
|
|
if (!headers || typeof headers !== 'object') return;
|
|
Object.keys(headers).forEach((name) => {
|
|
const value = headers[name];
|
|
if (value === undefined || value === null || value === '') return;
|
|
merged[name] = String(value);
|
|
});
|
|
});
|
|
return merged;
|
|
};
|
|
|
|
const deriveReferer = function(url) {
|
|
if (!url) return '';
|
|
try {
|
|
return `${new URL(url).origin}/`;
|
|
} catch (err) {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
// Ranks the playable formats best-first so callers can fall back to the
|
|
// next candidate when a URL fails. Quality is the primary key; when several
|
|
// formats share the same quality the one that appears later in the source
|
|
// list is preferred (start with the last one). When a preferred height is
|
|
// set, formats at or below it come first (best of those first), followed by
|
|
// anything above it ordered closest-to-preferred first as a last resort.
|
|
App.videos.rankFormats = function(formats, preferredHeight) {
|
|
if (!Array.isArray(formats) || formats.length === 0) return [];
|
|
const candidates = formats
|
|
.map((fmt, index) => ({ fmt, index }))
|
|
.filter((entry) => entry.fmt && entry.fmt.url);
|
|
if (!candidates.length) return [];
|
|
const videoCandidates = candidates.filter((entry) => {
|
|
const videoExt = String(entry.fmt.video_ext || '').toLowerCase();
|
|
const vcodec = String(entry.fmt.vcodec || '').toLowerCase();
|
|
if (videoExt && videoExt !== 'none') return true;
|
|
if (vcodec && vcodec !== 'none') return true;
|
|
return false;
|
|
});
|
|
const pool = videoCandidates.length ? videoCandidates : candidates;
|
|
const score = (fmt) => {
|
|
const height = App.videos.coerceNumber(fmt.height || fmt.quality);
|
|
const width = App.videos.coerceNumber(fmt.width);
|
|
const size = height || width;
|
|
const bitrate = App.videos.coerceNumber(fmt.tbr || fmt.bitrate);
|
|
const fps = App.videos.coerceNumber(fmt.fps);
|
|
return [size, bitrate, fps];
|
|
};
|
|
// Tie-break on the original index so equal-quality formats start with
|
|
// the last one in the source list.
|
|
const compare = (a, b, descending) => {
|
|
const sa = score(a.fmt);
|
|
const sb = score(b.fmt);
|
|
for (let i = 0; i < sa.length; i++) {
|
|
if (sa[i] !== sb[i]) return descending ? sb[i] - sa[i] : sa[i] - sb[i];
|
|
}
|
|
return b.index - a.index;
|
|
};
|
|
if (preferredHeight) {
|
|
const atOrBelow = [];
|
|
const above = [];
|
|
pool.forEach((entry) => {
|
|
const size = score(entry.fmt)[0];
|
|
if (size > 0 && size <= preferredHeight) {
|
|
atOrBelow.push(entry);
|
|
} else {
|
|
above.push(entry);
|
|
}
|
|
});
|
|
atOrBelow.sort((a, b) => compare(a, b, true));
|
|
above.sort((a, b) => compare(a, b, false));
|
|
return atOrBelow.concat(above).map((entry) => entry.fmt);
|
|
}
|
|
return pool.slice().sort((a, b) => compare(a, b, true)).map((entry) => entry.fmt);
|
|
};
|
|
|
|
App.videos.pickBestFormat = function(formats, preferredHeight) {
|
|
const ranked = App.videos.rankFormats(formats, preferredHeight);
|
|
return ranked.length ? ranked[0] : null;
|
|
};
|
|
|
|
// Resolves an ordered list of stream source candidates (best first). The
|
|
// player walks this list and falls back to the next entry when a URL fails.
|
|
App.videos.resolveStreamSources = function(videoOrUrl, options) {
|
|
const applyPreferredQuality = !options || options.applyPreferredQuality !== false;
|
|
const isLive = !!(videoOrUrl && typeof videoOrUrl === 'object' &&
|
|
(videoOrUrl.isLive || (videoOrUrl.meta && videoOrUrl.meta.isLive)));
|
|
if (typeof videoOrUrl === 'string') {
|
|
return videoOrUrl ? [{ url: videoOrUrl, referer: deriveReferer(videoOrUrl), userAgent: '', isLive, refererRequired: false }] : [];
|
|
}
|
|
if (!videoOrUrl || typeof videoOrUrl !== 'object') return [];
|
|
|
|
const meta = videoOrUrl.meta || videoOrUrl;
|
|
const metaReferer = headerValue(meta.http_headers, 'Referer');
|
|
const metaUserAgent = headerValue(meta.http_headers, 'User-Agent');
|
|
let preferredHeight = null;
|
|
if (applyPreferredQuality) {
|
|
const preferredQuality = App.storage.getPreferredQuality();
|
|
preferredHeight = preferredQuality === 'auto' ? null : App.videos.coerceNumber(preferredQuality);
|
|
}
|
|
|
|
const sources = App.videos.rankFormats(meta.formats, preferredHeight).map((fmt) => {
|
|
// An *explicit* Referer (from the extractor) signals the upstream
|
|
// enforces it; deriveReferer is only a best-effort fallback. The
|
|
// browser can't set a cross-origin Referer, so refererRequired tells
|
|
// callers (the probe) that direct playback can't work.
|
|
const explicitReferer = headerValue(fmt.http_headers, 'Referer') || metaReferer;
|
|
const referer = explicitReferer || deriveReferer(fmt.url);
|
|
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
|
|
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
|
|
return { url: fmt.url, referer, userAgent, headers, isLive, refererRequired: !!explicitReferer };
|
|
});
|
|
|
|
if (!sources.length) {
|
|
const fallbackUrl = meta.url || videoOrUrl.url || '';
|
|
if (fallbackUrl) {
|
|
sources.push({
|
|
url: fallbackUrl,
|
|
referer: metaReferer || deriveReferer(fallbackUrl),
|
|
userAgent: metaUserAgent,
|
|
headers: mergeHeaders(meta.http_headers, null),
|
|
isLive,
|
|
refererRequired: !!metaReferer
|
|
});
|
|
}
|
|
}
|
|
return sources;
|
|
};
|
|
|
|
App.videos.resolveStreamSource = function(videoOrUrl, options) {
|
|
const sources = App.videos.resolveStreamSources(videoOrUrl, options);
|
|
return sources.length ? sources[0] : { url: '', referer: '', userAgent: '', isLive: false, refererRequired: false };
|
|
};
|
|
|
|
// Resolves one specific format (as picked from a format-switcher menu, see
|
|
// App.customPlayer.buildFormatOptions) into a playable source, using the
|
|
// same header-merging rules as the automatic ranked path above.
|
|
App.videos.resolveSourceForFormat = function(videoOrUrl, fmt) {
|
|
if (!fmt || !fmt.url) return null;
|
|
const isLive = !!(videoOrUrl && typeof videoOrUrl === 'object' &&
|
|
(videoOrUrl.isLive || (videoOrUrl.meta && videoOrUrl.meta.isLive)));
|
|
const meta = (videoOrUrl && typeof videoOrUrl === 'object' && (videoOrUrl.meta || videoOrUrl)) || {};
|
|
const metaReferer = headerValue(meta.http_headers, 'Referer');
|
|
const metaUserAgent = headerValue(meta.http_headers, 'User-Agent');
|
|
const explicitReferer = headerValue(fmt.http_headers, 'Referer') || metaReferer;
|
|
const referer = explicitReferer || deriveReferer(fmt.url);
|
|
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
|
|
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
|
|
return { url: fmt.url, referer, userAgent, headers, isLive, refererRequired: !!explicitReferer };
|
|
};
|
|
|
|
// Background "direct playability" probe. The backend proxy exists to work
|
|
// around CORS, hotlink (403) protection, and TLS fingerprinting. When the
|
|
// browser can fetch a media URL cross-origin and actually read the response
|
|
// (CORS allowed, not blocked/403), playing it directly works and the proxy
|
|
// is pure overhead. CORS is an origin-level policy, so the answer is the
|
|
// same for every media URL served by a given host: we probe (and cache)
|
|
// once per host and let the player skip the proxy for any URL on a host
|
|
// that's been proven.
|
|
const DIRECT_PROBE_TIMEOUT_MS = 8000;
|
|
|
|
const directHostOf = (url) => {
|
|
try { return new URL(url).host; } catch (err) { return ''; }
|
|
};
|
|
|
|
// host -> true (proven directly playable) | false (proven not). Absent
|
|
// means unknown/unprobed, in which case the proxy is used.
|
|
App.videos._directStatus = new Map();
|
|
const directPending = new Map();
|
|
|
|
App.videos.isDirectProven = function(url) {
|
|
return App.videos._directStatus.get(directHostOf(url)) === true;
|
|
};
|
|
|
|
App.videos.probeDirect = function(url) {
|
|
if (!url) return Promise.resolve(false);
|
|
const host = directHostOf(url);
|
|
if (!host) return Promise.resolve(false);
|
|
if (App.videos._directStatus.has(host)) {
|
|
return Promise.resolve(App.videos._directStatus.get(host));
|
|
}
|
|
if (directPending.has(host)) {
|
|
return directPending.get(host);
|
|
}
|
|
const promise = (async () => {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), DIRECT_PROBE_TIMEOUT_MS);
|
|
let ok = false;
|
|
let detail = '';
|
|
try {
|
|
// A simple GET (no custom headers) avoids a CORS preflight. If
|
|
// the response is readable and successful, CORS + reachability
|
|
// are both proven; we abort immediately so the body isn't
|
|
// downloaded (it can be a whole video file).
|
|
const res = await fetch(url, {
|
|
method: 'GET',
|
|
mode: 'cors',
|
|
credentials: 'omit',
|
|
signal: controller.signal
|
|
});
|
|
ok = res.ok || res.status === 206;
|
|
detail = `HTTP ${res.status}`;
|
|
controller.abort();
|
|
} catch (err) {
|
|
ok = false;
|
|
detail = (err && err.name === 'AbortError') ? 'timeout' : (err && err.message) || 'fetch failed';
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
App.videos._directStatus.set(host, ok);
|
|
directPending.delete(host);
|
|
console.log(`[direct-probe] ${ok ? 'DIRECT' : 'PROXY '} (${detail}) ${host}`);
|
|
return ok;
|
|
})();
|
|
directPending.set(host, promise);
|
|
return promise;
|
|
};
|
|
|
|
// Kicks off a background probe of a video's best (first-played) source so a
|
|
// later playback can skip the proxy if its host is proven reachable. Only
|
|
// runs once the video has resolved formats (see resolveAndProbe): those are
|
|
// real media URLs (or redirects to them), whereas a bare listing item only
|
|
// carries a page URL that the player can't use directly.
|
|
App.videos.probeVideoSources = function(video) {
|
|
if (!video || typeof video !== 'object') return;
|
|
const meta = video.meta || video;
|
|
if (!meta || !Array.isArray(meta.formats) || !meta.formats.length) return;
|
|
let sources;
|
|
try {
|
|
sources = App.videos.resolveStreamSources(video);
|
|
} catch (err) {
|
|
return;
|
|
}
|
|
const best = sources && sources[0];
|
|
if (!best || !best.url || best.isLive) return;
|
|
// Sources that require a specific upstream Referer can't be fetched
|
|
// directly by the browser (it can't forge a cross-origin Referer), so a
|
|
// probe would always fail -- leave them to the proxy.
|
|
if (best.refererRequired) return;
|
|
App.videos.probeDirect(best.url);
|
|
};
|
|
|
|
// Listing items arrive without formats (meta is null) -- only a page URL --
|
|
// so there's nothing direct-playable to probe up front. This resolves a
|
|
// video's real media formats via the backend (yt-dlp), attaches them as
|
|
// `video.meta` so the player and probe can use them, then probes the best
|
|
// source. Resolution is per-video and deduped: it runs at most once per
|
|
// video, triggered lazily by hover/scroll so we don't resolve cards the
|
|
// user never looks at.
|
|
const cardVideo = new WeakMap();
|
|
|
|
// Session cache of `/api/resolve` results, keyed by video id (falling back
|
|
// to the page URL). Keyed rather than per-object so any *other* object
|
|
// describing the same video -- a favorites entry rebuilt from localStorage,
|
|
// a feed slide, a re-rendered card -- gets the formats attached too instead
|
|
// of silently skipping the fetch because some earlier object already ran it.
|
|
// Failures resolve to null and are cached the same way, so a broken video is
|
|
// attempted once per session.
|
|
const metaCache = new Map();
|
|
|
|
const hasFormats = (meta) => !!(meta && Array.isArray(meta.formats) && meta.formats.length);
|
|
|
|
// Resolves a video's real media formats (once per session) and attaches them
|
|
// as `video.meta`. Returns the resolved meta, or null when it can't be had.
|
|
App.videos.ensureFormats = function(video) {
|
|
if (!video || typeof video !== 'object') return Promise.resolve(null);
|
|
if (hasFormats(video.meta)) return Promise.resolve(video.meta);
|
|
const cacheKey = video.id || video.url;
|
|
if (!cacheKey || !video.url) return Promise.resolve(null);
|
|
|
|
let promise = metaCache.get(cacheKey);
|
|
if (!promise) {
|
|
promise = (async () => {
|
|
try {
|
|
const response = await fetch('/api/resolve', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url: video.url })
|
|
});
|
|
if (!response.ok) return null;
|
|
const data = await response.json();
|
|
return hasFormats(data) ? data : null;
|
|
} catch (err) {
|
|
// Best-effort: playback still works through the proxy.
|
|
return null;
|
|
}
|
|
})();
|
|
metaCache.set(cacheKey, promise);
|
|
}
|
|
return promise.then((data) => {
|
|
if (data) video.meta = data;
|
|
return data;
|
|
});
|
|
};
|
|
|
|
const probeObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach((entry) => {
|
|
if (!entry.isIntersecting) return;
|
|
probeObserver.unobserve(entry.target);
|
|
const video = cardVideo.get(entry.target);
|
|
if (video) App.videos.resolveAndProbe(video);
|
|
});
|
|
}, { rootMargin: '200px' });
|
|
|
|
App.videos.resolveAndProbe = function(video) {
|
|
if (!video || typeof video !== 'object') return Promise.resolve();
|
|
return App.videos.ensureFormats(video).then((meta) => {
|
|
if (meta) App.videos.probeVideoSources(video);
|
|
});
|
|
};
|
|
|
|
// Builds a proxied stream URL. Extra params other than `url` are forwarded
|
|
// by the backend as request headers, so use real header names here.
|
|
App.videos.buildStreamUrlFromSource = function(resolved) {
|
|
if (!resolved || !resolved.url) return '';
|
|
const params = [];
|
|
// Referer keeps its dedicated lowercase param (the backend maps it back to
|
|
// `Referer`) so the derived-referer fallback in resolveStreamSources is
|
|
// honoured even when no explicit Referer header was present.
|
|
if (resolved.referer) params.push(`referer=${encodeURIComponent(resolved.referer)}`);
|
|
if (resolved.userAgent) params.push(`User-Agent=${encodeURIComponent(resolved.userAgent)}`);
|
|
// Relay every other upstream header the extractor attached (e.g. Cookie).
|
|
// Referer/User-Agent are already emitted above, so skip them here to avoid
|
|
// duplicating the same header under two query keys.
|
|
const headers = resolved.headers;
|
|
if (headers && typeof headers === 'object') {
|
|
Object.keys(headers).forEach((name) => {
|
|
const lower = name.toLowerCase();
|
|
if (lower === 'referer' || lower === 'user-agent') return;
|
|
const value = headers[name];
|
|
if (value === undefined || value === null || value === '') return;
|
|
params.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
|
|
});
|
|
}
|
|
if (resolved.isLive) params.push('live=1');
|
|
const query = params.length ? `&${params.join('&')}` : '';
|
|
return `/api/stream?url=${encodeURIComponent(resolved.url)}${query}`;
|
|
};
|
|
|
|
App.videos.buildStreamUrl = function(videoOrUrl, options) {
|
|
return App.videos.buildStreamUrlFromSource(App.videos.resolveStreamSource(videoOrUrl, options));
|
|
};
|
|
|
|
// Lets enhancement layers (e.g. hover preview) recover the video object that
|
|
// backs a mounted card without reaching into the virtualizer internals.
|
|
App.videos.getVideoForCard = function(card) {
|
|
return cardVideo.get(card);
|
|
};
|
|
|
|
App.videos.downloadVideo = function(video) {
|
|
if (!video) return;
|
|
const streamUrl = App.videos.buildStreamUrl(video, { applyPreferredQuality: false });
|
|
if (!streamUrl) return;
|
|
const link = document.createElement('a');
|
|
link.href = streamUrl;
|
|
const rawName = (video.title || video.id || 'video').toString();
|
|
const safeName = rawName.replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').slice(0, 80);
|
|
link.download = safeName ? `${safeName}.mp4` : 'video.mp4';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
};
|
|
})();
|