798 lines
33 KiB
JavaScript
798 lines
33 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');
|
|
titleText.style.setProperty('--marquee-distance', `${overflow + 12}px`);
|
|
} else {
|
|
card.classList.remove('has-marquee', 'is-title-active');
|
|
titleText.style.removeProperty('--marquee-distance');
|
|
}
|
|
updateTitleActive(card);
|
|
};
|
|
|
|
let titleMeasureRaf = null;
|
|
const scheduleTitleMeasure = function() {
|
|
if (titleMeasureRaf) return;
|
|
titleMeasureRaf = requestAnimationFrame(() => {
|
|
titleMeasureRaf = null;
|
|
document.querySelectorAll('.video-card').forEach((card) => {
|
|
measureTitle(card);
|
|
});
|
|
});
|
|
};
|
|
|
|
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.
|
|
App.videos.loadGroupVideos = async function(session) {
|
|
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) {
|
|
state.hasNextPage = false;
|
|
App.videos.updateLoadMoreState();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
state.isLoading = true;
|
|
App.videos.updateLoadMoreState();
|
|
state.currentLoadController = new AbortController();
|
|
|
|
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: state.currentLoadController.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]);
|
|
});
|
|
}
|
|
|
|
App.videos.renderVideos({ items: interleaved });
|
|
state.hasNextPage = state.groupCursors.channels.some((cursor) => cursor.hasNextPage);
|
|
App.videos.ensureViewportFilled();
|
|
} catch (err) {
|
|
if (err.name !== 'AbortError') {
|
|
console.error("Failed to load group videos:", err);
|
|
}
|
|
} finally {
|
|
state.isLoading = false;
|
|
state.currentLoadController = null;
|
|
App.videos.updateLoadMoreState();
|
|
}
|
|
};
|
|
|
|
// Fetches the next page of videos and renders them into the grid.
|
|
App.videos.loadVideos = async function() {
|
|
const session = App.storage.getSession();
|
|
if (!session || !session.channel) return;
|
|
if (state.isLoading || !state.hasNextPage) return;
|
|
|
|
if (session.channel.isGroup) {
|
|
return App.videos.loadGroupVideos(session);
|
|
}
|
|
|
|
const searchInput = document.getElementById('search-input');
|
|
const query = searchInput ? searchInput.value : "";
|
|
|
|
let 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;
|
|
}
|
|
});
|
|
|
|
try {
|
|
state.isLoading = true;
|
|
App.videos.updateLoadMoreState();
|
|
state.currentLoadController = new AbortController();
|
|
const response = await fetch('/api/videos', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: state.currentLoadController.signal
|
|
});
|
|
const videos = await response.json();
|
|
App.videos.renderVideos(videos);
|
|
state.hasNextPage = videos && videos.pageInfo ? videos.pageInfo.hasNextPage !== false : true;
|
|
state.currentPage++;
|
|
App.videos.ensureViewportFilled();
|
|
} catch (err) {
|
|
if (err.name !== 'AbortError') {
|
|
console.error("Failed to load videos:", err);
|
|
}
|
|
} finally {
|
|
state.isLoading = false;
|
|
state.currentLoadController = null;
|
|
App.videos.updateLoadMoreState();
|
|
}
|
|
};
|
|
|
|
// Renders new cards for videos, wiring favorites + playback behavior.
|
|
App.videos.renderVideos = function(videos) {
|
|
const grid = document.getElementById('video-grid');
|
|
if (!grid) return;
|
|
|
|
const items = videos && Array.isArray(videos.items) ? videos.items : [];
|
|
const favoritesSet = App.favorites.getSet();
|
|
items.forEach(v => {
|
|
if (state.renderedVideoIds.has(v.id)) return;
|
|
state.loadedVideos.push(v);
|
|
// Probe in the background whether this video's best source plays
|
|
// directly, so playback can bypass the proxy when proven.
|
|
App.videos.probeVideoSources(v);
|
|
|
|
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>
|
|
<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>
|
|
<h4 class="video-title"><span class="video-title-text">${v.title}</span></h4>
|
|
${tagsMarkup}
|
|
${uploaderText ? `<p class="video-meta"><button class="uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button></p>` : ''}
|
|
${durationText ? `<p class="video-duration">${durationText}</p>` : ''}
|
|
`;
|
|
const thumb = card.querySelector('img');
|
|
App.videos.attachNoReferrerRetry(thumb);
|
|
if (thumb) {
|
|
thumb.addEventListener('load', App.videos.scheduleMasonryLayout);
|
|
}
|
|
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) {
|
|
requestAnimationFrame(() => {
|
|
measureTitle(card);
|
|
});
|
|
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);
|
|
});
|
|
} else if (titleObserver) {
|
|
titleObserver.observe(card);
|
|
}
|
|
}
|
|
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 });
|
|
};
|
|
grid.appendChild(card);
|
|
state.renderedVideoIds.add(v.id);
|
|
});
|
|
|
|
App.videos.scheduleMasonryLayout();
|
|
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;
|
|
}
|
|
}
|
|
state.currentPage = 1;
|
|
state.hasNextPage = true;
|
|
state.renderedVideoIds.clear();
|
|
state.loadedVideos = [];
|
|
state.groupCursors = null;
|
|
const grid = document.getElementById('video-grid');
|
|
if (grid) grid.innerHTML = "";
|
|
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;
|
|
}
|
|
state.currentPage = 1;
|
|
state.hasNextPage = true;
|
|
state.renderedVideoIds.clear();
|
|
state.loadedVideos = [];
|
|
state.groupCursors = null;
|
|
const grid = document.getElementById('video-grid');
|
|
if (grid) grid.innerHTML = "";
|
|
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);
|
|
}
|
|
};
|
|
|
|
let masonryRaf = null;
|
|
App.videos.scheduleMasonryLayout = function() {
|
|
if (masonryRaf) {
|
|
cancelAnimationFrame(masonryRaf);
|
|
}
|
|
masonryRaf = requestAnimationFrame(() => {
|
|
masonryRaf = null;
|
|
App.videos.applyMasonryLayout();
|
|
});
|
|
};
|
|
|
|
App.videos.applyMasonryLayout = function() {
|
|
const grid = document.getElementById('video-grid');
|
|
if (!grid) return;
|
|
const styles = window.getComputedStyle(grid);
|
|
if (styles.display !== 'grid') return;
|
|
const rowHeight = parseInt(styles.getPropertyValue('grid-auto-rows'), 10);
|
|
const rowGap = parseInt(styles.getPropertyValue('row-gap') || styles.getPropertyValue('gap'), 10) || 0;
|
|
if (!rowHeight) return;
|
|
Array.from(grid.children).forEach((item) => {
|
|
const itemHeight = item.getBoundingClientRect().height;
|
|
const span = Math.ceil((itemHeight + rowGap) / (rowHeight + rowGap));
|
|
item.style.gridRowEnd = `span ${span}`;
|
|
});
|
|
};
|
|
|
|
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()] || '';
|
|
};
|
|
|
|
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 }] : [];
|
|
}
|
|
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) => {
|
|
const referer = headerValue(fmt.http_headers, 'Referer') || metaReferer || deriveReferer(fmt.url);
|
|
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
|
|
return { url: fmt.url, referer, userAgent, isLive };
|
|
});
|
|
|
|
if (!sources.length) {
|
|
const fallbackUrl = meta.url || videoOrUrl.url || '';
|
|
if (fallbackUrl) {
|
|
sources.push({
|
|
url: fallbackUrl,
|
|
referer: metaReferer || deriveReferer(fallbackUrl),
|
|
userAgent: metaUserAgent,
|
|
isLive
|
|
});
|
|
}
|
|
}
|
|
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 };
|
|
};
|
|
|
|
// 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. We probe each loaded video's best source in the
|
|
// background and, only when proven, let the player skip the proxy.
|
|
const DIRECT_PROBE_TIMEOUT_MS = 8000;
|
|
// Only URLs that the player can hand straight to <video>/hls.js are worth
|
|
// probing; anything else (e.g. a live channel page URL) is resolved
|
|
// server-side and must keep going through the proxy.
|
|
const DIRECT_PLAYABLE_RE = /\.(mp4|m4v|m4s|webm|ts|mov|m3u8)($|\?)/i;
|
|
|
|
// url -> 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(url) === true;
|
|
};
|
|
|
|
App.videos.probeDirect = function(url) {
|
|
if (!url) return Promise.resolve(false);
|
|
if (App.videos._directStatus.has(url)) {
|
|
return Promise.resolve(App.videos._directStatus.get(url));
|
|
}
|
|
if (directPending.has(url)) {
|
|
return directPending.get(url);
|
|
}
|
|
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(url, ok);
|
|
directPending.delete(url);
|
|
console.log(`[direct-probe] ${ok ? 'DIRECT' : 'PROXY '} (${detail}) ${url}`);
|
|
return ok;
|
|
})();
|
|
directPending.set(url, 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 the direct URL is proven reachable.
|
|
App.videos.probeVideoSources = function(video) {
|
|
if (!video || typeof video !== 'object') return;
|
|
let sources;
|
|
try {
|
|
sources = App.videos.resolveStreamSources(video);
|
|
} catch (err) {
|
|
return;
|
|
}
|
|
const best = sources && sources[0];
|
|
if (!best || !best.url || best.isLive) return;
|
|
if (!DIRECT_PLAYABLE_RE.test(best.url)) return;
|
|
App.videos.probeDirect(best.url);
|
|
};
|
|
|
|
// 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 refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
|
|
const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : '';
|
|
const liveParam = resolved.isLive ? '&live=1' : '';
|
|
return `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`;
|
|
};
|
|
|
|
App.videos.buildStreamUrl = function(videoOrUrl, options) {
|
|
return App.videos.buildStreamUrlFromSource(App.videos.resolveStreamSource(videoOrUrl, options));
|
|
};
|
|
|
|
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();
|
|
};
|
|
})();
|