1630 lines
74 KiB
JavaScript
1630 lines
74 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) {
|
|
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 '';
|
|
}
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
// Throws away a video's resolved formats and asks the server again. Media
|
|
// URLs are commonly signed with an expiry (`?secure=<unix ts>-<token>`), 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();
|
|
}
|
|
};
|
|
|
|
// 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 || ''}" data-fav-url="${v.url || ''}">♡</button>
|
|
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
|
<div class="video-menu" role="menu">
|
|
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
|
|
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
|
</div>
|
|
<div class="video-thumb">
|
|
<img 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.openInfo(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;
|
|
}
|
|
}
|
|
// A search is a new result set, so it leaves the favorites grid.
|
|
if (App.favoritesView && App.favoritesView.isActive()) {
|
|
App.favoritesView.close({ silent: true });
|
|
}
|
|
App.videos.resetGrid();
|
|
App.videos.loadVideos();
|
|
};
|
|
|
|
// Empties the grid back to "nothing loaded yet", without deciding what
|
|
// fills it next -- the caller does that.
|
|
App.videos.resetGrid = function() {
|
|
// 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.resetAndReload = function() {
|
|
if (state.currentLoadController) {
|
|
state.currentLoadController.abort();
|
|
state.currentLoadController = null;
|
|
state.isLoading = false;
|
|
}
|
|
// Switching source/channel/filters means leaving the favorites grid.
|
|
if (App.favoritesView && App.favoritesView.isActive()) {
|
|
App.favoritesView.close({ silent: true });
|
|
}
|
|
App.videos.resetGrid();
|
|
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);
|
|
// `protocol` is the extractor's own word for how this format is
|
|
// delivered ('https', 'm3u8_native', ...). Carrying it through lets
|
|
// the player skip its content-type sniff -- a full round trip
|
|
// through the proxy -- for URLs whose extension gives nothing away.
|
|
return {
|
|
url: fmt.url, referer, userAgent, headers, isLive,
|
|
refererRequired: !!explicitReferer,
|
|
protocol: fmt.protocol || ''
|
|
};
|
|
});
|
|
|
|
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,
|
|
protocol: fmt.protocol || ''
|
|
};
|
|
};
|
|
|
|
// How does this source play -- an HLS manifest, or a media file the <video>
|
|
// element can take directly? Answered from the strongest evidence at hand:
|
|
// a live stream is always a manifest here, then the extractor's `protocol`,
|
|
// then the URL's extension. When none of them says (a signed CDN link with
|
|
// no extension and no protocol), the caller is left to sniff the content
|
|
// type over the network, which is why `protocol` is worth carrying around.
|
|
App.videos.classifySource = function(resolved) {
|
|
const url = (resolved && resolved.url) || '';
|
|
let isHls = /\.m3u8($|\?)/i.test(url);
|
|
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(url);
|
|
const protocol = String((resolved && resolved.protocol) || '').toLowerCase();
|
|
if (protocol.indexOf('m3u8') >= 0) {
|
|
isHls = true;
|
|
isDirectMedia = false;
|
|
} else if (protocol === 'https' || protocol === 'http') {
|
|
// A plain HTTP(S) download: one file, played as-is. Anything else
|
|
// yt-dlp names (http_dash_segments, ism, ...) stays unknown here.
|
|
isDirectMedia = true;
|
|
}
|
|
if (resolved && resolved.isLive) {
|
|
isHls = true;
|
|
isDirectMedia = false;
|
|
}
|
|
return { isHls, isDirectMedia };
|
|
};
|
|
|
|
// 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.
|
|
// Mirrors the backend's is_direct_media(): a URL whose path (query ignored,
|
|
// trailing slash tolerated) already names a media file.
|
|
const DIRECT_MEDIA_RE = /\.(mp4|m4v|m4s|webm|mov|ts|m3u8|mpd)$/i;
|
|
|
|
const isDirectMediaUrl = function(url) {
|
|
if (!url) return false;
|
|
try {
|
|
return DIRECT_MEDIA_RE.test(new URL(url, window.location.href).pathname.replace(/\/+$/, ''));
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
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);
|
|
|
|
// Some channels hand back the media URL itself as the item URL. Sending
|
|
// that to /api/resolve makes yt-dlp fetch the media just to tell us what
|
|
// we already know: slow, and on a signed URL it's a second request
|
|
// against a link that may be single-use or IP-bound -- after which the
|
|
// one that matters, the actual playback fetch, is refused. Play it as-is.
|
|
if (isDirectMediaUrl(video.url)) {
|
|
const meta = { url: video.url, formats: [{ url: video.url }], http_headers: {} };
|
|
video.meta = meta;
|
|
return Promise.resolve(meta);
|
|
}
|
|
|
|
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;
|
|
});
|
|
};
|
|
|
|
// Everything the extractor knows about a video, for the Show info panel:
|
|
// description, dates, counts, categories, thumbnails, every field of every
|
|
// format. Deliberately not `ensureFormats`' payload and deliberately not
|
|
// attached to `video.meta` -- meta is fetched for every card that scrolls
|
|
// past and is kept small on purpose, and it names things playback's way
|
|
// (`isLive`) rather than the extractor's (`is_live`). Cached per video for
|
|
// the session, like meta, so reopening the panel is free.
|
|
const fullInfoCache = new Map();
|
|
|
|
App.videos.fetchFullInfo = function(video) {
|
|
if (!video || typeof video !== 'object' || !video.url) return Promise.resolve(null);
|
|
// Same reasoning as ensureFormats: a URL that already names a media file
|
|
// has nothing to extract, and re-fetching a signed one can burn a
|
|
// single-use link that playback still needs.
|
|
if (isDirectMediaUrl(video.url)) return Promise.resolve(null);
|
|
|
|
const cacheKey = video.id || video.url;
|
|
let promise = fullInfoCache.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, full: '1' })
|
|
});
|
|
if (!response.ok) return null;
|
|
const data = await response.json();
|
|
if (!data || typeof data !== 'object' || !Object.keys(data).length) return null;
|
|
return data;
|
|
} catch (err) {
|
|
// Best-effort: the panel still shows what the client holds.
|
|
return null;
|
|
}
|
|
})();
|
|
fullInfoCache.set(cacheKey, promise);
|
|
}
|
|
return promise;
|
|
};
|
|
|
|
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();
|
|
};
|
|
})();
|