broke up monolithic structure
This commit is contained in:
162
frontend/js/videos.js
Normal file
162
frontend/js/videos.js
Normal file
@@ -0,0 +1,162 @@
|
||||
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`;
|
||||
};
|
||||
|
||||
// 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);
|
||||
card.innerHTML = `
|
||||
<button class="favorite-btn" type="button" aria-pressed="false" aria-label="Add to favorites" data-fav-key="${favoriteKey || ''}">♡</button>
|
||||
<img src="${v.thumb}" alt="${v.title}">
|
||||
<h4>${v.title}</h4>
|
||||
<p class="video-meta">${v.channel}</p>
|
||||
${durationText ? `<p class="video-duration">${durationText}</p>` : ''}
|
||||
`;
|
||||
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);
|
||||
};
|
||||
}
|
||||
card.onclick = () => App.player.open(v.url);
|
||||
grid.appendChild(card);
|
||||
state.renderedVideoIds.add(v.id);
|
||||
});
|
||||
|
||||
App.videos.ensureViewportFilled();
|
||||
};
|
||||
|
||||
App.videos.handleSearch = function(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';
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user