A favorite outlives its thumbnail. Sources sign their CDN paths and the signature expires, so a video kept months ago has a `thumb` that 404s and a card that is a grey box with a title under it. Asking the source page for a fresh picture covers the case where the page still exists; nothing covered the case where it doesn't. The video does, because playback re-resolves from the page URL every time. So once every route to the listed picture is exhausted, a frame a quarter of the way in becomes the card's picture -- past the black lead-in, and a fair guess at what the video looks like. It goes through our own proxy, so the canvas can be read back, is scaled to card width and JPEG'd to a handful of KB, and is kept: later mounts and later sessions paint it without a request, instead of asking again for a picture that is known to be gone. Only for favorites, once per item, one at a time, and never for a live stream or a manifest. Two things worth knowing, both found with the browser rather than reasoned about: a browser fires `seeked` for its own initial seek to zero, so taking the frame on the first one captures the opening of every video; and the same item is on screen in more than one place, so the captures are deduped by a promise rather than the first asker getting the picture and the rest being told there isn't one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
292 lines
15 KiB
HTML
292 lines
15 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">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-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-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>
|