Favorites now carry `favoriteDate`. Ones saved before this had no way of knowing when they were saved, so they are all stamped with the moment the client first reads them -- they sort together as one batch, at the point favorites learned to keep dates. An import brings the date the other client recorded instead, so a restored library keeps its history. The bar used to build a card per favorite, which an import of several hundred made an expensive way to open the app. It now renders a screenful and appends more as the strip is scrolled. "Browse all" turns the whole grid into favorites: the same cards, the same virtualized masonry, the same infinite scroll and reels mode as a channel listing -- App.videos.loadVideos simply pages out of localStorage instead of the server while that view is open. Sort applies to the bar and the grid together: recently added (default), oldest, title, longest, shortest, and a shuffle for rediscovering a long list. Tests (scratchpad): dates backfilled onto undated favorites, the bar paging as it scrolls rather than building every card, the grid paging to the full list with zero server calls, each sort order reordering it, and the way back to the channel listing. Also measured: 515 imported favorites load with the page responsive and 24 bar cards built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
774 lines
32 KiB
JavaScript
774 lines
32 KiB
JavaScript
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;
|
|
};
|
|
|
|
App.ui.applyPreferredQuality = function() {
|
|
const select = document.getElementById('quality-select');
|
|
if (select) select.value = App.storage.getPreferredQuality();
|
|
};
|
|
|
|
App.ui.applyFeedEndBehavior = function() {
|
|
const select = document.getElementById('feed-end-select');
|
|
if (select) select.value = App.storage.getFeedEndBehavior();
|
|
};
|
|
|
|
App.ui.applyDensity = function() {
|
|
const density = App.storage.getDensity();
|
|
document.body.dataset.density = density;
|
|
const select = document.getElementById('density-select');
|
|
if (select) select.value = density;
|
|
};
|
|
|
|
// Card Size: re-packs the virtual grid (column count derives from the scaled
|
|
// minimum card width in videos.js).
|
|
App.ui.applyCardScale = function() {
|
|
const scale = App.storage.getCardScale();
|
|
const range = document.getElementById('card-size-range');
|
|
if (range) range.value = scale;
|
|
if (App.virtualGrid && typeof App.virtualGrid.relayout === 'function') {
|
|
App.virtualGrid.relayout();
|
|
}
|
|
};
|
|
|
|
// Text Size: drives the --card-font-scale CSS variable; a re-pack follows so
|
|
// card heights account for the new text size.
|
|
App.ui.applyFontScale = function() {
|
|
const scale = App.storage.getFontScale();
|
|
document.documentElement.style.setProperty('--card-font-scale', scale);
|
|
const range = document.getElementById('text-size-range');
|
|
if (range) range.value = scale;
|
|
if (App.virtualGrid && typeof App.virtualGrid.relayout === 'function') {
|
|
App.virtualGrid.relayout();
|
|
}
|
|
};
|
|
|
|
// 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);
|
|
};
|
|
|
|
App.ui.showInfo = function(video) {
|
|
const modal = document.getElementById('info-modal');
|
|
if (!modal) return;
|
|
const title = document.getElementById('info-title');
|
|
const list = document.getElementById('info-list');
|
|
const empty = document.getElementById('info-empty');
|
|
|
|
const data = video && video.meta ? video.meta : video;
|
|
const titleText = data && data.title ? data.title : 'Video Info';
|
|
if (title) title.textContent = titleText;
|
|
|
|
if (list) {
|
|
list.innerHTML = "";
|
|
}
|
|
|
|
let hasRows = false;
|
|
if (data && typeof data === 'object') {
|
|
Object.entries(data).forEach(([key, value]) => {
|
|
if (!list) return;
|
|
const row = document.createElement('div');
|
|
row.className = 'info-row';
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'info-label';
|
|
label.textContent = key;
|
|
|
|
let valueNode;
|
|
if (value && typeof value === 'object') {
|
|
valueNode = document.createElement('pre');
|
|
valueNode.className = 'info-json';
|
|
valueNode.textContent = JSON.stringify(value, null, 2);
|
|
} else {
|
|
valueNode = document.createElement('span');
|
|
valueNode.className = 'info-value';
|
|
valueNode.textContent = value === undefined || value === null || value === '' ? '—' : String(value);
|
|
}
|
|
|
|
row.appendChild(label);
|
|
row.appendChild(valueNode);
|
|
list.appendChild(row);
|
|
hasRows = true;
|
|
});
|
|
}
|
|
|
|
if (empty) {
|
|
empty.style.display = hasRows ? 'none' : 'block';
|
|
}
|
|
|
|
modal.classList.add('open');
|
|
modal.setAttribute('aria-hidden', 'false');
|
|
};
|
|
|
|
App.ui.closeInfo = function() {
|
|
const modal = document.getElementById('info-modal');
|
|
if (!modal) return;
|
|
modal.classList.remove('open');
|
|
modal.setAttribute('aria-hidden', 'true');
|
|
};
|
|
|
|
// 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 selectedServerData = selectedServer && selectedServer.data ? selectedServer.data : null;
|
|
const channels = selectedServerData && selectedServerData.channels ? selectedServerData.channels : [];
|
|
const prefs = App.storage.getPreferences();
|
|
const serverPrefs = prefs[selectedServerUrl] || {};
|
|
const preferredChannel = selectedServerData ?
|
|
App.session.resolveChannelById(selectedServerData, 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 activeServerData = activeServer && activeServer.data ? activeServer.data : null;
|
|
const availableChannels = activeServerData && activeServerData.channels ?
|
|
[...activeServerData.channels] :
|
|
[];
|
|
availableChannels.sort((a, b) => {
|
|
const nameA = (a.name || a.id || '').toLowerCase();
|
|
const nameB = (b.name || b.id || '').toLowerCase();
|
|
return nameA.localeCompare(nameB);
|
|
});
|
|
|
|
const channelGroups = activeServerData && Array.isArray(activeServerData.channelGroups) ?
|
|
activeServerData.channelGroups :
|
|
[];
|
|
|
|
channelSelect.innerHTML = "";
|
|
const groupedChannelIds = new Set();
|
|
channelGroups.forEach((group) => {
|
|
const channelIds = Array.isArray(group.channelIds) ?
|
|
group.channelIds.filter((id) => availableChannels.some((channel) => channel.id === id)) :
|
|
[];
|
|
if (channelIds.length === 0) return;
|
|
channelIds.forEach((id) => groupedChannelIds.add(id));
|
|
|
|
const optgroup = document.createElement('optgroup');
|
|
optgroup.label = group.title || group.id;
|
|
|
|
const groupOption = document.createElement('option');
|
|
groupOption.value = `group:${group.id}`;
|
|
groupOption.textContent = `All ${group.title || group.id}`;
|
|
optgroup.appendChild(groupOption);
|
|
|
|
channelIds.forEach((id) => {
|
|
const channel = availableChannels.find((ch) => ch.id === id);
|
|
const option = document.createElement('option');
|
|
option.value = channel.id;
|
|
option.textContent = channel.name || channel.id;
|
|
optgroup.appendChild(option);
|
|
});
|
|
|
|
channelSelect.appendChild(optgroup);
|
|
});
|
|
|
|
availableChannels
|
|
.filter((channel) => !groupedChannelIds.has(channel.id))
|
|
.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 = activeServerData ? App.session.resolveChannelById(activeServerData, 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();
|
|
};
|
|
}
|
|
|
|
const qualitySelect = document.getElementById('quality-select');
|
|
if (qualitySelect) {
|
|
qualitySelect.onchange = () => {
|
|
App.storage.setPreferredQuality(qualitySelect.value);
|
|
};
|
|
}
|
|
|
|
const densitySelect = document.getElementById('density-select');
|
|
if (densitySelect) {
|
|
densitySelect.value = App.storage.getDensity();
|
|
densitySelect.onchange = () => {
|
|
App.storage.setDensity(densitySelect.value);
|
|
App.ui.applyDensity();
|
|
if (App.virtualGrid && typeof App.virtualGrid.relayout === 'function') {
|
|
App.virtualGrid.relayout();
|
|
}
|
|
};
|
|
}
|
|
|
|
const cardSizeRange = document.getElementById('card-size-range');
|
|
if (cardSizeRange) {
|
|
cardSizeRange.value = App.storage.getCardScale();
|
|
cardSizeRange.oninput = () => {
|
|
App.storage.setCardScale(cardSizeRange.value);
|
|
App.ui.applyCardScale();
|
|
};
|
|
}
|
|
|
|
const textSizeRange = document.getElementById('text-size-range');
|
|
if (textSizeRange) {
|
|
textSizeRange.value = App.storage.getFontScale();
|
|
textSizeRange.oninput = () => {
|
|
App.storage.setFontScale(textSizeRange.value);
|
|
App.ui.applyFontScale();
|
|
};
|
|
}
|
|
|
|
const feedEndSelect = document.getElementById('feed-end-select');
|
|
if (feedEndSelect) {
|
|
feedEndSelect.value = App.storage.getFeedEndBehavior();
|
|
feedEndSelect.onchange = () => {
|
|
App.storage.setFeedEndBehavior(feedEndSelect.value);
|
|
if (App.feed && typeof App.feed.applyEndBehavior === 'function') {
|
|
App.feed.applyEndBehavior();
|
|
}
|
|
};
|
|
}
|
|
|
|
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 nextServerData = nextServer.data || null;
|
|
const channels = nextServerData && nextServerData.channels ? nextServerData.channels : [];
|
|
const nextChannel = (nextServerData && App.session.resolveChannelById(nextServerData, 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 = () => {
|
|
// Refresh means "give me the current everything": the videos
|
|
// below, and the app itself. The version check runs in the
|
|
// background and only acts if the deployed assets actually
|
|
// differ from what this tab is running.
|
|
if (App.version && typeof App.version.checkNow === 'function') {
|
|
App.version.checkNow();
|
|
}
|
|
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 = session && session.channel && session.channel.isGroup ?
|
|
'No filters available when browsing a whole channel group.' :
|
|
'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.
|
|
// Settings -> Hot Tub Backup: pick an exported database and merge the
|
|
// favorites out of it. Bound once (unlike the controls in renderMenu, which
|
|
// are re-assigned on every render) because a file input mid-read must not
|
|
// have its handler swapped underneath it.
|
|
App.ui.bindBackupImport = function() {
|
|
const button = document.getElementById('import-favorites-btn');
|
|
const input = document.getElementById('import-favorites-file');
|
|
const status = document.getElementById('import-favorites-status');
|
|
if (!button || !input) return;
|
|
|
|
const say = (message) => { if (status) status.textContent = message; };
|
|
|
|
button.addEventListener('click', () => {
|
|
// Cleared first so picking the same file twice still fires change.
|
|
input.value = '';
|
|
input.click();
|
|
});
|
|
|
|
input.addEventListener('change', () => {
|
|
const file = input.files && input.files[0];
|
|
if (!file) return;
|
|
button.disabled = true;
|
|
say('Reading backup…');
|
|
App.hottubBackup.importFile(file).then((result) => {
|
|
if (!result.found) {
|
|
say('No favorites found in that backup.');
|
|
} else if (!result.added) {
|
|
say(`Nothing new: all ${result.found} favorites in that backup are already saved.`);
|
|
} else {
|
|
const plural = result.added === 1 ? 'favorite' : 'favorites';
|
|
const already = result.skipped ? ` ${result.skipped} were already saved.` : '';
|
|
say(`Imported ${result.added} ${plural}.${already}`);
|
|
}
|
|
}).catch((err) => {
|
|
say('Could not read that file.');
|
|
App.ui.showError((err && err.message) || 'Could not read that backup.');
|
|
}).then(() => {
|
|
button.disabled = false;
|
|
});
|
|
});
|
|
};
|
|
|
|
// Favorites bar header: the sort order (which applies to the bar and the
|
|
// favorites grid alike) and the toggle into that grid.
|
|
App.ui.bindFavoritesControls = function() {
|
|
const select = document.getElementById('favorites-sort');
|
|
const button = document.getElementById('favorites-browse-btn');
|
|
|
|
if (select && !select.options.length) {
|
|
App.favorites.SORTS.forEach((sort) => {
|
|
const option = document.createElement('option');
|
|
option.value = sort.id;
|
|
option.textContent = sort.label;
|
|
select.appendChild(option);
|
|
});
|
|
select.value = App.favorites.getSort();
|
|
select.addEventListener('change', () => App.favoritesView.applySort(select.value));
|
|
}
|
|
|
|
if (button) {
|
|
button.addEventListener('click', () => App.favoritesView.toggle());
|
|
}
|
|
App.favoritesView.syncControls();
|
|
};
|
|
|
|
App.ui.bindGlobalHandlers = function() {
|
|
App.ui.bindBackupImport();
|
|
App.ui.bindFavoritesControls();
|
|
|
|
window.toggleDrawer = App.ui.toggleDrawer;
|
|
window.closeDrawers = App.ui.closeDrawers;
|
|
window.handleSearch = App.videos.handleSearch;
|
|
|
|
const modeToggleBtn = document.getElementById('mode-toggle-btn');
|
|
if (modeToggleBtn) {
|
|
modeToggleBtn.onclick = () => {
|
|
App.feed.toggle();
|
|
};
|
|
}
|
|
|
|
const feedMuteBtn = document.getElementById('feed-mute-btn');
|
|
if (feedMuteBtn) {
|
|
feedMuteBtn.onclick = () => {
|
|
App.feed.toggleMute();
|
|
};
|
|
}
|
|
|
|
const searchInput = document.getElementById('search-input');
|
|
const clearSearchBtn = document.getElementById('search-clear-btn');
|
|
if (searchInput && clearSearchBtn) {
|
|
let searchDebounce = null;
|
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
|
|
const updateClearVisibility = () => {
|
|
const hasValue = searchInput.value.trim().length > 0;
|
|
clearSearchBtn.classList.toggle('is-visible', hasValue);
|
|
clearSearchBtn.disabled = !hasValue;
|
|
};
|
|
|
|
clearSearchBtn.addEventListener('click', (event) => {
|
|
event.preventDefault();
|
|
if (!searchInput.value) return;
|
|
if (searchDebounce) clearTimeout(searchDebounce);
|
|
searchInput.value = '';
|
|
updateClearVisibility();
|
|
App.videos.handleSearch('');
|
|
searchInput.focus();
|
|
});
|
|
|
|
// Update the clear button immediately for snappy feedback, but
|
|
// debounce the actual reload so typing doesn't wipe the grid and
|
|
// fire a backend request on every keystroke.
|
|
searchInput.addEventListener('input', updateClearVisibility);
|
|
searchInput.addEventListener('input', () => {
|
|
if (searchDebounce) clearTimeout(searchDebounce);
|
|
searchDebounce = setTimeout(() => {
|
|
searchDebounce = null;
|
|
App.videos.handleSearch(searchInput.value);
|
|
}, SEARCH_DEBOUNCE_MS);
|
|
});
|
|
updateClearVisibility();
|
|
}
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape') {
|
|
App.ui.closeDrawers();
|
|
App.ui.closeInfo();
|
|
App.videos.closeAllMenus();
|
|
if (App.feed.isOpen()) {
|
|
App.feed.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
document.addEventListener('click', () => {
|
|
App.videos.closeAllMenus();
|
|
});
|
|
|
|
const infoModal = document.getElementById('info-modal');
|
|
if (infoModal) {
|
|
infoModal.addEventListener('click', (event) => {
|
|
if (event.target === infoModal) {
|
|
App.ui.closeInfo();
|
|
}
|
|
});
|
|
}
|
|
|
|
const infoClose = document.getElementById('info-close');
|
|
if (infoClose) {
|
|
infoClose.addEventListener('click', () => {
|
|
App.ui.closeInfo();
|
|
});
|
|
}
|
|
};
|
|
})();
|