Let the reader have the captured pictures taken again

A frame taken out of a video is kept for good: that is what makes a dead
favorite cheap to scroll past, and it is also the one thing that can go wrong
with no way out. A picture from the wrong moment, or one kept by a version of
this that would keep a frame with nothing in it, stays on the card for the life
of the item.

So Settings grows a Captured Thumbnails section: what is kept and how much room
it takes, and a button that throws the lot away. The cards on screen go back to
the start of the thumbnail ladder rather than waiting to be scrolled out of the
window and back, which is where a fresh capture comes from, and the session's
memory of which items there was no point asking about goes with the pictures --
otherwise "take them again" would quietly skip every item that failed once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
Simon
2026-10-21 21:08:06 +00:00
parent be058dba37
commit 4f772773e1
5 changed files with 106 additions and 0 deletions

View File

@@ -163,6 +163,14 @@
</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">

View File

@@ -200,10 +200,18 @@ App.posters = App.posters || {};
writeIndex();
};
// Throws every captured picture away, and with it this session's memory of
// which items there was no point asking about. That memory is the whole
// reason a dead favorite is cheap to scroll past, so it only goes when the
// reader asks for the pictures to be taken again (Settings -> Captured
// Thumbnails) -- otherwise an item that failed once would be retried on
// every mount for the rest of the session.
App.posters.clear = function() {
readIndex().forEach(drop);
index = [];
writeIndex();
failed.clear();
rechecks.clear();
};
App.posters.stats = function() {

View File

@@ -288,6 +288,11 @@ App.ui = App.ui || {};
if (otherBtn) otherBtn.classList.remove('active');
if (willOpen) {
// What the captured-thumbnail note says is a count, and captures
// happen while the drawer is shut, so it is read as it opens.
if (!isMenu && typeof App.ui.syncPosterCache === 'function') {
App.ui.syncPosterCache();
}
targetDrawer.classList.add('open');
if (targetBtn) targetBtn.classList.add('active');
overlay.classList.add('open');
@@ -1696,6 +1701,56 @@ App.ui = App.ui || {};
});
};
// Settings -> Captured Thumbnails. A favorite whose listed picture is gone
// gets a frame out of the video instead, kept for good so no later session
// pays for it again (see App.posters). Kept for good is also the problem:
// a picture taken from the wrong moment, or from a version of this that
// kept frames with nothing in them, has no other way out. So the reader
// can throw the lot away and have them taken again.
const posterCacheNote = function() {
if (!App.posters || typeof App.posters.stats !== 'function') return '';
const stats = App.posters.stats();
if (!stats.count) {
return 'No pictures taken from videos yet. A favorite gets one when ' +
'its own thumbnail is gone for good.';
}
const plural = stats.count === 1 ? 'picture' : 'pictures';
const kb = Math.max(1, Math.round(stats.bytes / 1024));
return `${stats.count} ${plural} kept, ${kb} KB. Throwing them away has ` +
'each one taken again from the video the next time its card is shown.';
};
App.ui.syncPosterCache = function() {
const status = document.getElementById('clear-posters-status');
if (status) status.textContent = posterCacheNote();
};
App.ui.bindPosterCacheControls = function() {
const button = document.getElementById('clear-posters-btn');
if (!button) return;
App.ui.syncPosterCache();
button.addEventListener('click', () => {
const before = App.posters.stats().count;
App.posters.clear();
// The cards on screen are still showing what was just thrown away,
// so they go back to the start of the thumbnail ladder -- which is
// where a fresh capture comes from.
if (typeof App.videos.refreshThumbnails === 'function') {
App.videos.refreshThumbnails();
}
if (App.favorites && typeof App.favorites.renderBar === 'function') {
App.favorites.renderBar();
}
const status = document.getElementById('clear-posters-status');
if (status) {
status.textContent = before
? `Threw away ${before} ${before === 1 ? 'picture' : 'pictures'}. ` +
'They are being taken again.'
: posterCacheNote();
}
});
};
// 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() {
@@ -1721,6 +1776,7 @@ App.ui = App.ui || {};
App.ui.bindGlobalHandlers = function() {
App.ui.bindBackupImport();
App.ui.bindPosterCacheControls();
App.ui.bindFavoritesControls();
window.toggleDrawer = App.ui.toggleDrawer;

View File

@@ -542,6 +542,20 @@ App.videos = App.videos || {};
}
};
// Puts every thumbnail on the page back to the start of the ladder. For the
// settings action that throws the captured pictures away (see
// App.posters.clear): a card that is already mounted would otherwise keep
// showing the picture that was just discarded until something scrolled it
// out of the window and back.
App.videos.refreshThumbnails = function() {
document.querySelectorAll('img[data-thumb-token]').forEach((img) => {
const video = img._thumbVideo;
if (!video) return;
App.videos.attachThumbnail(img, video.thumb || '',
img.dataset.thumbPage || App.videos.sourcePageUrl(video), video);
});
};
// Points `img` at `url` by whichever route is known to work for its host,
// racing the two the first time that host is seen. `page` is optional: the
// item's own page, asked for a replacement if the picture turns out to be

View File

@@ -278,6 +278,26 @@ def main():
c.ok("the bar card is the captured frame as well",
bar is not None and bar.startswith("data:image/jpeg"), str(bar))
print("\nand the reader can have them taken again")
streamed.clear()
page.click(".settings-toggle")
c.ok("the setting says what is being kept",
"1 picture kept" in page.inner_text("#clear-posters-status"),
page.inner_text("#clear-posters-status"))
page.click("#clear-posters-btn")
c.ok("nothing is kept any more", page.evaluate(CACHE) == [], str(page.evaluate(CACHE)))
c.ok("and it says what it threw away",
"Threw away 1 picture" in page.inner_text("#clear-posters-status"),
page.inner_text("#clear-posters-status"))
page.wait_for_function(CAPTURED, arg="vid-0", timeout=40000)
shot = page.evaluate(SHOT, "vid-0")
c.ok("the card has a frame out of the video again",
shot and shot["src"].startswith("data:image/jpeg"), str(shot))
c.ok("which was fetched for it, this being the point of the setting",
any("/api/stream" in url for url in streamed), str(streamed[:2]))
c.ok("and kept again, as one picture",
len(page.evaluate(CACHE)) == 1, str(page.evaluate(CACHE)))
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()