Settings gains a file picker that reads an exported Hot Tub database and merges its favorites into this client's. The file never leaves the device: sqlite.js is a small read-only reader -- header, schema, table b-trees, record decoding, and the overflow pages that real rows here spill onto -- which is all it takes to walk one table, and avoids putting a wasm SQLite behind a CDN fetch. The two sides don't agree on what identifies a video. The app keys one by a hash it computes locally (a 64-hex string); the server, and so this client, keys it as something like "reddit-1rdudss". So the merge matches on normalized URL: entries already saved here are left exactly as they are, keeping the server id that makes a listing card's heart light up, and only genuinely new videos are appended. That means an imported favorite has no server id, so hearts now also match by URL (`data-fav-url` on the card, feed slide and favorites bar). Without it an imported favorite would look unsaved on its own card, and clicking the heart would file a second copy of the same video. Only the columns a favorite needs are read. `allFormats` is deliberately left behind: it holds resolved, signed URLs, which is exactly what favorites must not store (they expire -- see App.favorites.normalize). Tests (scratchpad): the reader checked against Python's sqlite3 on a real 12MB backup -- table list, every table's row count, all 515 favorites with their fields and order, and the 25 longest records byte-for-byte, which is where a wrong overflow split shows up; and the Settings control driven end-to-end, covering the merge, an existing favorite keeping its id, a re-import adding nothing, and a listing card recognising an imported favorite and unfavoriting it cleanly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
750 lines
31 KiB
JavaScript
750 lines
31 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;
|
|
});
|
|
});
|
|
};
|
|
|
|
App.ui.bindGlobalHandlers = function() {
|
|
App.ui.bindBackupImport();
|
|
|
|
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();
|
|
});
|
|
}
|
|
};
|
|
})();
|