Files
jacuzzi/frontend/js/videos.js
2026-02-09 16:28:01 +00:00

284 lines
11 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
});
App.videos.observeSentinel = function() {
const sentinel = document.getElementById('sentinel');
if (sentinel) {
observer.observe(sentinel);
}
};
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);
if (hours > 0) {
return `${hours}h ${String(minutes).padStart(2, '0')}m`;
}
return `${minutes}m`;
};
App.videos.buildImageProxyUrl = function(imageUrl) {
if (!imageUrl) return '';
try {
return `/api/image?url=${encodeURIComponent(imageUrl)}&ts=${Date.now()}`;
} 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;
}
});
};
// Fetches the next page of videos and renders them into the grid.
App.videos.loadVideos = async function() {
const session = App.storage.getSession();
if (!session) return;
if (state.isLoading || !state.hasNextPage) return;
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;
const card = document.createElement('div');
card.className = 'video-card';
const durationText = App.videos.formatDuration(v.duration);
const favoriteKey = App.favorites.getKey(v);
const uploaderText = v.uploader || v.channel || '';
card.innerHTML = `
<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}">
<h4>${v.title}</h4>
${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);
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 uploaderBtn = card.querySelector('.uploader-link');
if (uploaderBtn) {
uploaderBtn.onclick = (event) => {
event.stopPropagation();
const uploader = uploaderBtn.dataset.uploader || uploaderBtn.textContent || '';
App.videos.handleSearch(uploader);
};
}
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 = () => App.player.open(v.url);
grid.appendChild(card);
state.renderedVideoIds.add(v.id);
});
App.videos.ensureViewportFilled();
};
App.videos.handleSearch = function(value) {
if (typeof value === 'string') {
const searchInput = document.getElementById('search-input');
if (searchInput && searchInput.value !== value) {
searchInput.value = value;
}
}
state.currentPage = 1;
state.hasNextPage = true;
state.renderedVideoIds.clear();
const grid = document.getElementById('video-grid');
if (grid) grid.innerHTML = "";
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();
const grid = document.getElementById('video-grid');
if (grid) grid.innerHTML = "";
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);
}
};
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');
}
}
};
// Builds a proxied stream URL with an optional referer parameter.
App.videos.buildStreamUrl = function(videoUrl) {
let refererParam = '';
try {
const origin = new URL(videoUrl).origin;
refererParam = `&referer=${encodeURIComponent(origin + '/')}`;
} catch (err) {
refererParam = '';
}
return `/api/stream?url=${encodeURIComponent(videoUrl)}${refererParam}`;
};
App.videos.downloadVideo = function(video) {
if (!video || !video.url) return;
const link = document.createElement('a');
link.href = App.videos.buildStreamUrl(video.url);
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();
};
})();