broke up monolithic structure
This commit is contained in:
124
frontend/js/favorites.js
Normal file
124
frontend/js/favorites.js
Normal file
@@ -0,0 +1,124 @@
|
||||
window.App = window.App || {};
|
||||
App.favorites = App.favorites || {};
|
||||
|
||||
(function() {
|
||||
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants;
|
||||
|
||||
// Favorites storage helpers.
|
||||
App.favorites.getAll = function() {
|
||||
try {
|
||||
const raw = localStorage.getItem(FAVORITES_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
App.favorites.setAll = function(items) {
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
App.favorites.getKey = function(video) {
|
||||
if (!video) return null;
|
||||
return video.key || video.id || video.url || null;
|
||||
};
|
||||
|
||||
App.favorites.normalize = function(video) {
|
||||
const key = App.favorites.getKey(video);
|
||||
if (!key) return null;
|
||||
return {
|
||||
key,
|
||||
id: video.id || null,
|
||||
url: video.url || '',
|
||||
title: video.title || '',
|
||||
thumb: video.thumb || '',
|
||||
channel: video.channel || '',
|
||||
duration: video.duration || 0
|
||||
};
|
||||
};
|
||||
|
||||
App.favorites.getSet = function() {
|
||||
return new Set(App.favorites.getAll().map((item) => item.key));
|
||||
};
|
||||
|
||||
App.favorites.isVisible = function() {
|
||||
return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false';
|
||||
};
|
||||
|
||||
App.favorites.setVisible = function(isVisible) {
|
||||
localStorage.setItem(FAVORITES_VISIBILITY_KEY, isVisible ? 'true' : 'false');
|
||||
};
|
||||
|
||||
// UI helpers for rendering and syncing heart states.
|
||||
App.favorites.setButtonState = function(button, isFavorite) {
|
||||
button.classList.toggle('is-favorite', isFavorite);
|
||||
button.textContent = isFavorite ? '♥' : '♡';
|
||||
button.setAttribute('aria-pressed', isFavorite ? 'true' : 'false');
|
||||
button.setAttribute('aria-label', isFavorite ? 'Remove from favorites' : 'Add to favorites');
|
||||
};
|
||||
|
||||
App.favorites.syncButtons = function() {
|
||||
const favoritesSet = App.favorites.getSet();
|
||||
document.querySelectorAll('.favorite-btn[data-fav-key]').forEach((button) => {
|
||||
const key = button.dataset.favKey;
|
||||
if (!key) return;
|
||||
App.favorites.setButtonState(button, favoritesSet.has(key));
|
||||
});
|
||||
};
|
||||
|
||||
App.favorites.toggle = function(video) {
|
||||
const key = App.favorites.getKey(video);
|
||||
if (!key) return;
|
||||
const favorites = App.favorites.getAll();
|
||||
const existingIndex = favorites.findIndex((item) => item.key === key);
|
||||
if (existingIndex >= 0) {
|
||||
favorites.splice(existingIndex, 1);
|
||||
} else {
|
||||
const entry = App.favorites.normalize(video);
|
||||
if (entry) favorites.unshift(entry);
|
||||
}
|
||||
App.favorites.setAll(favorites);
|
||||
App.favorites.renderBar();
|
||||
App.favorites.syncButtons();
|
||||
};
|
||||
|
||||
App.favorites.renderBar = function() {
|
||||
const bar = document.getElementById('favorites-bar');
|
||||
const list = document.getElementById('favorites-list');
|
||||
const empty = document.getElementById('favorites-empty');
|
||||
if (!bar || !list) return;
|
||||
|
||||
const favorites = App.favorites.getAll();
|
||||
const visible = App.favorites.isVisible();
|
||||
bar.style.display = visible ? 'block' : 'none';
|
||||
|
||||
list.innerHTML = "";
|
||||
favorites.forEach((item) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'favorite-card';
|
||||
card.dataset.favKey = item.key;
|
||||
card.innerHTML = `
|
||||
<button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}">♥</button>
|
||||
<img src="${item.thumb}" alt="${item.title}">
|
||||
<div class="favorite-info">
|
||||
<h4>${item.title}</h4>
|
||||
<p>${item.channel}</p>
|
||||
</div>
|
||||
`;
|
||||
card.onclick = () => App.player.open(item.url);
|
||||
const favoriteBtn = card.querySelector('.favorite-btn');
|
||||
if (favoriteBtn) {
|
||||
favoriteBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.favorites.toggle(item);
|
||||
};
|
||||
}
|
||||
list.appendChild(card);
|
||||
});
|
||||
|
||||
if (empty) {
|
||||
empty.style.display = favorites.length > 0 ? 'none' : 'block';
|
||||
}
|
||||
};
|
||||
})();
|
||||
38
frontend/js/main.js
Normal file
38
frontend/js/main.js
Normal file
@@ -0,0 +1,38 @@
|
||||
window.App = window.App || {};
|
||||
|
||||
(function() {
|
||||
// App bootstrap: initialize storage, render UI, and load the first page.
|
||||
async function initApp() {
|
||||
await App.storage.ensureDefaults();
|
||||
App.ui.applyTheme();
|
||||
App.ui.renderMenu();
|
||||
App.favorites.renderBar();
|
||||
App.ui.bindGlobalHandlers();
|
||||
|
||||
App.videos.observeSentinel();
|
||||
|
||||
const loadMoreBtn = document.getElementById('load-more-btn');
|
||||
if (loadMoreBtn) {
|
||||
loadMoreBtn.onclick = () => {
|
||||
App.videos.loadVideos();
|
||||
};
|
||||
}
|
||||
|
||||
const errorToastClose = document.getElementById('error-toast-close');
|
||||
if (errorToastClose) {
|
||||
errorToastClose.onclick = () => {
|
||||
const toast = document.getElementById('error-toast');
|
||||
if (toast) toast.classList.remove('show');
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
App.videos.ensureViewportFilled();
|
||||
});
|
||||
|
||||
await App.videos.loadVideos();
|
||||
App.favorites.syncButtons();
|
||||
}
|
||||
|
||||
initApp();
|
||||
})();
|
||||
221
frontend/js/player.js
Normal file
221
frontend/js/player.js
Normal file
@@ -0,0 +1,221 @@
|
||||
window.App = window.App || {};
|
||||
App.player = App.player || {};
|
||||
|
||||
(function() {
|
||||
const state = App.state;
|
||||
|
||||
// Playback heuristics for full-screen behavior on mobile/TV browsers.
|
||||
function isMobilePlayback() {
|
||||
if (navigator.userAgentData && typeof navigator.userAgentData.mobile === 'boolean') {
|
||||
return navigator.userAgentData.mobile;
|
||||
}
|
||||
const ua = navigator.userAgent || '';
|
||||
if (/iPhone|iPad|iPod|Android/i.test(ua)) return true;
|
||||
return window.matchMedia('(pointer: coarse)').matches && window.matchMedia('(max-width: 900px)').matches;
|
||||
}
|
||||
|
||||
function isTvPlayback() {
|
||||
const ua = navigator.userAgent || '';
|
||||
return /SMART-TV|SmartTV|Smart TV|Internet\.TV|HbbTV|NetCast|Web0S|webOS|Tizen|AppleTV|Apple TV|GoogleTV|Android TV|AFTB|AFTS|AFTM|AFTT|AFTQ|AFTK|AFTN|AFTMM|AFTKR|Roku|DTV|BRAVIA|VIZIO|SHIELD|PhilipsTV|Hisense|VIDAA|TOSHIBA/i.test(ua);
|
||||
}
|
||||
|
||||
function getMobileVideoHost() {
|
||||
let host = document.getElementById('mobile-video-host');
|
||||
if (!host) {
|
||||
host = document.createElement('div');
|
||||
host.id = 'mobile-video-host';
|
||||
document.body.appendChild(host);
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
App.player.open = async function(url) {
|
||||
const modal = document.getElementById('video-modal');
|
||||
const video = document.getElementById('player');
|
||||
if (!modal || !video) return;
|
||||
const useMobileFullscreen = isMobilePlayback() || isTvPlayback();
|
||||
let playbackStarted = false;
|
||||
|
||||
if (!state.playerHome) {
|
||||
state.playerHome = video.parentElement;
|
||||
}
|
||||
|
||||
// Normalize stream URL + optional referer forwarding.
|
||||
let refererParam = '';
|
||||
try {
|
||||
const origin = new URL(url).origin;
|
||||
refererParam = `&referer=${encodeURIComponent(origin + '/')}`;
|
||||
} catch (err) {
|
||||
refererParam = '';
|
||||
}
|
||||
const streamUrl = `/api/stream?url=${encodeURIComponent(url)}${refererParam}`;
|
||||
let isHls = /\.m3u8($|\?)/i.test(url);
|
||||
|
||||
// Cleanup existing player instance to prevent aborted bindings.
|
||||
if (state.hlsPlayer) {
|
||||
state.hlsPlayer.stopLoad();
|
||||
state.hlsPlayer.detachMedia();
|
||||
state.hlsPlayer.destroy();
|
||||
state.hlsPlayer = null;
|
||||
}
|
||||
|
||||
// Reset the video element before re-binding a new source.
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
|
||||
if (!isHls) {
|
||||
try {
|
||||
const headResp = await fetch(streamUrl, { method: 'HEAD' });
|
||||
const contentType = headResp.headers.get('Content-Type') || '';
|
||||
if (contentType.includes('application/vnd.apple.mpegurl')) {
|
||||
isHls = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to detect stream type', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (useMobileFullscreen) {
|
||||
const host = getMobileVideoHost();
|
||||
if (video.parentElement !== host) {
|
||||
host.appendChild(video);
|
||||
}
|
||||
state.playerMode = 'mobile';
|
||||
video.removeAttribute('playsinline');
|
||||
video.removeAttribute('webkit-playsinline');
|
||||
video.playsInline = false;
|
||||
} else {
|
||||
if (state.playerHome && video.parentElement !== state.playerHome) {
|
||||
state.playerHome.appendChild(video);
|
||||
}
|
||||
state.playerMode = 'modal';
|
||||
video.setAttribute('playsinline', '');
|
||||
video.setAttribute('webkit-playsinline', '');
|
||||
video.playsInline = true;
|
||||
}
|
||||
|
||||
const requestFullscreen = () => {
|
||||
if (state.playerMode !== 'mobile') return;
|
||||
if (typeof video.webkitEnterFullscreen === 'function') {
|
||||
try {
|
||||
video.webkitEnterFullscreen();
|
||||
} catch (err) {
|
||||
// Ignore if fullscreen is not allowed.
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (video.requestFullscreen) {
|
||||
video.requestFullscreen().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const startPlayback = () => {
|
||||
if (playbackStarted) return;
|
||||
playbackStarted = true;
|
||||
const playPromise = video.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => {});
|
||||
}
|
||||
if (state.playerMode === 'mobile') {
|
||||
if (video.readyState >= 1) {
|
||||
requestFullscreen();
|
||||
} else {
|
||||
video.addEventListener('loadedmetadata', requestFullscreen, { once: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isHls) {
|
||||
if (window.Hls && window.Hls.isSupported()) {
|
||||
state.hlsPlayer = new window.Hls();
|
||||
state.hlsPlayer.loadSource(streamUrl);
|
||||
state.hlsPlayer.attachMedia(video);
|
||||
state.hlsPlayer.on(window.Hls.Events.MANIFEST_PARSED, function() {
|
||||
startPlayback();
|
||||
});
|
||||
startPlayback();
|
||||
state.hlsPlayer.on(window.Hls.Events.ERROR, function(event, data) {
|
||||
if (data && data.fatal) {
|
||||
if (App.ui && App.ui.showError) {
|
||||
App.ui.showError('Unable to play this stream.');
|
||||
}
|
||||
App.player.close();
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = streamUrl;
|
||||
startPlayback();
|
||||
} else {
|
||||
console.error("HLS not supported in this browser.");
|
||||
if (App.ui && App.ui.showError) {
|
||||
App.ui.showError('HLS is not supported in this browser.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
video.src = streamUrl;
|
||||
startPlayback();
|
||||
}
|
||||
|
||||
video.onerror = () => {
|
||||
if (App.ui && App.ui.showError) {
|
||||
App.ui.showError('Video failed to load.');
|
||||
}
|
||||
App.player.close();
|
||||
};
|
||||
|
||||
if (state.playerMode === 'modal') {
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = 'auto';
|
||||
if (!state.onFullscreenChange) {
|
||||
state.onFullscreenChange = () => {
|
||||
if (state.playerMode === 'mobile' && !document.fullscreenElement) {
|
||||
App.player.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
document.addEventListener('fullscreenchange', state.onFullscreenChange);
|
||||
if (!state.onWebkitEndFullscreen) {
|
||||
state.onWebkitEndFullscreen = () => {
|
||||
if (state.playerMode === 'mobile') {
|
||||
App.player.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
video.addEventListener('webkitendfullscreen', state.onWebkitEndFullscreen);
|
||||
}
|
||||
};
|
||||
|
||||
App.player.close = function() {
|
||||
const modal = document.getElementById('video-modal');
|
||||
const video = document.getElementById('player');
|
||||
if (!modal || !video) return;
|
||||
|
||||
if (state.hlsPlayer) {
|
||||
state.hlsPlayer.destroy();
|
||||
state.hlsPlayer = null;
|
||||
}
|
||||
if (document.fullscreenElement && document.exitFullscreen) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
if (state.onFullscreenChange) {
|
||||
document.removeEventListener('fullscreenchange', state.onFullscreenChange);
|
||||
}
|
||||
if (state.onWebkitEndFullscreen) {
|
||||
video.removeEventListener('webkitendfullscreen', state.onWebkitEndFullscreen);
|
||||
}
|
||||
video.onerror = null;
|
||||
video.pause();
|
||||
video.src = '';
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = 'auto';
|
||||
if (state.playerHome && video.parentElement !== state.playerHome) {
|
||||
state.playerHome.appendChild(video);
|
||||
}
|
||||
state.playerMode = 'modal';
|
||||
};
|
||||
})();
|
||||
23
frontend/js/state.js
Normal file
23
frontend/js/state.js
Normal file
@@ -0,0 +1,23 @@
|
||||
window.App = window.App || {};
|
||||
|
||||
// Centralized runtime state for pagination, player, and UI behavior.
|
||||
App.state = {
|
||||
currentPage: 1,
|
||||
perPage: 12,
|
||||
renderedVideoIds: new Set(),
|
||||
hasNextPage: true,
|
||||
isLoading: false,
|
||||
hlsPlayer: null,
|
||||
currentLoadController: null,
|
||||
errorToastTimer: null,
|
||||
playerMode: 'modal',
|
||||
playerHome: null,
|
||||
onFullscreenChange: null,
|
||||
onWebkitEndFullscreen: null
|
||||
};
|
||||
|
||||
// Local storage keys used across modules.
|
||||
App.constants = {
|
||||
FAVORITES_KEY: 'favorites',
|
||||
FAVORITES_VISIBILITY_KEY: 'favoritesVisible'
|
||||
};
|
||||
182
frontend/js/storage.js
Normal file
182
frontend/js/storage.js
Normal file
@@ -0,0 +1,182 @@
|
||||
window.App = window.App || {};
|
||||
App.storage = App.storage || {};
|
||||
App.session = App.session || {};
|
||||
|
||||
(function() {
|
||||
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants;
|
||||
|
||||
// Basic localStorage helpers.
|
||||
App.storage.getConfig = function() {
|
||||
return JSON.parse(localStorage.getItem('config')) || { servers: [] };
|
||||
};
|
||||
|
||||
App.storage.setConfig = function(nextConfig) {
|
||||
localStorage.setItem('config', JSON.stringify(nextConfig));
|
||||
};
|
||||
|
||||
App.storage.getSession = function() {
|
||||
return JSON.parse(localStorage.getItem('session')) || null;
|
||||
};
|
||||
|
||||
App.storage.setSession = function(nextSession) {
|
||||
localStorage.setItem('session', JSON.stringify(nextSession));
|
||||
};
|
||||
|
||||
App.storage.getPreferences = function() {
|
||||
return JSON.parse(localStorage.getItem('preferences')) || {};
|
||||
};
|
||||
|
||||
App.storage.setPreferences = function(nextPreferences) {
|
||||
localStorage.setItem('preferences', JSON.stringify(nextPreferences));
|
||||
};
|
||||
|
||||
App.storage.getServerEntries = function() {
|
||||
const config = App.storage.getConfig();
|
||||
if (!config.servers || !Array.isArray(config.servers)) return [];
|
||||
return config.servers.map((serverObj) => {
|
||||
const server = Object.keys(serverObj)[0];
|
||||
return {
|
||||
url: server,
|
||||
data: serverObj[server] || null
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Options/session helpers that power channel selection and filters.
|
||||
App.session.serializeOptions = function(options) {
|
||||
const serialized = {};
|
||||
Object.entries(options || {}).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
serialized[key] = value.map((entry) => entry.id);
|
||||
} else if (value && value.id) {
|
||||
serialized[key] = value.id;
|
||||
}
|
||||
});
|
||||
return serialized;
|
||||
};
|
||||
|
||||
App.session.hydrateOptions = function(channel, savedOptions) {
|
||||
const hydrated = {};
|
||||
if (!channel || !Array.isArray(channel.options)) return hydrated;
|
||||
const saved = savedOptions || {};
|
||||
channel.options.forEach((optionGroup) => {
|
||||
const allOptions = optionGroup.options || [];
|
||||
const savedValue = saved[optionGroup.id];
|
||||
if (optionGroup.multiSelect) {
|
||||
const selectedIds = Array.isArray(savedValue) ? savedValue : [];
|
||||
const selected = allOptions.filter((opt) => selectedIds.includes(opt.id));
|
||||
hydrated[optionGroup.id] = selected.length > 0 ? selected : allOptions.slice(0, 1);
|
||||
} else {
|
||||
const selected = allOptions.find((opt) => opt.id === savedValue) || allOptions[0];
|
||||
if (selected) hydrated[optionGroup.id] = selected;
|
||||
}
|
||||
});
|
||||
return hydrated;
|
||||
};
|
||||
|
||||
App.session.savePreference = function(session) {
|
||||
if (!session || !session.server || !session.channel) return;
|
||||
const prefs = App.storage.getPreferences();
|
||||
const serverPrefs = prefs[session.server] || {};
|
||||
serverPrefs.channelId = session.channel.id;
|
||||
serverPrefs.optionsByChannel = serverPrefs.optionsByChannel || {};
|
||||
serverPrefs.optionsByChannel[session.channel.id] = App.session.serializeOptions(session.options);
|
||||
prefs[session.server] = serverPrefs;
|
||||
App.storage.setPreferences(prefs);
|
||||
};
|
||||
|
||||
App.session.buildDefaultOptions = function(channel) {
|
||||
const selected = {};
|
||||
if (!channel || !Array.isArray(channel.options)) return selected;
|
||||
channel.options.forEach((optionGroup) => {
|
||||
if (!optionGroup.options || optionGroup.options.length === 0) return;
|
||||
if (optionGroup.multiSelect) {
|
||||
selected[optionGroup.id] = [optionGroup.options[0]];
|
||||
} else {
|
||||
selected[optionGroup.id] = optionGroup.options[0];
|
||||
}
|
||||
});
|
||||
return selected;
|
||||
};
|
||||
|
||||
// Ensures defaults exist and refreshes server status.
|
||||
App.storage.ensureDefaults = async function() {
|
||||
if (!localStorage.getItem('config')) {
|
||||
localStorage.setItem('config', JSON.stringify({
|
||||
servers: [
|
||||
{ "https://getfigleaf.com": {} },
|
||||
{ "https://hottubapp.io": {} },
|
||||
{ "https://hottub.spacemoehre.de": {} }
|
||||
]
|
||||
}));
|
||||
}
|
||||
if (!localStorage.getItem('theme')) {
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
if (!localStorage.getItem(FAVORITES_KEY)) {
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify([]));
|
||||
}
|
||||
if (!localStorage.getItem(FAVORITES_VISIBILITY_KEY)) {
|
||||
localStorage.setItem(FAVORITES_VISIBILITY_KEY, 'true');
|
||||
}
|
||||
await App.storage.initializeServerStatus();
|
||||
};
|
||||
|
||||
// Fetches server status and keeps the session pointing to a valid channel/options.
|
||||
App.storage.initializeServerStatus = async function() {
|
||||
const config = JSON.parse(localStorage.getItem('config'));
|
||||
if (!config || !config.servers) return;
|
||||
|
||||
const statusPromises = config.servers.map(async (serverObj) => {
|
||||
const server = Object.keys(serverObj)[0];
|
||||
try {
|
||||
const response = await fetch(`/api/status`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
server: server
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
});
|
||||
const status = await response.json();
|
||||
serverObj[server] = status;
|
||||
} catch (err) {
|
||||
serverObj[server] = {
|
||||
online: false,
|
||||
channels: []
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(statusPromises);
|
||||
localStorage.setItem('config', JSON.stringify(config));
|
||||
|
||||
const existingSession = App.storage.getSession();
|
||||
const serverKeys = config.servers.map((serverObj) => Object.keys(serverObj)[0]);
|
||||
if (serverKeys.length === 0) return;
|
||||
const selectedServerKey = existingSession && serverKeys.includes(existingSession.server)
|
||||
? existingSession.server
|
||||
: serverKeys[0];
|
||||
const serverEntry = config.servers.find((serverObj) => Object.keys(serverObj)[0] === selectedServerKey);
|
||||
const serverData = serverEntry ? serverEntry[selectedServerKey] : null;
|
||||
|
||||
if (serverData && serverData.channels && serverData.channels.length > 0) {
|
||||
const prefs = App.storage.getPreferences();
|
||||
const serverPrefs = prefs[selectedServerKey] || {};
|
||||
const preferredChannelId = serverPrefs.channelId;
|
||||
const channel = serverData.channels.find((ch) => ch.id === preferredChannelId) || serverData.channels[0];
|
||||
const savedOptions = serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[channel.id] : null;
|
||||
const options = savedOptions ? App.session.hydrateOptions(channel, savedOptions) : App.session.buildDefaultOptions(channel);
|
||||
|
||||
const sessionData = {
|
||||
server: selectedServerKey,
|
||||
channel: channel,
|
||||
options: options,
|
||||
};
|
||||
|
||||
App.storage.setSession(sessionData);
|
||||
App.session.savePreference(sessionData);
|
||||
}
|
||||
};
|
||||
})();
|
||||
437
frontend/js/ui.js
Normal file
437
frontend/js/ui.js
Normal file
@@ -0,0 +1,437 @@
|
||||
window.App = window.App || {};
|
||||
App.ui = App.ui || {};
|
||||
|
||||
(function() {
|
||||
const state = App.state;
|
||||
|
||||
App.ui.applyTheme = function() {
|
||||
const theme = localStorage.getItem('theme') || 'dark';
|
||||
document.body.classList.toggle('theme-light', theme === 'light');
|
||||
const select = document.getElementById('theme-select');
|
||||
if (select) select.value = theme;
|
||||
};
|
||||
|
||||
// Toast helper for playback + network errors.
|
||||
App.ui.showError = function(message) {
|
||||
const toast = document.getElementById('error-toast');
|
||||
const text = document.getElementById('error-toast-text');
|
||||
if (!toast || !text) return;
|
||||
text.textContent = message;
|
||||
toast.classList.add('show');
|
||||
if (state.errorToastTimer) {
|
||||
clearTimeout(state.errorToastTimer);
|
||||
}
|
||||
state.errorToastTimer = setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
// Drawer controls shared by the inline HTML handlers.
|
||||
App.ui.closeDrawers = function() {
|
||||
const menuDrawer = document.getElementById('drawer-menu');
|
||||
const settingsDrawer = document.getElementById('drawer-settings');
|
||||
const overlay = document.getElementById('overlay');
|
||||
const menuBtn = document.querySelector('.menu-toggle');
|
||||
const settingsBtn = document.querySelector('.settings-toggle');
|
||||
|
||||
if (menuDrawer) menuDrawer.classList.remove('open');
|
||||
if (settingsDrawer) settingsDrawer.classList.remove('open');
|
||||
if (overlay) overlay.classList.remove('open');
|
||||
if (menuBtn) menuBtn.classList.remove('active');
|
||||
if (settingsBtn) settingsBtn.classList.remove('active');
|
||||
document.body.classList.remove('drawer-open');
|
||||
};
|
||||
|
||||
App.ui.toggleDrawer = function(type) {
|
||||
const menuDrawer = document.getElementById('drawer-menu');
|
||||
const settingsDrawer = document.getElementById('drawer-settings');
|
||||
const overlay = document.getElementById('overlay');
|
||||
const menuBtn = document.querySelector('.menu-toggle');
|
||||
const settingsBtn = document.querySelector('.settings-toggle');
|
||||
|
||||
const isMenu = type === 'menu';
|
||||
const targetDrawer = isMenu ? menuDrawer : settingsDrawer;
|
||||
const otherDrawer = isMenu ? settingsDrawer : menuDrawer;
|
||||
const targetBtn = isMenu ? menuBtn : settingsBtn;
|
||||
const otherBtn = isMenu ? settingsBtn : menuBtn;
|
||||
|
||||
if (!targetDrawer || !overlay) return;
|
||||
|
||||
const willOpen = !targetDrawer.classList.contains('open');
|
||||
|
||||
if (otherDrawer) otherDrawer.classList.remove('open');
|
||||
if (otherBtn) otherBtn.classList.remove('active');
|
||||
|
||||
if (willOpen) {
|
||||
targetDrawer.classList.add('open');
|
||||
if (targetBtn) targetBtn.classList.add('active');
|
||||
overlay.classList.add('open');
|
||||
document.body.classList.add('drawer-open');
|
||||
} else {
|
||||
App.ui.closeDrawers();
|
||||
}
|
||||
};
|
||||
|
||||
// Settings + menu rendering.
|
||||
App.ui.renderMenu = function() {
|
||||
const session = App.storage.getSession();
|
||||
const serverEntries = App.storage.getServerEntries();
|
||||
const sourceSelect = document.getElementById('source-select');
|
||||
const channelSelect = document.getElementById('channel-select');
|
||||
const filtersContainer = document.getElementById('filters-container');
|
||||
const sourcesList = document.getElementById('sources-list');
|
||||
const addSourceBtn = document.getElementById('add-source-btn');
|
||||
const sourceInput = document.getElementById('source-input');
|
||||
const reloadChannelBtn = document.getElementById('reload-channel-btn');
|
||||
const favoritesToggle = document.getElementById('favorites-toggle');
|
||||
|
||||
if (!sourceSelect || !channelSelect || !filtersContainer) return;
|
||||
|
||||
sourceSelect.innerHTML = "";
|
||||
serverEntries.forEach((entry) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = entry.url;
|
||||
option.textContent = entry.url;
|
||||
sourceSelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (session && session.server) {
|
||||
sourceSelect.value = session.server;
|
||||
}
|
||||
|
||||
sourceSelect.onchange = () => {
|
||||
const selectedServerUrl = sourceSelect.value;
|
||||
const selectedServer = serverEntries.find((entry) => entry.url === selectedServerUrl);
|
||||
const channels = selectedServer && selectedServer.data && selectedServer.data.channels ?
|
||||
selectedServer.data.channels :
|
||||
[];
|
||||
const prefs = App.storage.getPreferences();
|
||||
const serverPrefs = prefs[selectedServerUrl] || {};
|
||||
const preferredChannel = channels.find((channel) => channel.id === serverPrefs.channelId) || null;
|
||||
const nextChannel = preferredChannel || (channels.length > 0 ? channels[0] : null);
|
||||
const savedOptions = nextChannel && serverPrefs.optionsByChannel ?
|
||||
serverPrefs.optionsByChannel[nextChannel.id] :
|
||||
null;
|
||||
const nextSession = {
|
||||
server: selectedServerUrl,
|
||||
channel: nextChannel,
|
||||
options: nextChannel ? (savedOptions ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel)) : {}
|
||||
};
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
App.ui.renderMenu();
|
||||
App.videos.resetAndReload();
|
||||
};
|
||||
|
||||
const activeServer = serverEntries.find((entry) => entry.url === (session && session.server));
|
||||
const availableChannels = activeServer && activeServer.data && activeServer.data.channels ?
|
||||
[...activeServer.data.channels] :
|
||||
[];
|
||||
availableChannels.sort((a, b) => {
|
||||
const nameA = (a.name || a.id || '').toLowerCase();
|
||||
const nameB = (b.name || b.id || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
channelSelect.innerHTML = "";
|
||||
availableChannels.forEach((channel) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = channel.id;
|
||||
option.textContent = channel.name || channel.id;
|
||||
channelSelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (session && session.channel) {
|
||||
channelSelect.value = session.channel.id;
|
||||
}
|
||||
|
||||
channelSelect.onchange = () => {
|
||||
const selectedId = channelSelect.value;
|
||||
const nextChannel = availableChannels.find((channel) => channel.id === selectedId) || null;
|
||||
const prefs = App.storage.getPreferences();
|
||||
const serverPrefs = prefs[session.server] || {};
|
||||
const savedOptions = nextChannel && serverPrefs.optionsByChannel ?
|
||||
serverPrefs.optionsByChannel[nextChannel.id] :
|
||||
null;
|
||||
const nextSession = {
|
||||
server: session.server,
|
||||
channel: nextChannel,
|
||||
options: nextChannel ? (savedOptions ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel)) : {}
|
||||
};
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
App.ui.renderMenu();
|
||||
App.videos.resetAndReload();
|
||||
};
|
||||
|
||||
App.ui.renderFilters(filtersContainer, session);
|
||||
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
if (themeSelect) {
|
||||
themeSelect.onchange = () => {
|
||||
const nextTheme = themeSelect.value === 'light' ? 'light' : 'dark';
|
||||
localStorage.setItem('theme', nextTheme);
|
||||
App.ui.applyTheme();
|
||||
};
|
||||
}
|
||||
|
||||
if (favoritesToggle) {
|
||||
favoritesToggle.checked = App.favorites.isVisible();
|
||||
favoritesToggle.onchange = () => {
|
||||
App.favorites.setVisible(favoritesToggle.checked);
|
||||
App.favorites.renderBar();
|
||||
};
|
||||
}
|
||||
|
||||
if (sourcesList) {
|
||||
sourcesList.innerHTML = "";
|
||||
serverEntries.forEach((entry) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'source-item';
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = entry.url;
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.onclick = async () => {
|
||||
const config = App.storage.getConfig();
|
||||
config.servers = (config.servers || []).filter((serverObj) => {
|
||||
const key = Object.keys(serverObj)[0];
|
||||
return key !== entry.url;
|
||||
});
|
||||
App.storage.setConfig(config);
|
||||
const prefs = App.storage.getPreferences();
|
||||
if (prefs[entry.url]) {
|
||||
delete prefs[entry.url];
|
||||
App.storage.setPreferences(prefs);
|
||||
}
|
||||
|
||||
const remaining = App.storage.getServerEntries();
|
||||
if (remaining.length === 0) {
|
||||
localStorage.removeItem('session');
|
||||
} else {
|
||||
const nextServerUrl = remaining[0].url;
|
||||
const nextServer = remaining[0];
|
||||
const serverPrefs = prefs[nextServerUrl] || {};
|
||||
const channels = nextServer.data && nextServer.data.channels ? nextServer.data.channels : [];
|
||||
const nextChannel = channels.find((channel) => channel.id === serverPrefs.channelId) || channels[0] || null;
|
||||
const savedOptions = nextChannel && serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[nextChannel.id] : null;
|
||||
const nextSession = {
|
||||
server: nextServerUrl,
|
||||
channel: nextChannel,
|
||||
options: nextChannel ? (savedOptions ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel)) : {}
|
||||
};
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
}
|
||||
|
||||
await App.storage.initializeServerStatus();
|
||||
App.videos.resetAndReload();
|
||||
App.ui.renderMenu();
|
||||
};
|
||||
|
||||
row.appendChild(text);
|
||||
row.appendChild(removeBtn);
|
||||
sourcesList.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
if (addSourceBtn && sourceInput) {
|
||||
addSourceBtn.onclick = async () => {
|
||||
const raw = sourceInput.value.trim();
|
||||
if (!raw) return;
|
||||
const normalized = raw.endsWith('/') ? raw.slice(0, -1) : raw;
|
||||
|
||||
const config = App.storage.getConfig();
|
||||
const exists = (config.servers || []).some((serverObj) => Object.keys(serverObj)[0] === normalized);
|
||||
if (!exists) {
|
||||
config.servers = config.servers || [];
|
||||
config.servers.push({
|
||||
[normalized]: {}
|
||||
});
|
||||
App.storage.setConfig(config);
|
||||
sourceInput.value = '';
|
||||
await App.storage.initializeServerStatus();
|
||||
|
||||
const session = App.storage.getSession();
|
||||
if (!session || session.server !== normalized) {
|
||||
const entries = App.storage.getServerEntries();
|
||||
const addedEntry = entries.find((entry) => entry.url === normalized);
|
||||
const nextChannel = addedEntry && addedEntry.data && addedEntry.data.channels ?
|
||||
addedEntry.data.channels[0] :
|
||||
null;
|
||||
const nextSession = {
|
||||
server: normalized,
|
||||
channel: nextChannel,
|
||||
options: nextChannel ? App.session.buildDefaultOptions(nextChannel) : {}
|
||||
};
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
}
|
||||
App.ui.renderMenu();
|
||||
App.videos.resetAndReload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (reloadChannelBtn) {
|
||||
reloadChannelBtn.onclick = () => {
|
||||
App.videos.resetAndReload();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
App.ui.renderFilters = function(container, session) {
|
||||
container.innerHTML = "";
|
||||
|
||||
if (!session || !session.channel || !Array.isArray(session.channel.options)) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'filters-empty';
|
||||
empty.textContent = 'No filters available for this channel.';
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
session.channel.options.forEach((optionGroup) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'setting-item';
|
||||
|
||||
const labelRow = document.createElement('div');
|
||||
labelRow.className = 'setting-label-row';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.textContent = optionGroup.title || optionGroup.id;
|
||||
labelRow.appendChild(label);
|
||||
const options = optionGroup.options || [];
|
||||
const currentSelection = session.options ? session.options[optionGroup.id] : null;
|
||||
|
||||
if (optionGroup.multiSelect) {
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.type = 'button';
|
||||
actionBtn.className = 'btn-link';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'multi-select';
|
||||
|
||||
const selectedIds = new Set(
|
||||
Array.isArray(currentSelection)
|
||||
? currentSelection.map((item) => item.id)
|
||||
: []
|
||||
);
|
||||
|
||||
const updateActionLabel = () => {
|
||||
const allChecked = options.length > 0 &&
|
||||
Array.from(list.querySelectorAll('input[type="checkbox"]'))
|
||||
.every((cb) => cb.checked);
|
||||
actionBtn.textContent = allChecked ? 'Deselect all' : 'Select all';
|
||||
actionBtn.disabled = options.length === 0;
|
||||
};
|
||||
|
||||
options.forEach((opt) => {
|
||||
const item = document.createElement('label');
|
||||
item.className = 'multi-select-item';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.value = opt.id;
|
||||
checkbox.checked = selectedIds.has(opt.id);
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = opt.title || opt.id;
|
||||
|
||||
checkbox.onchange = () => {
|
||||
const nextSession = App.storage.getSession();
|
||||
if (!nextSession || !nextSession.channel) return;
|
||||
const selected = [];
|
||||
list.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
|
||||
if (cb.checked) {
|
||||
const found = options.find((item) => item.id === cb.value);
|
||||
if (found) selected.push(found);
|
||||
}
|
||||
});
|
||||
nextSession.options[optionGroup.id] = selected;
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
App.videos.resetAndReload();
|
||||
updateActionLabel();
|
||||
};
|
||||
|
||||
item.appendChild(checkbox);
|
||||
item.appendChild(text);
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
updateActionLabel();
|
||||
|
||||
actionBtn.onclick = () => {
|
||||
const checkboxes = Array.from(list.querySelectorAll('input[type="checkbox"]'));
|
||||
const allChecked = checkboxes.length > 0 && checkboxes.every((cb) => cb.checked);
|
||||
checkboxes.forEach((cb) => {
|
||||
cb.checked = !allChecked;
|
||||
});
|
||||
|
||||
const nextSession = App.storage.getSession();
|
||||
if (!nextSession || !nextSession.channel) return;
|
||||
const selected = [];
|
||||
if (!allChecked) {
|
||||
options.forEach((opt) => selected.push(opt));
|
||||
}
|
||||
nextSession.options[optionGroup.id] = selected;
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
App.videos.resetAndReload();
|
||||
updateActionLabel();
|
||||
};
|
||||
|
||||
labelRow.appendChild(actionBtn);
|
||||
wrapper.appendChild(labelRow);
|
||||
wrapper.appendChild(list);
|
||||
container.appendChild(wrapper);
|
||||
return;
|
||||
}
|
||||
|
||||
const select = document.createElement('select');
|
||||
options.forEach((opt) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = opt.id;
|
||||
option.textContent = opt.title || opt.id;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
if (currentSelection && currentSelection.id) {
|
||||
select.value = currentSelection.id;
|
||||
}
|
||||
|
||||
select.onchange = () => {
|
||||
const nextSession = App.storage.getSession();
|
||||
if (!nextSession || !nextSession.channel) return;
|
||||
const selected = options.find((item) => item.id === select.value);
|
||||
if (selected) {
|
||||
nextSession.options[optionGroup.id] = selected;
|
||||
}
|
||||
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
App.videos.resetAndReload();
|
||||
};
|
||||
|
||||
wrapper.appendChild(labelRow);
|
||||
wrapper.appendChild(select);
|
||||
container.appendChild(wrapper);
|
||||
});
|
||||
};
|
||||
|
||||
// Expose inline handlers + keyboard shortcuts.
|
||||
App.ui.bindGlobalHandlers = function() {
|
||||
window.toggleDrawer = App.ui.toggleDrawer;
|
||||
window.closeDrawers = App.ui.closeDrawers;
|
||||
window.closePlayer = App.player.close;
|
||||
window.handleSearch = App.videos.handleSearch;
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') App.ui.closeDrawers();
|
||||
});
|
||||
};
|
||||
})();
|
||||
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