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); }; // Which video the panel is currently showing, so a slow resolve that lands // after the user moved on doesn't redraw someone else's panel. let infoVideo = null; const appendInfoHeading = function(list, label) { const heading = document.createElement('div'); heading.className = 'info-section'; heading.textContent = label; list.appendChild(heading); }; // One row per field, whatever the field is. Objects and arrays are printed // as JSON rather than summarised: the panel is the place to see exactly what // the server said, so nothing is dropped or abbreviated here. const appendInfoRows = function(list, data) { let count = 0; Object.entries(data || {}).forEach(([key, value]) => { 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); count++; }); return count; }; // Shows every field the client holds for a video: the listing item's own // (id, title, uploader, duration, tags, ...) and then the extractor's, which // arrive separately. It used to show `video.meta` *instead of* the item once // one had been resolved, which silently hid everything the listing knew the // moment a card had been hovered. // `options.info` is the full extractor payload (App.videos.fetchFullInfo); // `options.pending` notes that it's still on its way. App.ui.showInfo = function(video, options) { const modal = document.getElementById('info-modal'); if (!modal) return; const opts = options || {}; const title = document.getElementById('info-title'); const list = document.getElementById('info-list'); const empty = document.getElementById('info-empty'); const item = (video && typeof video === 'object') ? video : {}; // `meta` is the trimmed playback payload; the full extractor info is a // superset of it, so only one of the two is ever shown. const resolved = opts.info || item.meta || null; if (title) title.textContent = item.title || (resolved && resolved.title) || 'Video Info'; let rows = 0; if (list) { list.innerHTML = ""; // `meta` gets its own section below rather than a row of JSON. const own = Object.assign({}, item); delete own.meta; rows += appendInfoRows(list, own); if (resolved && typeof resolved === 'object') { appendInfoHeading(list, opts.info ? 'Extractor' : 'Resolved'); rows += appendInfoRows(list, resolved); } if (opts.pending) { const pending = document.createElement('div'); pending.className = 'info-pending'; pending.textContent = 'Resolving full metadata…'; list.appendChild(pending); } } if (empty) { empty.style.display = rows ? 'none' : 'block'; } modal.classList.add('open'); modal.setAttribute('aria-hidden', 'false'); }; // Opens the panel on what the client already has, then redraws it with the // extractor's full payload once that lands. Resolution runs yt-dlp against // the source site and can take seconds; there's no reason to stare at // nothing (or at a spinner) while it does. App.ui.openInfo = function(video) { infoVideo = video; const canResolve = !!(App.videos && typeof App.videos.fetchFullInfo === 'function'); App.ui.showInfo(video, { pending: canResolve }); if (!canResolve) return; App.videos.fetchFullInfo(video).then((info) => { if (infoVideo !== video) return; // the panel moved on, or closed App.ui.showInfo(video, { info: info }); }); }; App.ui.closeInfo = function() { const modal = document.getElementById('info-modal'); if (!modal) return; infoVideo = null; 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(); } }; // --------------------------------------------------------------------- // Channel picker // // A was also where the command palette read its channel actions // from, so the list lives here now and the palette asks for it. // --------------------------------------------------------------------- App.ui.channels = (function() { // Sections in display order: a group, then whatever the server didn't // put in one. Rebuilt whenever the menu renders, which is whenever the // server, its status, or the selection changes. let sections = []; let matched = []; // the rows the current search leaves on screen let activeIndex = 0; let bound = false; const el = (id) => document.getElementById(id); const activeServerData = function() { const session = App.storage.getSession(); if (!session) return null; const entry = App.storage.getServerEntries() .find((candidate) => candidate.url === session.server); return (entry && entry.data) || null; }; // Everything about a channel worth searching, in one string: a reader // typing "jav", "leaks" or the site's own name should all land. const haystack = function(parts) { return parts.filter(Boolean).join(' ').toLowerCase(); }; const channelRow = function(channel, groupTitle) { const tags = Array.isArray(channel.tags) ? channel.tags : []; return { id: channel.id, name: channel.name || channel.id, note: channel.description || '', favicon: channel.favicon || '', tags: tags.slice(0, 3), // "work in progress" is the server's own word for a channel // that may not answer; worth saying before it's picked. flag: channel.status && channel.status !== 'active' ? channel.status : (channel.premium ? 'premium' : ''), group: groupTitle || '', search: haystack([channel.name, channel.id, channel.description, tags.join(' '), groupTitle]) }; }; const build = function() { const data = activeServerData(); const channels = (data && Array.isArray(data.channels)) ? data.channels : []; const groups = (data && Array.isArray(data.channelGroups)) ? data.channelGroups : []; const byId = new Map(channels.map((channel) => [channel.id, channel])); const grouped = new Set(); sections = []; groups.forEach((group) => { const ids = (Array.isArray(group.channelIds) ? group.channelIds : []) .filter((id) => byId.has(id)); if (!ids.length) return; ids.forEach((id) => grouped.add(id)); const title = group.title || group.id; sections.push({ title: title, rows: [{ id: `group:${group.id}`, name: `All ${title}`, // Every group row would otherwise be an "A" for "All". mark: title, note: `Every channel in ${title}, interleaved.`, favicon: '', tags: [], flag: ids.length === 1 ? '1 channel' : `${ids.length} channels`, group: title, search: haystack(['all', title, group.id]) }].concat(ids.map((id) => channelRow(byId.get(id), title))) }); }); const ungrouped = channels .filter((channel) => !grouped.has(channel.id)) .sort((a, b) => (a.name || a.id || '').toLowerCase() .localeCompare((b.name || b.id || '').toLowerCase())); if (ungrouped.length) { sections.push({ title: sections.length ? 'Everything else' : 'Channels', rows: ungrouped.map((channel) => channelRow(channel, '')) }); } }; // The letter behind a favicon that hasn't arrived (or never will), so a // row is never a name next to an empty square. const markFor = function(row) { const mark = document.createElement('span'); mark.className = 'channel-mark'; mark.dataset.letter = (row.mark || row.name || '?').trim().charAt(0).toUpperCase(); if (row.favicon) { const img = document.createElement('img'); img.className = 'channel-favicon'; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; mark.appendChild(img); // The same route race, proxy fallback and retries every other // remote picture in the app goes through. App.videos.attachThumbnail(img, row.favicon); } return mark; }; const rowButton = function(row, currentId) { const button = document.createElement('button'); button.type = 'button'; button.className = 'channel-row' + (row.id === currentId ? ' is-current' : ''); button.setAttribute('role', 'option'); button.setAttribute('aria-selected', row.id === currentId ? 'true' : 'false'); button.dataset.channelId = row.id; button.appendChild(markFor(row)); const text = document.createElement('span'); text.className = 'channel-row-text'; const name = document.createElement('span'); name.className = 'channel-row-name'; name.textContent = row.name; text.appendChild(name); if (row.note) { const note = document.createElement('span'); note.className = 'channel-row-note'; note.textContent = row.note; text.appendChild(note); } if (row.tags.length) { const tags = document.createElement('span'); tags.className = 'channel-row-tags'; row.tags.forEach((tag) => { const chip = document.createElement('span'); chip.className = 'channel-tag'; chip.textContent = tag; tags.appendChild(chip); }); text.appendChild(tags); } button.appendChild(text); if (row.flag) { const flag = document.createElement('span'); flag.className = 'channel-row-flag'; flag.textContent = row.flag; button.appendChild(flag); } return button; }; const render = function() { const list = el('channel-picker-list'); const empty = el('channel-picker-empty'); const search = el('channel-search'); if (!list) return; const query = (search ? search.value : '').trim().toLowerCase(); const session = App.storage.getSession(); const currentId = (session && session.channel) ? session.channel.id : ''; list.innerHTML = ''; matched = []; sections.forEach((section) => { const rows = query ? section.rows.filter((row) => row.search.includes(query)) : section.rows; if (!rows.length) return; const heading = document.createElement('div'); heading.className = 'channel-section'; heading.textContent = section.title; list.appendChild(heading); rows.forEach((row) => { const button = rowButton(row, currentId); const index = matched.length; button.addEventListener('click', () => App.ui.channels.choose(row.id)); button.addEventListener('pointermove', () => setActive(index)); list.appendChild(button); matched.push(button); }); }); if (empty) empty.hidden = matched.length > 0; // A shorter list under an unchanged scroll position hides its own // first hits, so every search starts back at the top. if (query) list.scrollTop = 0; // A search starts on its first hit; an unsearched list starts on the // channel already being read, which is also what gets scrolled to. const current = matched.findIndex((button) => button.classList.contains('is-current')); setActive(query || current < 0 ? 0 : current, !query); }; const setActive = function(index, scroll) { if (!matched.length) { activeIndex = 0; return; } activeIndex = Math.max(0, Math.min(index, matched.length - 1)); matched.forEach((button, i) => button.classList.toggle('is-active', i === activeIndex)); if (scroll && matched[activeIndex]) { matched[activeIndex].scrollIntoView({ block: 'center' }); } }; const step = function(delta) { setActive(activeIndex + delta); const button = matched[activeIndex]; if (button) button.scrollIntoView({ block: 'nearest' }); }; const open = function() { const picker = el('channel-picker'); const search = el('channel-search'); const trigger = el('channel-picker-btn'); if (!picker) return; build(); if (search) search.value = ''; // Shown before it is filled: scrolling the current channel into // view can't work while the list is still display:none. picker.classList.add('open'); picker.setAttribute('aria-hidden', 'false'); render(); if (trigger) trigger.setAttribute('aria-expanded', 'true'); // Typing is the point of the thing -- but not on a phone, where // focusing the field throws up the keyboard over the list. if (search && window.matchMedia('(min-width: 720px)').matches) { requestAnimationFrame(() => search.focus()); } }; const close = function() { const picker = el('channel-picker'); const trigger = el('channel-picker-btn'); if (picker) { picker.classList.remove('open'); picker.setAttribute('aria-hidden', 'true'); } if (trigger) trigger.setAttribute('aria-expanded', 'false'); }; const bind = function() { if (bound) return; const picker = el('channel-picker'); const trigger = el('channel-picker-btn'); const search = el('channel-search'); const closeBtn = el('channel-picker-close'); if (!picker || !trigger) return; bound = true; trigger.addEventListener('click', open); if (closeBtn) closeBtn.addEventListener('click', close); picker.addEventListener('click', (event) => { if (event.target === picker) close(); }); if (search) { search.addEventListener('input', render); search.addEventListener('keydown', (event) => { if (event.key === 'ArrowDown') { event.preventDefault(); step(1); } else if (event.key === 'ArrowUp') { event.preventDefault(); step(-1); } else if (event.key === 'Enter') { event.preventDefault(); const button = matched[activeIndex]; if (button) button.click(); } else if (event.key === 'Escape') { event.preventDefault(); close(); } }); } document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && picker.classList.contains('open')) close(); }); }; // The trigger says what is being read now, with the same favicon the // list shows, so the menu answers the question without being opened. const renderTrigger = function() { const session = App.storage.getSession(); const channel = session && session.channel; const name = el('channel-trigger-name'); const note = el('channel-trigger-note'); const mark = el('channel-trigger-mark'); const icon = el('channel-trigger-icon'); if (!name || !mark || !icon) return; name.textContent = channel ? (channel.name || channel.id) : 'No channel'; mark.dataset.letter = (channel ? (channel.name || channel.id || '?') : '?') .trim().charAt(0).toUpperCase(); if (note) { note.textContent = channel ? (channel.isGroup ? 'Whole group' : (channel.description || '')) : 'This source has no channels.'; } App.videos.detachThumbnail(icon); icon.removeAttribute('src'); icon.hidden = !(channel && channel.favicon); if (channel && channel.favicon) App.videos.attachThumbnail(icon, channel.favicon); }; return { // Called by renderMenu: the picker follows whatever the menu is // showing, and nothing else has to know it exists. render: function() { bind(); build(); renderTrigger(); if (el('channel-picker') && el('channel-picker').classList.contains('open')) { render(); } }, open: open, close: close, // The flat list the command palette offers alongside its own // actions -- id and label only; it draws its own rows. entries: function() { if (!sections.length) build(); const out = []; sections.forEach((section) => section.rows.forEach((row) => { out.push({ id: row.id, label: row.name, group: section.title }); })); return out; }, choose: function(id) { const session = App.storage.getSession(); const data = activeServerData(); const nextChannel = data ? App.session.resolveChannelById(data, id) : null; if (!session || !nextChannel) return; const serverPrefs = App.storage.getPreferences()[session.server] || {}; const savedOptions = serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[nextChannel.id] : null; const nextSession = { server: session.server, channel: nextChannel, options: savedOptions ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel) }; App.storage.setSession(nextSession); App.session.savePreference(nextSession); close(); App.ui.renderMenu(); App.videos.resetAndReload(); } }; })(); // Settings + menu rendering. App.ui.renderMenu = function() { const session = App.storage.getSession(); const serverEntries = App.storage.getServerEntries(); const sourceSelect = document.getElementById('source-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 || !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(); }; App.ui.channels.render(); 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(); }); } }; })();