diff --git a/frontend/css/style.css b/frontend/css/style.css index 585e6d7..1e11049 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -139,7 +139,8 @@ body.theme-light .top-bar { .search-container input { width: 100%; - padding: 10px 44px 10px 16px; + /* Room on the right for the clear button and the bookmark beside it. */ + padding: 10px 78px 10px 16px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 999px; @@ -162,7 +163,7 @@ body.theme-light .top-bar { .search-clear-btn { position: absolute; top: 50%; - right: 12px; + right: 44px; transform: translateY(-50%); width: 26px; height: 26px; @@ -3092,3 +3093,363 @@ body.theme-light .video-card img:not(.is-loaded) { @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { .privacy-veil { background: var(--bg-primary); } } + +/* ===================================================================== + Saved searches (see savedSearches.js + App.ui.savedSearches) + + One hairline per row and no boxes inside boxes: the list is read as a + document, with the brass reserved for the one thing that is news -- how + many videos a saved search has picked up since it was last opened. + ===================================================================== */ + +/* Line icons drawn in place rather than fetched: these sit inside buttons + that change colour with state, and a tinted cannot follow. */ +.icon-line { + width: 22px; + height: 22px; + display: block; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* --- The bookmark in the search bar ---------------------------------- */ +.search-save-btn { + position: absolute; + top: 50%; + right: 10px; + transform: translateY(-50%); + width: 30px; + height: 30px; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s ease, background 0.2s ease, transform 0.2s ease; +} + +.search-save-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); } +.search-save-btn:active { transform: translateY(-50%) scale(0.94); } +.search-save-btn:disabled { opacity: 0.4; cursor: default; } + +/* Saved: the glyph fills in, which is the whole state in one look. */ +.search-save-btn.is-saved { color: var(--accent); } +.search-save-btn.is-saved .icon-line { fill: currentColor; stroke: currentColor; } + +@keyframes saved-flash { + 0% { transform: translateY(-50%) scale(1); } + 35% { transform: translateY(-50%) scale(1.22); } + 100% { transform: translateY(-50%) scale(1); } +} + +.search-save-btn.is-just-saved { animation: saved-flash 0.42s cubic-bezier(0.2, 0.7, 0.2, 1); } + +/* --- The header button and its count --------------------------------- */ +.saved-toggle { position: relative; } + +.saved-badge { + position: absolute; + top: 6px; + right: 5px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: var(--accent); + color: var(--bg-primary); + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.02em; + font-variant-numeric: tabular-nums; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 0 2px rgba(20, 17, 13, 0.9); +} + +body.theme-light .saved-badge { box-shadow: 0 0 0 2px rgba(246, 241, 231, 0.95); } + +/* --- The list ---------------------------------------------------------- */ +.saved-panel { + position: fixed; + inset: 0; + z-index: 4200; + display: none; + align-items: flex-start; + justify-content: center; + padding: 8vh 12px 12px 12px; + background: rgba(10, 8, 4, 0.55); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); +} + +.saved-panel.open { display: flex; } + +.saved-box { + display: flex; + flex-direction: column; + width: min(620px, 100%); + max-height: min(78vh, 760px); + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: 0 30px 70px var(--shadow); + overflow: hidden; + animation: card-rise 0.2s ease both; +} + +.saved-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 20px 20px 14px 20px; +} + +.saved-head h3 { + font-family: var(--font-display); + font-size: 19px; + font-weight: 600; + letter-spacing: -0.015em; +} + +.saved-head-note { + margin-top: 4px; + max-width: 44ch; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); +} + +.saved-head-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.saved-list { + flex: 1; + overflow-y: auto; + padding: 0 10px; + overscroll-behavior: contain; +} + +@keyframes saved-row-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } +} + +.saved-row { + position: relative; + display: flex; + align-items: center; + gap: 4px; + border-bottom: 1px solid var(--border); + animation: saved-row-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both; + animation-delay: calc(var(--i, 0) * 40ms); +} + +.saved-list .saved-row:last-child { border-bottom: none; } + +.saved-row-main { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 14px; + padding: 14px 10px; + border: none; + border-radius: var(--radius-md); + background: none; + color: var(--text-primary); + font-family: inherit; + text-align: left; + cursor: pointer; + transition: background 0.18s ease; +} + +.saved-row-main:hover { background: var(--bg-tertiary); } +.saved-row-main:disabled { cursor: default; } + +/* The count. Brass and filled when there is something new; a hairline ring + and a single dot when there isn't, so an up-to-date row reads as quiet + rather than as a zero. */ +.saved-count { + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: 999px; + border: 1px solid var(--border-hover); + background: rgba(201, 165, 103, 0.16); + color: var(--accent); + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; + display: flex; + align-items: center; + justify-content: center; +} + +.saved-count.is-quiet { + border-color: var(--border); + background: transparent; + color: var(--text-secondary); +} + +.saved-row.is-unreachable .saved-count { border-style: dashed; } + +.saved-row-text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.saved-row-name { + font-family: var(--font-display); + font-size: 15px; + font-weight: 500; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.saved-row-meta { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.saved-row-stamp { + font-size: 10px; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--text-secondary); + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.saved-row-tools { + display: flex; + gap: 2px; + flex-shrink: 0; + padding-right: 6px; + opacity: 0; + transition: opacity 0.18s ease; +} + +.saved-row:hover .saved-row-tools, +.saved-row:focus-within .saved-row-tools { opacity: 1; } + +.saved-tool { + width: 32px; + height: 32px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: none; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease; +} + +.saved-tool .icon-line { width: 18px; height: 18px; } +.saved-tool:hover { color: var(--accent); background: var(--bg-tertiary); border-color: var(--border); } + +.saved-rename { + width: 100%; + padding: 5px 9px; + background: var(--bg-tertiary); + border: 1px solid var(--border-hover); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-display); + font-size: 15px; +} + +.saved-rename:focus { outline: none; box-shadow: 0 0 0 3px var(--focus-ring); } + +/* Removing asks first, in the row itself -- no dialog on top of a dialog. */ +.saved-confirm { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + padding-right: 8px; +} + +.saved-confirm-text { + font-size: 12px; + color: var(--text-secondary); +} + +.saved-confirm-btn { + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: none; + color: var(--text-primary); + font-family: inherit; + font-size: 12px; + cursor: pointer; + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.saved-confirm-btn:hover { background: var(--bg-tertiary); border-color: var(--border-hover); } + +.saved-confirm-btn.is-danger { color: #e2a2a2; } +body.theme-light .saved-confirm-btn.is-danger { color: #9f2f2d; } +.saved-confirm-btn.is-danger:hover { border-color: currentColor; } + +.saved-row.is-confirming .saved-row-main { opacity: 0.5; } + +.saved-empty { + padding: 4px 20px 20px 20px; + max-width: 52ch; + font-size: 13px; + line-height: 1.65; + color: var(--text-secondary); +} + +.saved-foot { + padding: 14px 20px 18px 20px; + border-top: 1px solid var(--border); +} + +.saved-add { width: 100%; } +.saved-add:disabled { opacity: 0.55; cursor: default; } + +@media (max-width: 720px) { + .saved-panel { padding: 0; } + + .saved-box { + width: 100%; + height: 100%; + max-height: 100%; + border: none; + border-radius: 0; + } + + /* Nothing to hover with, so the row's tools are simply there. */ + .saved-row-tools { opacity: 1; } + .saved-row-stamp { white-space: normal; } +} + +@media (pointer: coarse) { + .saved-row-tools { opacity: 1; } + .search-save-btn { width: 34px; height: 34px; } +} diff --git a/frontend/index.html b/frontend/index.html index a9a2a60..afdc18d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,12 +19,26 @@ autocomplete="off" role="combobox" aria-expanded="false" aria-controls="search-suggest" aria-autocomplete="list"> +
+ @@ -229,6 +243,29 @@
+ + @@ -241,6 +278,7 @@ + diff --git a/frontend/js/enhance.js b/frontend/js/enhance.js index 06a0148..8db7953 100644 --- a/frontend/js/enhance.js +++ b/frontend/js/enhance.js @@ -185,6 +185,26 @@ App.enhance = App.enhance || {}; }); }); } + // Saved searches: the list itself, the one-press save, and every + // saved search as a row of its own so ⌘K can jump straight to one. + if (App.savedSearches && App.ui && App.ui.savedSearches) { + const saved = App.savedSearches.getAll(); + out.push({ label: 'Saved searches', hint: saved.length ? `${saved.length} saved` : 'Nothing saved yet', run: () => App.ui.savedSearches.open() }); + const match = App.savedSearches.findMatching(); + out.push({ + label: match ? `Remove saved search: ${match.name}` : 'Save the current search', + hint: 'Source · channel · filters · query', + run: () => App.ui.savedSearches.toggleCurrent() + }); + saved.forEach((entry) => { + const label = App.savedSearches.badgeLabel(entry.newCount); + out.push({ + label: entry.name, + hint: label ? `Saved search · ${label} new` : 'Saved search', + run: () => App.savedSearches.apply(entry.id) + }); + }); + } out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } }); out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } }); out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } }); diff --git a/frontend/js/main.js b/frontend/js/main.js index 5279702..2489964 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -43,6 +43,12 @@ window.App = window.App || {}; await App.videos.loadVideos(); App.favorites.syncButtons(); + // The grid is showing something now. If that something is one of the + // saved searches, the reader is looking at it -- so it stops being + // "new" from here, and the once-a-day check starts running. + App.savedSearches.syncViewed(); + App.savedSearches.start(); + // The UI above is rendered entirely from the last known status cached in // localStorage, so startup never blocks on (or breaks because of) a slow // or failing status endpoint. Now fetch fresh status in the background and diff --git a/frontend/js/savedSearches.js b/frontend/js/savedSearches.js new file mode 100644 index 0000000..96f8589 --- /dev/null +++ b/frontend/js/savedSearches.js @@ -0,0 +1,425 @@ +window.App = window.App || {}; +App.savedSearches = App.savedSearches || {}; + +// --------------------------------------------------------------------------- +// Saved searches +// +// A search here is more than the words in the box: it is the source, the +// channel, every filter that channel offers, and the query together. Set all +// that up once -- "uncensored JAV, newest first, 1080p and up" -- and this +// keeps it, so getting back to it is one tap instead of five. +// +// Each saved search also remembers what was on screen the last time it was +// opened (the ids of the newest videos it showed). Once a day the client asks +// each one's first page again and counts how many of those videos it has never +// shown the reader, which is the number on the badge. Nothing is counted +// server-side and nothing leaves the device but the same listing request the +// grid would have made anyway. +// --------------------------------------------------------------------------- +(function() { + const KEY = App.constants.SAVED_SEARCHES_KEY; + + // How many of the newest ids are kept as the "already seen" mark. Twice + // the probe below, so a listing that shifts by a page between visits is + // still measured against something. + const SEEN_KEEP = 24; + // One page is enough to answer "is there anything new, and roughly how + // much": the badge stops counting at 9+ anyway. + const PROBE_PER_PAGE = 12; + // A group asks each of its channels, so each one asks for less. + const GROUP_PROBE_PER_PAGE = 4; + // Above this the badge says "9+" rather than a number. + const NEW_CAP = 9; + const DAY_MS = 24 * 60 * 60 * 1000; + // How often a tab left open gets round to asking again. The check itself + // is still once a day per search; this is just how often we look. + const HEARTBEAT_MS = 30 * 60 * 1000; + // Checks run together, but not all of them: a reader with twenty saved + // searches should not open twenty listing requests on startup. + const CHECK_CONCURRENCY = 3; + const PROBE_TIMEOUT_MS = 12000; + + App.savedSearches.NEW_CAP = NEW_CAP; + + const now = () => Date.now(); + + // ---- store ---------------------------------------------------------- + + const clean = function(entry) { + if (!entry || typeof entry !== 'object') return null; + if (!entry.id || !entry.server || !entry.channelId) return null; + return { + id: String(entry.id), + name: typeof entry.name === 'string' && entry.name.trim() ? entry.name : 'Saved search', + server: String(entry.server), + channelId: String(entry.channelId), + channelName: typeof entry.channelName === 'string' ? entry.channelName : '', + query: typeof entry.query === 'string' ? entry.query : '', + options: entry.options && typeof entry.options === 'object' ? entry.options : {}, + optionLabels: Array.isArray(entry.optionLabels) ? entry.optionLabels.map(String) : [], + createdAt: Number(entry.createdAt) || 0, + seenIds: Array.isArray(entry.seenIds) ? entry.seenIds.map(String).slice(0, SEEN_KEEP) : [], + lastViewedAt: Number(entry.lastViewedAt) || 0, + lastCheckedAt: Number(entry.lastCheckedAt) || 0, + newCount: Math.max(0, Number(entry.newCount) || 0), + unreachable: !!entry.unreachable + }; + }; + + App.savedSearches.getAll = function() { + try { + const parsed = JSON.parse(localStorage.getItem(KEY)); + if (!Array.isArray(parsed)) return []; + return parsed.map(clean).filter(Boolean); + } catch (err) { + return []; + } + }; + + const setAll = function(entries) { + try { + localStorage.setItem(KEY, JSON.stringify(entries)); + } catch (err) { /* a full store is not worth failing the click over */ } + return entries; + }; + + App.savedSearches.get = function(id) { + return App.savedSearches.getAll().find((entry) => entry.id === id) || null; + }; + + // Every write goes through here: the list is read again first, so a check + // that lands while the reader is renaming or deleting can't write back a + // copy of the list from before they did it. + const patch = function(id, changes) { + const entries = App.savedSearches.getAll(); + const found = entries.find((entry) => entry.id === id); + if (!found) return null; + Object.assign(found, changes); + setAll(entries); + return found; + }; + + // ---- what is on screen right now ------------------------------------- + + const optionLabelsOf = function(options) { + const labels = []; + Object.values(options || {}).forEach((value) => { + if (Array.isArray(value)) { + // A multi-select that is fully on says nothing worth printing; + // the interesting case is a narrowed one. + if (value.length && value.length <= 3) { + value.forEach((opt) => labels.push(opt.title || opt.id)); + } else if (value.length) { + labels.push(`${value.length} selected`); + } + } else if (value && (value.title || value.id)) { + labels.push(value.title || value.id); + } + }); + return labels; + }; + + // The search the app is showing, in the shape a saved one is stored in. + App.savedSearches.current = function() { + const session = App.storage.getSession(); + if (!session || !session.channel) return null; + const box = document.getElementById('search-input'); + return { + server: session.server, + channelId: session.channel.id, + channelName: session.channel.name || session.channel.id, + query: (box ? box.value : '').trim(), + options: App.session.serializeOptions(session.options), + optionLabels: optionLabelsOf(session.options) + }; + }; + + // Two searches are the same search when they would send the same request. + App.savedSearches.signature = function(desc) { + if (!desc) return ''; + const options = desc.options || {}; + const parts = Object.keys(options).sort().map((key) => { + const value = options[key]; + const flat = Array.isArray(value) ? value.slice().sort().join(',') : String(value); + return `${key}=${flat}`; + }); + return [desc.server, desc.channelId, (desc.query || '').toLowerCase().trim()] + .concat(parts).join('||'); + }; + + // The saved search the app is currently showing, if any -- what makes the + // bookmark in the search bar light up. + App.savedSearches.findMatching = function() { + const desc = App.savedSearches.current(); + if (!desc) return null; + const wanted = App.savedSearches.signature(desc); + return App.savedSearches.getAll() + .find((entry) => App.savedSearches.signature(entry) === wanted) || null; + }; + + // What to call a search nobody has named: the words searched for, or the + // channel when there were none. + const autoName = function(desc) { + if (desc.query) return desc.query; + const channel = desc.channelName || desc.channelId; + const narrowed = desc.optionLabels.slice(0, 2).join(' · '); + return narrowed ? `${channel} · ${narrowed}` : channel; + }; + + // The ids of what the grid is showing, newest first. Saving straight after + // a load therefore starts at "nothing new", which is the truth: the reader + // is looking at it. + const visibleIds = function() { + return (App.state.loadedVideos || []) + .slice(0, SEEN_KEEP) + .map((video) => String(video && (video.id || video.url) || '')) + .filter(Boolean); + }; + + App.savedSearches.saveCurrent = function() { + const desc = App.savedSearches.current(); + if (!desc) return null; + const already = App.savedSearches.findMatching(); + if (already) return already; + const entry = clean(Object.assign({}, desc, { + id: `ss_${now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, + name: autoName(desc), + createdAt: now(), + seenIds: visibleIds(), + lastViewedAt: now(), + lastCheckedAt: now(), + newCount: 0 + })); + const entries = App.savedSearches.getAll(); + entries.unshift(entry); + setAll(entries); + changed(); + return entry; + }; + + App.savedSearches.remove = function(id) { + setAll(App.savedSearches.getAll().filter((entry) => entry.id !== id)); + changed(); + }; + + App.savedSearches.rename = function(id, name) { + const next = (name || '').trim(); + if (!next) return null; + const entry = patch(id, { name: next.slice(0, 80) }); + changed(); + return entry; + }; + + // Opened, so whatever it shows now counts as seen and the badge goes out. + App.savedSearches.markViewed = function(id) { + const seen = visibleIds(); + const entry = patch(id, Object.assign({ + lastViewedAt: now(), + lastCheckedAt: now(), + newCount: 0, + unreachable: false + }, seen.length ? { seenIds: seen } : {})); + changed(); + return entry; + }; + + // Being on a saved search is viewing it, however the reader got there -- + // by tapping it here, or by setting the same channel and filters by hand. + App.savedSearches.syncViewed = function() { + const match = App.savedSearches.findMatching(); + if (match) App.savedSearches.markViewed(match.id); + return match; + }; + + App.savedSearches.totalNew = function() { + return App.savedSearches.getAll() + .reduce((sum, entry) => sum + (entry.newCount || 0), 0); + }; + + // What a count looks like on a badge: nothing, a number, or "9+". + App.savedSearches.badgeLabel = function(count) { + if (!count) return ''; + return count > NEW_CAP ? `${NEW_CAP}+` : String(count); + }; + + const changed = function() { + if (App.ui && App.ui.savedSearches && typeof App.ui.savedSearches.sync === 'function') { + App.ui.savedSearches.sync(); + } + }; + + // ---- the daily check ------------------------------------------------- + + // The ids on the first page of a saved search, as the server orders them. + // Not the grid's request: the grid's paging state belongs to what the + // reader is looking at, and a check must not disturb it. + const probeIds = async function(entry, signal) { + const serverEntry = App.storage.getServerEntries() + .find((candidate) => candidate.url === entry.server); + const serverData = serverEntry ? serverEntry.data : null; + const channel = serverData ? App.session.resolveChannelById(serverData, entry.channelId) : null; + if (!channel) throw new Error('channel is no longer listed'); + + const ask = async function(channelId, perPage, extra) { + const body = Object.assign({ + channel: channelId, + query: entry.query || '', + page: 1, + perPage: perPage, + server: entry.server + }, extra || {}); + const response = await fetch('/api/videos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: signal + }); + if (!response.ok) throw new Error(`listing request failed: ${response.status}`); + const data = await response.json(); + const items = data && Array.isArray(data.items) ? data.items : []; + return items.map((item) => String(item && (item.id || item.url) || '')).filter(Boolean); + }; + + if (channel.isGroup) { + const selected = entry.options ? entry.options[App.session.GROUP_CHANNELS_OPTION_ID] : null; + const enabled = (Array.isArray(selected) && selected.length ? selected : channel.channelIds) + .filter((id) => channel.channelIds.includes(id)); + if (!enabled.length) return []; + const perChannel = await Promise.all(enabled.map((id) => ask(id, GROUP_PROBE_PER_PAGE))); + // Interleaved, so "the newest" means the same thing it does in the + // grid: one from each channel in turn. + const merged = []; + const longest = perChannel.reduce((max, ids) => Math.max(max, ids.length), 0); + for (let i = 0; i < longest; i++) { + perChannel.forEach((ids) => { if (ids[i]) merged.push(ids[i]); }); + } + return merged; + } + + const extra = {}; + Object.entries(entry.options || {}).forEach(([key, value]) => { + extra[key] = Array.isArray(value) ? value.join(', ') : value; + }); + return ask(entry.channelId, PROBE_PER_PAGE, extra); + }; + + const checkOne = async function(entry) { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + try { + const ids = await probeIds(entry, controller.signal); + const seen = new Set(entry.seenIds || []); + // A search nobody has ever opened has nothing to compare against, + // so the whole first page is its mark rather than its news. + if (!seen.size) { + patch(entry.id, { seenIds: ids.slice(0, SEEN_KEEP), lastCheckedAt: now(), newCount: 0, unreachable: false }); + return; + } + const fresh = ids.filter((id) => !seen.has(id)).length; + patch(entry.id, { lastCheckedAt: now(), newCount: fresh, unreachable: false }); + } catch (err) { + // A source that is down must not look like a search with nothing + // new in it, and must not reset the clock either: it is tried + // again on the next look. + patch(entry.id, { unreachable: true }); + } finally { + window.clearTimeout(timer); + } + }; + + let running = null; + + // Checks every saved search that is due (or all of them, with `force`). + // Resolves when they have all been written back; safe to call at any time, + // and calling it while a run is in flight joins that run. + App.savedSearches.check = function(opts) { + const force = !!(opts && opts.force); + if (running) return running; + const due = App.savedSearches.getAll().filter((entry) => + force || !entry.lastCheckedAt || (now() - entry.lastCheckedAt) >= DAY_MS); + if (!due.length) return Promise.resolve(0); + + const queue = due.slice(); + const worker = async function() { + while (queue.length) { + await checkOne(queue.shift()); + changed(); + } + }; + running = Promise.all( + Array.from({ length: Math.min(CHECK_CONCURRENCY, queue.length) }, worker) + ).then(() => { + running = null; + changed(); + return due.length; + }).catch(() => { + running = null; + changed(); + return 0; + }); + return running; + }; + + App.savedSearches.isChecking = function() { return !!running; }; + + // ---- opening one ----------------------------------------------------- + + // Puts the whole saved search back: source, channel, filters, query. The + // grid is reloaded here rather than left to the caller, because "applied" + // means the reader is looking at it -- which is also when it stops being + // new (see markViewed). + App.savedSearches.apply = async function(id) { + const entry = App.savedSearches.get(id); + if (!entry) return false; + + const serverEntry = App.storage.getServerEntries() + .find((candidate) => candidate.url === entry.server); + if (!serverEntry || !serverEntry.data) { + App.ui.showError(`${entry.name}: the source ${entry.server} isn't set up on this device any more.`); + return false; + } + const channel = App.session.resolveChannelById(serverEntry.data, entry.channelId); + if (!channel) { + App.ui.showError(`${entry.name}: ${entry.server} no longer lists that channel.`); + patch(entry.id, { unreachable: true }); + changed(); + return false; + } + + const session = { + server: entry.server, + channel: channel, + options: App.session.hydrateOptions(channel, entry.options) + }; + App.storage.setSession(session); + App.session.savePreference(session); + + App.videos.setSearchBox(entry.query || ''); + if (App.favoritesView && App.favoritesView.isActive()) { + App.favoritesView.close({ silent: true }); + } + App.ui.renderMenu(); + App.videos.resetGrid(); + await App.videos.loadVideos(); + App.savedSearches.markViewed(entry.id); + return true; + }; + + // ---- lifecycle ------------------------------------------------------- + + let started = false; + + App.savedSearches.start = function() { + if (started) return; + started = true; + changed(); + App.savedSearches.check(); + // A tab left open for days still gets round to it. + window.setInterval(() => App.savedSearches.check(), HEARTBEAT_MS); + // And a phone that had the app in the background for a day checks as + // soon as it is looked at again, rather than at the next reload. + document.addEventListener('visibilitychange', function() { + if (document.visibilityState === 'visible') App.savedSearches.check(); + }); + }; +})(); diff --git a/frontend/js/state.js b/frontend/js/state.js index 742a429..363eac9 100644 --- a/frontend/js/state.js +++ b/frontend/js/state.js @@ -25,7 +25,8 @@ App.constants = { FAVORITES_SORT_KEY: 'favoritesSort', PREFERRED_QUALITY_KEY: 'preferredQuality', FEED_END_BEHAVIOR_KEY: 'feedEndBehavior', - SEARCH_HISTORY_KEY: 'searchHistory' + SEARCH_HISTORY_KEY: 'searchHistory', + SAVED_SEARCHES_KEY: 'savedSearches' }; // Lazily injects hls.js the first time a stream actually needs it. Sessions diff --git a/frontend/js/ui.js b/frontend/js/ui.js index ada8b3d..9c91cf0 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -472,6 +472,386 @@ App.ui = App.ui || {}; return { bind: bind, refresh: refresh, close: close, isOpen: isOpen, shown: () => offered.slice() }; })(); + // --------------------------------------------------------------------- + // Saved searches + // + // The bookmark in the search bar keeps whatever is on screen -- source, + // channel, filters, query -- and the bookmark in the header opens the + // list of them. Each row carries what is new in it since it was last + // opened (App.savedSearches does the counting), and tapping a row puts + // that whole search back. + // --------------------------------------------------------------------- + App.ui.savedSearches = (function() { + let bound = false; + // A row with its rename field open, or its "remove?" question showing. + // Held here so a check landing mid-edit redraws around it rather than + // through it. + let pending = null; + + const el = (id) => document.getElementById(id); + const panel = () => el('saved-panel'); + const isOpen = () => !!panel() && panel().classList.contains('open'); + + const ago = function(stamp) { + if (!stamp) return 'never'; + const seconds = Math.max(0, Math.round((Date.now() - stamp) / 1000)); + if (seconds < 90) return 'just now'; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours} h ago`; + const days = Math.round(hours / 24); + return days === 1 ? 'yesterday' : `${days} days ago`; + }; + + // Where the search points, in one line: the channel, what the filters + // narrow it to, and the words themselves. + const metaLine = function(entry) { + const parts = [entry.channelName || entry.channelId]; + if (entry.optionLabels && entry.optionLabels.length) { + parts.push(entry.optionLabels.slice(0, 3).join(' · ')); + } + parts.push(entry.query ? `“${entry.query}”` : 'no search term'); + return parts.join(' · '); + }; + + const stampLine = function(entry) { + if (entry.unreachable) return 'Could not be checked — the source did not answer.'; + const checked = `Checked ${ago(entry.lastCheckedAt)}`; + if (!entry.newCount) return `${checked} · nothing new`; + const since = entry.lastViewedAt ? ` since you opened it ${ago(entry.lastViewedAt)}` : ''; + const label = App.savedSearches.badgeLabel(entry.newCount); + return `${label} new${since} · ${checked.toLowerCase()}`; + }; + + const svgIcon = function(paths, extraClass) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('class', 'icon-line' + (extraClass ? ' ' + extraClass : '')); + paths.forEach((d) => { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', d); + svg.appendChild(path); + }); + return svg; + }; + + const RENAME_ICON = ['M4.5 19.5h3L19 8a2.1 2.1 0 0 0-3-3L4.5 16.5v3z']; + const DELETE_ICON = ['M5 7.5h14', 'M9.5 7.5V5.75h5V7.5', 'M7 7.5l.8 11.2h8.4L17 7.5']; + + const toolButton = function(act, label, paths) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'saved-tool'; + button.dataset.act = act; + button.title = label; + button.setAttribute('aria-label', label); + button.appendChild(svgIcon(paths)); + return button; + }; + + // A row mid-rename: the name becomes a field, and leaving it (Enter or + // blur) keeps what was typed. Escape puts the old name back -- the + // normal way round for an edit in place. + const renameField = function(entry, row) { + const field = document.createElement('input'); + field.type = 'text'; + field.className = 'saved-rename'; + field.value = entry.name; + field.maxLength = 80; + field.setAttribute('aria-label', 'Name for this saved search'); + let settled = false; + const commit = function(keep) { + if (settled) return; + settled = true; + pending = null; + if (keep) App.savedSearches.rename(entry.id, field.value); + else sync(); + }; + field.addEventListener('keydown', (event) => { + event.stopPropagation(); + if (event.key === 'Enter') { event.preventDefault(); commit(true); } + else if (event.key === 'Escape') { event.preventDefault(); commit(false); } + }); + field.addEventListener('blur', () => commit(true)); + field.addEventListener('click', (event) => event.stopPropagation()); + row.classList.add('is-renaming'); + return field; + }; + + const confirmStrip = function(entry) { + const strip = document.createElement('div'); + strip.className = 'saved-confirm'; + + const question = document.createElement('span'); + question.className = 'saved-confirm-text'; + question.textContent = 'Remove this saved search?'; + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'saved-confirm-btn is-danger'; + remove.textContent = 'Remove'; + remove.addEventListener('click', () => { + pending = null; + App.savedSearches.remove(entry.id); + }); + + const keep = document.createElement('button'); + keep.type = 'button'; + keep.className = 'saved-confirm-btn'; + keep.textContent = 'Keep'; + keep.addEventListener('click', () => { pending = null; sync(); }); + + strip.appendChild(question); + strip.appendChild(keep); + strip.appendChild(remove); + return strip; + }; + + const buildRow = function(entry, index) { + const row = document.createElement('div'); + row.className = 'saved-row'; + row.dataset.id = entry.id; + row.style.setProperty('--i', String(index)); + if (entry.unreachable) row.classList.add('is-unreachable'); + + const main = document.createElement('button'); + main.type = 'button'; + main.className = 'saved-row-main'; + + const count = document.createElement('span'); + const label = App.savedSearches.badgeLabel(entry.newCount); + count.className = 'saved-count' + (label ? '' : ' is-quiet'); + // The quiet state is a mark, not a number: a row with nothing new + // should not look like a row with something in it. + count.textContent = label || '·'; + if (label) count.title = `${entry.newCount} new since you last opened this`; + + const text = document.createElement('span'); + text.className = 'saved-row-text'; + + const name = document.createElement('span'); + name.className = 'saved-row-name'; + name.textContent = entry.name; + + const meta = document.createElement('span'); + meta.className = 'saved-row-meta'; + meta.textContent = metaLine(entry); + + const stamp = document.createElement('span'); + stamp.className = 'saved-row-stamp'; + stamp.textContent = stampLine(entry); + + text.appendChild(name); + text.appendChild(meta); + text.appendChild(stamp); + main.appendChild(count); + main.appendChild(text); + main.addEventListener('click', () => choose(entry.id)); + row.appendChild(main); + + if (pending && pending.id === entry.id && pending.mode === 'rename') { + const field = renameField(entry, row); + name.replaceWith(field); + main.disabled = true; + requestAnimationFrame(() => { field.focus(); field.select(); }); + return row; + } + + if (pending && pending.id === entry.id && pending.mode === 'delete') { + row.classList.add('is-confirming'); + row.appendChild(confirmStrip(entry)); + return row; + } + + const tools = document.createElement('div'); + tools.className = 'saved-row-tools'; + const rename = toolButton('rename', `Rename ${entry.name}`, RENAME_ICON); + rename.addEventListener('click', () => { pending = { id: entry.id, mode: 'rename' }; render(); }); + const remove = toolButton('delete', `Remove ${entry.name}`, DELETE_ICON); + remove.addEventListener('click', () => { pending = { id: entry.id, mode: 'delete' }; render(); }); + tools.appendChild(rename); + tools.appendChild(remove); + row.appendChild(tools); + return row; + }; + + const render = function() { + const list = el('saved-list'); + const empty = el('saved-empty'); + const note = el('saved-head-note'); + const add = el('saved-add-btn'); + const check = el('saved-check-btn'); + if (!list) return; + + const entries = App.savedSearches.getAll(); + list.textContent = ''; + entries.forEach((entry, index) => list.appendChild(buildRow(entry, index))); + if (empty) empty.hidden = entries.length > 0; + + if (note) { + const total = App.savedSearches.totalNew(); + note.textContent = !entries.length ? + 'Keep a search once, come back to it in one tap.' : + (total ? + `${App.savedSearches.badgeLabel(total)} new since you last looked.` : + 'Checked once a day. Nothing new right now.'); + } + if (check) { + check.disabled = !entries.length || App.savedSearches.isChecking(); + check.textContent = App.savedSearches.isChecking() ? 'Checking…' : 'Check now'; + } + if (add) { + const match = App.savedSearches.findMatching(); + add.disabled = !!match || !App.savedSearches.current(); + add.textContent = match ? `Already saved as “${match.name}”` : 'Save the current search'; + } + }; + + const choose = function(id) { + close(); + App.savedSearches.apply(id); + }; + + const open = function() { + const box = panel(); + if (!box) return; + pending = null; + render(); + box.classList.add('open'); + box.setAttribute('aria-hidden', 'false'); + const trigger = el('saved-searches-btn'); + if (trigger) trigger.setAttribute('aria-expanded', 'true'); + // Opening the list is as good a moment as any to find out whether + // anything is due -- the daily check may have been missed by a tab + // that was never left open that long. + App.savedSearches.check(); + const closeBtn = el('saved-close-btn'); + if (closeBtn) requestAnimationFrame(() => closeBtn.focus()); + }; + + const close = function() { + const box = panel(); + // Where the keyboard goes next: back to the button that opened + // this, not to the top of the page behind it. + const returnFocus = !!(box && box.contains(document.activeElement)); + if (box) { + box.classList.remove('open'); + box.setAttribute('aria-hidden', 'true'); + } + pending = null; + const trigger = el('saved-searches-btn'); + if (trigger) { + trigger.setAttribute('aria-expanded', 'false'); + if (returnFocus) trigger.focus(); + } + }; + + // The bookmark in the search bar: lit when what is on screen is + // already saved, and pressing it then takes it off the list again. + const toggleCurrent = function() { + const match = App.savedSearches.findMatching(); + if (match) { + App.savedSearches.remove(match.id); + return; + } + const saved = App.savedSearches.saveCurrent(); + if (!saved) return; + const button = el('search-save-btn'); + if (button) { + button.classList.remove('is-just-saved'); + // Restarted rather than left to run: saving twice in a row + // should flash twice. + void button.offsetWidth; + button.classList.add('is-just-saved'); + } + }; + + // Keeps every piece of the feature showing the same thing: the + // bookmark's state, the header's count, and the list if it is open. + const sync = function() { + const button = el('search-save-btn'); + if (button) { + const match = App.savedSearches.findMatching(); + const available = !!App.savedSearches.current(); + button.classList.toggle('is-saved', !!match); + button.setAttribute('aria-pressed', match ? 'true' : 'false'); + button.disabled = !available; + const title = match ? `Saved as “${match.name}” — tap to remove` : 'Save this search'; + button.title = title; + button.setAttribute('aria-label', title); + } + + const badge = el('saved-searches-badge'); + const trigger = el('saved-searches-btn'); + if (badge) { + const total = App.savedSearches.totalNew(); + const label = App.savedSearches.badgeLabel(total); + badge.textContent = label; + badge.hidden = !label; + if (trigger) { + const count = App.savedSearches.getAll().length; + trigger.setAttribute('aria-label', label ? + `Saved searches, ${total > App.savedSearches.NEW_CAP ? 'more than ' + App.savedSearches.NEW_CAP : total} new` : + (count ? `Saved searches (${count})` : 'Saved searches')); + } + } + + // A rename in progress is the reader's text, not ours to redraw. + if (isOpen() && !(pending && pending.mode === 'rename')) render(); + }; + + const bind = function() { + if (bound) return; + const box = panel(); + const trigger = el('saved-searches-btn'); + if (!box || !trigger) return; + bound = true; + + trigger.addEventListener('click', () => { if (isOpen()) close(); else open(); }); + const closeBtn = el('saved-close-btn'); + if (closeBtn) closeBtn.addEventListener('click', close); + box.addEventListener('click', (event) => { if (event.target === box) close(); }); + + const saveBtn = el('search-save-btn'); + if (saveBtn) saveBtn.addEventListener('click', toggleCurrent); + + const add = el('saved-add-btn'); + if (add) { + add.addEventListener('click', () => { + const saved = App.savedSearches.saveCurrent(); + if (saved) pending = { id: saved.id, mode: 'rename' }; + render(); + }); + } + + const check = el('saved-check-btn'); + if (check) { + check.addEventListener('click', () => { + App.savedSearches.check({ force: true }); + render(); + }); + } + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && isOpen()) { + event.stopPropagation(); + close(); + } + }); + }; + + return { + bind: bind, + sync: sync, + open: open, + close: close, + isOpen: isOpen, + toggleCurrent: toggleCurrent + }; + })(); + // --------------------------------------------------------------------- // Channel picker // @@ -1043,6 +1423,10 @@ App.ui = App.ui || {}; App.videos.resetAndReload(); }; } + + // Source, channel and filters are all part of what a saved search is, + // so whenever the menu redraws, so does the bookmark. + if (App.ui.savedSearches) App.ui.savedSearches.sync(); }; App.ui.renderFilters = function(container, session) { @@ -1301,7 +1685,11 @@ App.ui = App.ui || {}; // debounce the actual reload so typing doesn't wipe the grid and // fire a backend request on every keystroke. App.ui.searchHistory.bind(); + App.ui.savedSearches.bind(); searchInput.addEventListener('input', updateClearVisibility); + // The bookmark is about this search, so it follows the words as + // they are typed, not just the channel and filters. + searchInput.addEventListener('input', () => App.ui.savedSearches.sync()); searchInput.addEventListener('input', () => { if (searchDebounce) clearTimeout(searchDebounce); searchDebounce = setTimeout(() => { diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 50aa170..7a979b7 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -1322,20 +1322,24 @@ App.videos = App.videos || {}; return (best || cards[0]).dataset.videoId || null; }; + // Puts a query in the box without searching for it. The clear button is + // kept in sync here rather than by re-dispatching an `input` event, which + // would re-trigger the debounced reload listener behind it. + App.videos.setSearchBox = function(value) { + const searchInput = document.getElementById('search-input'); + if (!searchInput) return; + if (searchInput.value !== value) searchInput.value = value; + const clearBtn = document.getElementById('search-clear-btn'); + if (clearBtn) { + const hasValue = searchInput.value.trim().length > 0; + clearBtn.classList.toggle('is-visible', hasValue); + clearBtn.disabled = !hasValue; + } + }; + App.videos.handleSearch = function(value) { if (typeof value === 'string') { - const searchInput = document.getElementById('search-input'); - if (searchInput && searchInput.value !== value) { - searchInput.value = value; - } - // Keep the clear button in sync without re-dispatching an `input` - // event (which would re-trigger the debounced reload listener). - const clearBtn = document.getElementById('search-clear-btn'); - if (searchInput && clearBtn) { - const hasValue = searchInput.value.trim().length > 0; - clearBtn.classList.toggle('is-visible', hasValue); - clearBtn.disabled = !hasValue; - } + App.videos.setSearchBox(value); } // Every search worth running is a search worth remembering: this is // the one place they all come through -- the box, a tag, the command diff --git a/tests/smoke_saved_searches.py b/tests/smoke_saved_searches.py new file mode 100644 index 0000000..1d799b9 --- /dev/null +++ b/tests/smoke_saved_searches.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""A whole search -- source, channel, filters, query -- can be kept and come back to. + +Run against a locally running backend: + + backend/main.py & + .venv/bin/python tests/smoke_saved_searches.py + +Both the status and the listing are fixtures, so the test owns what "new" +means: it changes the ids the listing hands back and then asks the client how +many of them it has never shown. +""" +import json +import sys +from playwright.sync_api import sync_playwright + +BASE = "http://127.0.0.1:5000/" +SERVER = "https://fixture.test" +CHANNEL = "clips" + +STATUS = { + "online": True, + "channels": [{ + "id": CHANNEL, + "name": "Fixture Clips", + "description": "A channel that only exists for this test.", + "options": [{ + "id": "sort", + "title": "Sort", + "options": [ + {"id": "latest", "title": "Latest"}, + {"id": "views", "title": "Most viewed"}, + ], + }], + }], +} + +SEED = """([server, channel]) => { + localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] })); + localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } })); + localStorage.removeItem('session'); + localStorage.removeItem('searchHistory'); + localStorage.removeItem('savedSearches'); +}""" + +SAVED = """() => JSON.parse(localStorage.getItem('savedSearches') || '[]')""" + +BADGE = """() => { + const badge = document.getElementById('saved-searches-badge'); + return { hidden: badge.hidden, text: badge.textContent }; +}""" + +ROWS = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')).map((row) => ({ + name: row.querySelector('.saved-row-name') ? row.querySelector('.saved-row-name').textContent : '', + count: row.querySelector('.saved-count').textContent, + quiet: row.querySelector('.saved-count').classList.contains('is-quiet'), + meta: row.querySelector('.saved-row-meta').textContent, + stamp: row.querySelector('.saved-row-stamp').textContent +}))""" + +IS_SAVED = """() => document.getElementById('search-save-btn').classList.contains('is-saved')""" +PANEL_OPEN = """() => document.getElementById('saved-panel').classList.contains('open')""" +SESSION = """() => { + const s = JSON.parse(localStorage.getItem('session')); + return { channel: s.channel.id, sort: s.options.sort ? s.options.sort.id : null }; +}""" + + +class Checks: + def __init__(self): + self.failed = 0 + + def ok(self, label, condition, detail=""): + mark = "PASS" if condition else "FAIL" + if not condition: + self.failed += 1 + print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else "")) + + +def main(): + c = Checks() + asked = [] + # Which ids the listing hands back. Shifting this window is how the test + # says "three new videos were uploaded". + offset = {"at": 0, "fail": False} + + def items(query): + start = offset["at"] + return [{"id": f"vid-{i}", "title": f"{query or 'anything'} {i}", + "url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL, + "duration": 1, "isLive": False, "tags": []} + for i in range(start, start + 12)] + + with sync_playwright() as p: + browser = p.chromium.launch(args=[ + "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", + "--renderer-process-limit=1", "--js-flags=--max-old-space-size=512", + ]) + page = browser.new_page(viewport={"width": 1180, "height": 920}) + crashes = [] + page.on("pageerror", lambda e: crashes.append(str(e))) + + def status(route): + route.fulfill(status=200, content_type="application/json", body=json.dumps(STATUS)) + + def listing(route): + try: + body = json.loads(route.request.post_data or "{}") + except Exception: + body = {} + asked.append(body) + if offset["fail"]: + route.fulfill(status=500, content_type="application/json", body="{}") + return + route.fulfill(status=200, content_type="application/json", body=json.dumps( + {"items": items(body.get("query", "")), "pageInfo": {"hasNextPage": False}})) + + page.route("**/api/status", status) + page.route("**/api/videos", listing) + page.goto(BASE, wait_until="domcontentloaded") + page.evaluate(SEED, [SERVER, CHANNEL]) + page.goto(BASE, wait_until="load") + page.wait_for_selector(".video-card", timeout=30000) + page.wait_for_timeout(400) + + print("\nthe feature is there at all") + c.ok("the module loaded", page.evaluate("() => typeof App.savedSearches.saveCurrent") == 'function') + c.ok("with nothing saved, the header count is out", page.evaluate(BADGE)["hidden"] is True) + c.ok("and the bookmark is unlit", page.evaluate(IS_SAVED) is False) + + print("\nkeeping the search that is on screen") + page.fill("#search-input", "") + page.type("#search-input", "sauna", delay=30) + page.wait_for_timeout(900) + page.click("#search-save-btn") + page.wait_for_timeout(200) + saved = page.evaluate(SAVED) + c.ok("one search was kept", len(saved) == 1, str(saved)) + entry = saved[0] if saved else {} + c.ok("with the words in it", entry.get("query") == "sauna", str(entry.get("query"))) + c.ok("the channel it was run against", entry.get("channelId") == CHANNEL, str(entry.get("channelId"))) + c.ok("the source", entry.get("server") == SERVER, str(entry.get("server"))) + c.ok("and the filters as they stood", entry.get("options") == {"sort": "latest"}, str(entry.get("options"))) + c.ok("it is named after the search", entry.get("name") == "sauna", str(entry.get("name"))) + c.ok("what is on screen counts as seen", len(entry.get("seenIds") or []) == 12, str(len(entry.get("seenIds") or []))) + c.ok("so nothing is new yet", entry.get("newCount") == 0 and page.evaluate(BADGE)["hidden"] is True) + c.ok("and the bookmark is lit", page.evaluate(IS_SAVED) is True) + + print("\nthe bookmark is about this search, not just this channel") + page.fill("#search-input", "") + page.type("#search-input", "pool", delay=30) + page.wait_for_timeout(300) + c.ok("typing something else puts it out", page.evaluate(IS_SAVED) is False) + page.fill("#search-input", "") + page.type("#search-input", "sauna", delay=30) + page.wait_for_timeout(300) + c.ok("coming back to it lights it again", page.evaluate(IS_SAVED) is True) + + print("\nand a filter is part of it too") + page.evaluate("""() => { + const s = JSON.parse(localStorage.getItem('session')); + s.options.sort = { id: 'views', title: 'Most viewed' }; + localStorage.setItem('session', JSON.stringify(s)); + App.ui.renderMenu(); + }""") + page.wait_for_timeout(200) + c.ok("a different sort is a different search", page.evaluate(IS_SAVED) is False) + + print("\nthree videos turn up while nobody is looking") + offset["at"] = 3 + asked.clear() + page.evaluate("() => App.savedSearches.check({ force: true })") + page.wait_for_timeout(900) + saved = page.evaluate(SAVED) + c.ok("the check asked for the saved search, not what is on screen", + any(b.get("query") == "sauna" and b.get("sort") == "latest" for b in asked), str(asked)) + c.ok("three of them are new", saved[0].get("newCount") == 3, str(saved[0].get("newCount"))) + badge = page.evaluate(BADGE) + c.ok("the header says so", badge["hidden"] is False and badge["text"] == "3", str(badge)) + + print("\na busy day") + offset["at"] = 40 + page.evaluate("() => App.savedSearches.check({ force: true })") + page.wait_for_timeout(900) + saved = page.evaluate(SAVED) + badge = page.evaluate(BADGE) + c.ok("every one of them is new", saved[0].get("newCount") == 12, str(saved[0].get("newCount"))) + c.ok("the badge stops counting at 9+", badge["text"] == "9+", str(badge)) + + print("\nonce a day, not once a page view") + asked.clear() + page.evaluate("() => App.savedSearches.check()") + page.wait_for_timeout(500) + c.ok("a search checked a moment ago is left alone", asked == [], str(asked)) + page.evaluate("""() => { + const all = JSON.parse(localStorage.getItem('savedSearches')); + all[0].lastCheckedAt = Date.now() - 2 * 24 * 60 * 60 * 1000; + localStorage.setItem('savedSearches', JSON.stringify(all)); + }""") + page.evaluate("() => App.savedSearches.check()") + page.wait_for_timeout(900) + c.ok("one last checked two days ago is asked about", + any(b.get("query") == "sauna" for b in asked), str(asked)) + + print("\nthe list") + page.click("#saved-searches-btn") + page.wait_for_timeout(300) + rows = page.evaluate(ROWS) + c.ok("it opens", page.evaluate(PANEL_OPEN) is True) + c.ok("with the saved search in it", len(rows) == 1 and rows[0]["name"] == "sauna", str(rows)) + c.ok("carrying its count", rows[0]["count"] == "9+" and rows[0]["quiet"] is False, str(rows[0])) + c.ok("and saying where it points", + "Fixture Clips" in rows[0]["meta"] and "Latest" in rows[0]["meta"] and "sauna" in rows[0]["meta"], + rows[0]["meta"]) + + print("\ntapping it puts the whole search back") + asked.clear() + page.click("#saved-panel .saved-row-main") + page.wait_for_timeout(1200) + c.ok("the list closes", page.evaluate(PANEL_OPEN) is False) + c.ok("the words are back in the box", + page.evaluate("() => document.getElementById('search-input').value") == "sauna") + session = page.evaluate(SESSION) + c.ok("the filter is back as it was", session == {"channel": CHANNEL, "sort": "latest"}, str(session)) + c.ok("and that is what was fetched", + any(b.get("query") == "sauna" and b.get("sort") == "latest" for b in asked), str(asked)) + saved = page.evaluate(SAVED) + c.ok("opening it is viewing it, so the count goes out", + saved[0].get("newCount") == 0 and page.evaluate(BADGE)["hidden"] is True, str(saved[0])) + c.ok("and what it showed is the new mark", + "vid-40" in (saved[0].get("seenIds") or []), str((saved[0].get("seenIds") or [])[:3])) + c.ok("so the bookmark is lit again", page.evaluate(IS_SAVED) is True) + + print("\nnothing new, said quietly") + page.click("#saved-searches-btn") + page.wait_for_timeout(300) + rows = page.evaluate(ROWS) + c.ok("no number where there is no news", + rows[0]["quiet"] is True and "nothing new" in rows[0]["stamp"].lower(), str(rows[0])) + + print("\nrenaming one") + page.click("#saved-panel .saved-tool[data-act='rename']") + page.wait_for_timeout(250) + page.fill("#saved-panel .saved-rename", "Sauna, newest first") + page.keyboard.press("Enter") + page.wait_for_timeout(300) + saved = page.evaluate(SAVED) + c.ok("the new name is kept", saved[0].get("name") == "Sauna, newest first", str(saved[0].get("name"))) + c.ok("and shown", page.evaluate(ROWS)[0]["name"] == "Sauna, newest first") + + print("\nsaving a second one from the list itself") + page.evaluate("() => App.ui.savedSearches.close()") + page.fill("#search-input", "") + page.type("#search-input", "steam", delay=30) + page.wait_for_timeout(900) + page.click("#saved-searches-btn") + page.wait_for_timeout(250) + page.click("#saved-add-btn") + page.wait_for_timeout(300) + saved = page.evaluate(SAVED) + c.ok("there are two now", len(saved) == 2, str([s.get("name") for s in saved])) + c.ok("the new one is first and named after its query", + saved[0].get("query") == "steam", str(saved[0].get("name"))) + c.ok("and it opens ready to be named", + page.evaluate("() => !!document.querySelector('#saved-panel .saved-rename')")) + page.keyboard.press("Escape") + page.wait_for_timeout(200) + + print("\nremoving one asks first") + page.click("#saved-panel .saved-row:nth-of-type(1) .saved-tool[data-act='delete']") + page.wait_for_timeout(250) + c.ok("the row asks", page.evaluate( + "() => !!document.querySelector('#saved-panel .saved-confirm')")) + page.click("#saved-panel .saved-confirm-btn:not(.is-danger)") + page.wait_for_timeout(250) + c.ok("keeping keeps it", len(page.evaluate(SAVED)) == 2) + page.click("#saved-panel .saved-row:nth-of-type(1) .saved-tool[data-act='delete']") + page.wait_for_timeout(250) + page.click("#saved-panel .saved-confirm-btn.is-danger") + page.wait_for_timeout(250) + saved = page.evaluate(SAVED) + c.ok("removing removes it", len(saved) == 1 and saved[0].get("query") == "sauna", str(saved)) + + print("\na source that does not answer") + page.evaluate("""() => { + const all = JSON.parse(localStorage.getItem('savedSearches')); + all[0].newCount = 0; + localStorage.setItem('savedSearches', JSON.stringify(all)); + }""") + offset["fail"] = True + page.evaluate("() => App.savedSearches.check({ force: true })") + page.wait_for_timeout(1200) + saved = page.evaluate(SAVED) + c.ok("is not a search with nothing new in it", + saved[0].get("unreachable") is True and saved[0].get("newCount") == 0, str(saved[0])) + rows = page.evaluate(ROWS) + c.ok("and the row says as much", "could not be checked" in rows[0]["stamp"].lower(), str(rows[0]["stamp"])) + offset["fail"] = False + + print("\nacross a reload") + page.evaluate("""() => { + const all = JSON.parse(localStorage.getItem('savedSearches')); + all[0].newCount = 4; + all[0].unreachable = false; + all[0].lastCheckedAt = Date.now(); + localStorage.setItem('savedSearches', JSON.stringify(all)); + }""") + page.goto(BASE, wait_until="load") + page.wait_for_selector(".video-card", timeout=30000) + page.wait_for_timeout(600) + badge = page.evaluate(BADGE) + c.ok("the count is on screen straight from storage", + badge["hidden"] is False and badge["text"] == "4", str(badge)) + c.ok("the search survived too", len(page.evaluate(SAVED)) == 1) + + c.ok("nothing threw along the way", not crashes, str(crashes[:2])) + browser.close() + + print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed") + return 1 if c.failed else 0 + + +sys.exit(main())