Files
jacuzzi/frontend/index.html
Simon 2fcc17877d Let saved searches from different sources be read as one
A saved search is one source, one channel on it, that channel's filters
and the words. Two of them on different sources had no way to be read
together, and a channel group is not it: every channel in a group belongs
to the same server and shares one query, where the interesting pair is
"uncensored JAV over here" and "amateur 1080p over there".

So a saved search can now hold several of those instead of one. Opening it
feeds a single grid from all of them, taken alternately -- one from each in
turn, the way a group zips its channels -- rather than one source's page
and then the next. Each member keeps its own source, channel, filters and
words, because having its own words is most of why it was worth saving
separately.

Every entry is read as a list of members, a plain search being a merge of
one. That is what lets the daily check, mark-all-read and the bookmark go
on working without learning what a merge is: they ask each member and zip
the answers, so the badge counts what the grid would have shown.

The search box is turned off while a merge is open and says why -- there
is nothing for one box to say for several members. A tag or an uploader
tapped on a card steps out of the merge onto one real channel instead,
since otherwise it would reload the same videos and look broken.

A merge answers to no single server, so it is not stored as a channel
preference and is not looked up from a server's status: its members ride
on the session, and a reload rebuilds it from the saved search so a rename
is picked up rather than frozen in. One source failing drops that member
and leaves the rest readable. Merging a merge flattens it, so "the
searches in it" means the same however it was assembled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-11-01 07:59:12 +00:00

302 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jacuzzi</title>
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,400&family=Sora:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="static/css/style.css">
</head>
<body>
<div id="scroll-progress" class="scroll-progress" aria-hidden="true"></div>
<header class="top-bar">
<div class="logo">Jacuzzi</div>
<div class="search-container">
<input type="text" id="search-input" placeholder="Search videos..."
autocomplete="off" role="combobox" aria-expanded="false"
aria-controls="search-suggest" aria-autocomplete="list">
<button class="search-clear-btn" id="search-clear-btn" type="button" aria-label="Clear search" title="Clear search">✕</button>
<button class="search-save-btn" id="search-save-btn" type="button" aria-pressed="false"
aria-label="Save this search" title="Save this search">
<svg class="icon-line" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6.5 4.75h11a.75.75 0 0 1 .75.75v13.3l-6.25-4.1-6.25 4.1V5.5a.75.75 0 0 1 .75-.75z"/>
</svg>
</button>
<div id="search-suggest" class="search-suggest" role="listbox" aria-label="Recent searches" hidden></div>
</div>
<div class="actions">
<button class="icon-btn reload-toggle" id="reload-channel-btn" title="Reload Channel">
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-path.svg" alt="Reload">
</button>
<button class="icon-btn saved-toggle" id="saved-searches-btn" type="button"
title="Saved searches" aria-label="Saved searches" aria-haspopup="dialog" aria-expanded="false">
<svg class="icon-line" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 3.75h8a1 1 0 0 1 1 1V17l-5-3.2L7 17V4.75a1 1 0 0 1 1-1z"/>
<path d="M18.5 6.5h.5a1 1 0 0 1 1 1v12.75l-3-2" stroke-opacity="0.55"/>
</svg>
<span class="saved-badge" id="saved-searches-badge" aria-hidden="true" hidden></span>
</button>
<button class="icon-btn menu-toggle" onclick="toggleDrawer('menu')" title="Menu">
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/bars-3.svg" alt="Menu">
</button>
<button class="icon-btn settings-toggle" onclick="toggleDrawer('settings')" title="Settings">
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/wrench.svg" alt="Settings">
</button>
</div>
</header>
<section id="favorites-bar" class="favorites-bar" aria-label="Favorites">
<div class="favorites-header">
<h3>Favorites</h3>
<div class="favorites-actions">
<select id="favorites-sort" class="favorites-sort" aria-label="Sort favorites"></select>
<button id="favorites-browse-btn" class="btn-secondary" type="button" aria-pressed="false">Browse all</button>
</div>
</div>
<div id="favorites-list" class="favorites-list"></div>
<div id="favorites-empty" class="favorites-empty">No favorites yet. Tap the heart on a video to save it here.</div>
</section>
<div class="sidebar-overlay" id="overlay" onclick="closeDrawers()"></div>
<aside id="drawer-menu" class="sidebar">
<div class="sidebar-header">
<h3>Menu</h3>
<button class="close-btn" onclick="closeDrawers()">✕</button>
</div>
<div class="sidebar-content">
<div class="sidebar-section">
<h4 class="sidebar-subtitle">Network</h4>
<div class="setting-item">
<label for="source-select">Source</label>
<select id="source-select"></select>
</div>
<div class="setting-item">
<label for="channel-picker-btn">Channel</label>
<button id="channel-picker-btn" class="channel-trigger" type="button" aria-haspopup="dialog" aria-expanded="false">
<span class="channel-mark" id="channel-trigger-mark">
<img class="channel-favicon" id="channel-trigger-icon" alt="" decoding="async">
</span>
<span class="channel-trigger-text">
<span class="channel-trigger-name" id="channel-trigger-name">No channel</span>
<span class="channel-trigger-note" id="channel-trigger-note"></span>
</span>
<span class="channel-trigger-caret" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="sidebar-section">
<h4 class="sidebar-subtitle">Filters</h4>
<div id="filters-container" class="filters-container"></div>
</div>
</div>
</aside>
<aside id="drawer-settings" class="sidebar">
<div class="sidebar-header">
<h3>Settings</h3>
<button class="close-btn" onclick="closeDrawers()">✕</button>
</div>
<div class="sidebar-content">
<div class="setting-item">
<label>Theme</label>
<select id="theme-select">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
<div class="setting-item">
<label>Preferred Quality</label>
<select id="quality-select">
<option value="auto">Best Available</option>
<option value="2160">2160p</option>
<option value="1440">1440p</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="480">480p</option>
<option value="360">360p</option>
</select>
</div>
<div class="setting-item">
<label for="density-select">Grid Density</label>
<select id="density-select">
<option value="comfortable">Comfortable</option>
<option value="compact">Compact</option>
</select>
</div>
<div class="setting-item">
<label for="card-size-range">Card Size</label>
<input type="range" id="card-size-range" min="0.7" max="1.5" step="0.1" value="1">
</div>
<div class="setting-item">
<label for="text-size-range">Text Size</label>
<input type="range" id="text-size-range" min="0.8" max="1.4" step="0.1" value="1">
</div>
<div class="setting-item">
<label for="feed-end-select">Reels: On Video End</label>
<select id="feed-end-select">
<option value="loop">Loop</option>
<option value="scroll">Scroll to Next</option>
</select>
</div>
<div class="setting-item setting-toggle">
<div class="setting-label-row">
<label for="favorites-toggle">Favorites Bar</label>
</div>
<label class="toggle">
<input type="checkbox" id="favorites-toggle">
<span class="toggle-track"></span>
</label>
</div>
<div class="sidebar-section">
<h4 class="sidebar-subtitle">Sources</h4>
<div class="setting-item">
<label for="source-input">Add Source URL</label>
<div class="input-row">
<input id="source-input" type="text" placeholder="https://example.com">
<button id="add-source-btn" class="btn-secondary" type="button">Add</button>
</div>
</div>
<div id="sources-list" class="sources-list"></div>
</div>
<div class="sidebar-section">
<h4 class="sidebar-subtitle">Captured Thumbnails</h4>
<div class="setting-item">
<label for="clear-posters-btn">Kept Pictures</label>
<button id="clear-posters-btn" class="btn-secondary" type="button">Take them again</button>
<p id="clear-posters-status" class="setting-note" role="status" aria-live="polite"></p>
</div>
</div>
<div class="sidebar-section">
<h4 class="sidebar-subtitle">Hot Tub Backup</h4>
<div class="setting-item">
<label for="import-favorites-btn">Import Favorites</label>
<input id="import-favorites-file" type="file" accept=".sqlite3,.sqlite,.db" hidden>
<button id="import-favorites-btn" class="btn-secondary" type="button">Choose backup file…</button>
<p id="import-favorites-status" class="setting-note" role="status" aria-live="polite">
Reads the favorites out of an exported Hot Tub database. The file stays on this device.
</p>
</div>
</div>
</div>
</aside>
<main id="video-grid" class="grid-container"></main>
<div id="sentinel"></div>
<button id="load-more-btn" class="load-more-btn" title="Load More">
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/chevron-down.svg" alt="Load More">
</button>
<div id="custom-player" class="custom-player" aria-hidden="true"></div>
<button id="mode-toggle-btn" class="mode-toggle-btn" type="button" title="Switch to Reels view" aria-pressed="false">
<img class="icon-svg" id="mode-toggle-icon" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/device-phone-mobile.svg" alt="Switch to Reels view">
</button>
<div id="feed-view" class="feed-view" aria-hidden="true">
<button id="feed-mute-btn" class="feed-mute-btn" type="button" title="Toggle mute">
<img class="icon-svg" id="feed-mute-icon" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg" alt="Unmute">
</button>
<div id="feed-scroll" class="feed-scroll">
<div id="feed-top-spacer" class="feed-top-spacer"></div>
<div id="feed-sentinel" class="feed-sentinel"></div>
</div>
</div>
<div id="info-modal" class="info-modal" aria-hidden="true">
<div class="info-card" role="dialog" aria-modal="true" aria-labelledby="info-title">
<div class="info-actions">
<button id="info-copy" class="btn-secondary info-copy" type="button">Copy JSON</button>
<button id="info-close" class="info-close" type="button" aria-label="Close">✕</button>
</div>
<h3 id="info-title">Video Info</h3>
<div id="info-list" class="info-list"></div>
<div id="info-empty" class="info-empty">No additional info available.</div>
</div>
</div>
<div id="error-toast" class="error-toast" role="alert" aria-live="assertive">
<span id="error-toast-text"></span>
<button id="error-toast-close" type="button" aria-label="Close">✕</button>
</div>
<div id="update-banner" class="update-banner" role="status" aria-live="polite">
<span>A new version is available.</span>
<button id="update-banner-btn" type="button">Refresh</button>
</div>
<button id="back-to-top" class="back-to-top" type="button" title="Back to top" aria-label="Back to top">↑</button>
<div id="channel-picker" class="channel-picker" aria-hidden="true">
<div class="channel-picker-box" role="dialog" aria-modal="true" aria-labelledby="channel-picker-title">
<div class="channel-picker-head">
<h3 id="channel-picker-title">Channel</h3>
<button id="channel-picker-close" class="close-btn" type="button" aria-label="Close">✕</button>
</div>
<input id="channel-search" class="channel-search" type="text" placeholder="Search channels…"
autocomplete="off" spellcheck="false" aria-controls="channel-picker-list">
<div id="channel-picker-list" class="channel-picker-list" role="listbox" aria-label="Channels"></div>
<div id="channel-picker-empty" class="channel-picker-empty">No channel matches that.</div>
</div>
</div>
<div id="command-palette" class="command-palette" aria-hidden="true">
<div class="cmdk-box" role="dialog" aria-modal="true" aria-label="Command palette">
<input id="cmdk-input" class="cmdk-input" type="text" placeholder="Type a command or search… (⌘K)" autocomplete="off" spellcheck="false">
<div id="cmdk-list" class="cmdk-list" role="listbox"></div>
</div>
</div>
<div id="saved-panel" class="saved-panel" aria-hidden="true">
<div class="saved-box" role="dialog" aria-modal="true" aria-labelledby="saved-panel-title">
<div class="saved-head">
<div class="saved-head-text">
<h3 id="saved-panel-title">Saved searches</h3>
<p class="saved-head-note" id="saved-head-note"></p>
</div>
<div class="saved-head-actions">
<button id="saved-select-btn" class="btn-link" type="button">Merge…</button>
<button id="saved-read-btn" class="btn-link" type="button">Mark all read</button>
<button id="saved-check-btn" class="btn-link" type="button">Check now</button>
<button id="saved-close-btn" class="close-btn" type="button" aria-label="Close">✕</button>
</div>
</div>
<div id="saved-list" class="saved-list"></div>
<div id="saved-empty" class="saved-empty">
Nothing saved yet. Pick a channel, set your filters, type a search —
then press the bookmark in the search bar to keep the whole thing.
</div>
<div class="saved-foot">
<button id="saved-merge-btn" class="btn-secondary saved-add" type="button" hidden>Merge selected</button>
<button id="saved-add-btn" class="btn-secondary saved-add" type="button">Save the current search</button>
</div>
</div>
</div>
<div id="privacy-veil" class="privacy-veil" aria-hidden="true"></div>
<script src="static/js/state.js"></script>
<script src="static/js/marquee.js"></script>
<script src="static/js/storage.js"></script>
<script src="static/js/customPlayer.js"></script>
<script src="static/js/player.js"></script>
<script src="static/js/favorites.js"></script>
<script src="static/js/favoritesView.js"></script>
<script src="static/js/posters.js"></script>
<script src="static/js/sqlite.js"></script>
<script src="static/js/hottubBackup.js"></script>
<script src="static/js/videos.js"></script>
<script src="static/js/savedSearches.js"></script>
<script src="static/js/feed.js"></script>
<script src="static/js/ui.js"></script>
<script src="static/js/version.js"></script>
<script src="static/js/enhance.js"></script>
<script src="static/js/privacy.js"></script>
<script src="static/js/main.js"></script>
</body>
</html>