Increase slide-show subscription limit to 10000 and improve image loading

This commit is contained in:
Laan Tungir
2026-08-17 06:13:32 -04:00
parent 9bf3909bf0
commit ca38540cfa
5 changed files with 203 additions and 36 deletions
+7 -3
View File
@@ -364,18 +364,22 @@ a.nostr-embed-preview-text:hover {
} }
#divBody { #divBody {
position: relative; position: fixed;
top: max(var(--header-height), var(--header-min-height)); top: max(var(--header-height), var(--header-min-height));
bottom: max(var(--footer-height), var(--footer-min-height));
left: 0;
right: 0;
background-color: var(--background-color); background-color: var(--background-color);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-around; justify-content: space-around;
align-content: space-around; align-content: space-around;
width: 100%; width: auto;
height: 90%;
padding: 20px; padding: 20px;
overflow-y: auto; overflow-y: auto;
box-sizing: border-box;
z-index: 0;
} }
#divFooter { #divFooter {
+18 -4
View File
@@ -743,18 +743,20 @@ function appendTaggedMedia(contentEl, eventData) {
if (!media.video.length) return; if (!media.video.length) return;
const existingVideoSrcs = new Set( const existingVideoSrcs = new Set(
Array.from(contentEl.querySelectorAll('video')).map((el) => el.getAttribute('src')).filter(Boolean) Array.from(contentEl.querySelectorAll('video')).map((el) => el.getAttribute('src') || el.getAttribute('data-src')).filter(Boolean)
); );
media.video.forEach((url) => { media.video.forEach((url) => {
if (existingVideoSrcs.has(url)) return; if (existingVideoSrcs.has(url)) return;
const videoEl = document.createElement('video'); const videoEl = document.createElement('video');
videoEl.className = 'post-video-embed'; videoEl.className = 'post-video-embed';
videoEl.src = url;
videoEl.controls = true; videoEl.controls = true;
videoEl.playsInline = true; videoEl.playsInline = true;
videoEl.preload = 'metadata'; videoEl.preload = 'none';
videoEl.setAttribute('data-src', url);
contentEl.appendChild(videoEl); contentEl.appendChild(videoEl);
/* The global IntersectionObserver in utilities.mjs will promote
* data-src to src when this video scrolls into view. */
}); });
} }
@@ -768,13 +770,25 @@ function applyVideoPlaybackPreferences(contentEl, autoplayVideo = false) {
videoEl.controls = true; videoEl.controls = true;
videoEl.playsInline = true; videoEl.playsInline = true;
videoEl.setAttribute('playsinline', ''); videoEl.setAttribute('playsinline', '');
videoEl.preload = 'metadata';
/* Keep preload='none' for lazy-loaded videos (data-src not yet
* promoted to src). Once src is set, allow metadata preload. */
if (videoEl.getAttribute('data-src') && !videoEl.src) {
videoEl.preload = 'none';
} else {
videoEl.preload = 'metadata';
}
if (autoplayVideo) { if (autoplayVideo) {
videoEl.autoplay = true; videoEl.autoplay = true;
videoEl.muted = true; videoEl.muted = true;
videoEl.setAttribute('autoplay', ''); videoEl.setAttribute('autoplay', '');
videoEl.setAttribute('muted', ''); videoEl.setAttribute('muted', '');
/* If lazy-loaded and not yet visible, force-load now for autoplay. */
const dataSrc = videoEl.getAttribute('data-src');
if (dataSrc && !videoEl.src) {
videoEl.src = dataSrc;
}
const playPromise = videoEl.play?.(); const playPromise = videoEl.play?.();
if (playPromise?.catch) playPromise.catch(() => {}); if (playPromise?.catch) playPromise.catch(() => {});
} else { } else {
+68 -1
View File
@@ -27,6 +27,69 @@ export const objMimeTypes = {
export const arrImageExt = [".apng", ".avif", ".gif", ".jpg", ".jpeg", ".jfif", ".pjpeg", ".pjp", ".png", ".svg", ".webp"]; export const arrImageExt = [".apng", ".avif", ".gif", ".jpg", ".jpeg", ".jfif", ".pjpeg", ".pjp", ".png", ".svg", ".webp"];
/* ── Global lazy-load for <video data-src="..."> elements ──────────────
* WebKitGTK spins up a full GStreamer pipeline (~20+ threads) per <video>
* as soon as `src` is set, even with preload='metadata'. A feed with 11
* videos creates 250+ threads in a single WebKitWebProcess. To avoid this,
* videos are created with `data-src` instead of `src`. This global
* IntersectionObserver + MutationObserver promotes `data-src` to `src`
* only when the video scrolls near the viewport. */
const _lazyVideoIO = (typeof IntersectionObserver !== 'undefined')
? new IntersectionObserver(
(entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const el = entry.target;
const url = el.getAttribute('data-src');
if (url && !el.src) {
el.preload = 'metadata';
el.src = url;
}
obs.unobserve(el);
});
},
{ rootMargin: '300px', threshold: 0 }
)
: null;
if (typeof document !== 'undefined' && _lazyVideoIO) {
/* Observe existing videos with data-src */
document.querySelectorAll('video[data-src]').forEach((v) => _lazyVideoIO.observe(v));
/* Watch for new videos added to the DOM */
const _lazyVideoMO = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== 1) return;
const videos = (node.tagName === 'VIDEO')
? [node]
: (node.querySelectorAll ? Array.from(node.querySelectorAll('video[data-src]')) : []);
videos.forEach((v) => {
if (v.hasAttribute('data-src') && !v.src) {
/* Delay observation until after layout to avoid
* false-positive intersection at (0,0). */
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (v.hasAttribute('data-src') && !v.src) {
_lazyVideoIO.observe(v);
}
});
});
}
});
});
});
});
if (document.body) {
_lazyVideoMO.observe(document.body, { childList: true, subtree: true });
} else {
document.addEventListener('DOMContentLoaded', () => {
_lazyVideoMO.observe(document.body, { childList: true, subtree: true });
document.querySelectorAll('video[data-src]').forEach((v) => _lazyVideoIO.observe(v));
});
}
}
export const htmlCenteringWrapper = `<!DOCTYPE html> export const htmlCenteringWrapper = `<!DOCTYPE html>
<html lang="en" dir="ltr"> <html lang="en" dir="ltr">
<head> <head>
@@ -645,9 +708,13 @@ export const htmlFormatText = (txt) => {
if (rgxImg.test(EachLink)) { if (rgxImg.test(EachLink)) {
chunk = chunk.replace(EachLink, `<img src="${EachLink}">`); chunk = chunk.replace(EachLink, `<img src="${EachLink}">`);
} else if (isVideoUrl(EachLink)) { } else if (isVideoUrl(EachLink)) {
/* Lazy-load: use data-src instead of src to prevent WebKitGTK
* from spinning up GStreamer pipelines for off-screen videos.
* The global IntersectionObserver in utilities.mjs promotes
* data-src to src when the video scrolls into view. */
chunk = chunk.replace( chunk = chunk.replace(
EachLink, EachLink,
`<video class="post-video-embed" src="${EachLink}" controls playsinline preload="metadata"></video>` `<video class="post-video-embed" data-src="${EachLink}" controls playsinline preload="none"></video>`
); );
} else { } else {
chunk = chunk.replace(EachLink, `<a href="${EachLink}" target="_blank" rel="noopener noreferrer">${EachLink}</a>`); chunk = chunk.replace(EachLink, `<a href="${EachLink}" target="_blank" rel="noopener noreferrer">${EachLink}</a>`);
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"VERSION": "v0.7.103", "VERSION": "v0.7.104",
"VERSION_NUMBER": "0.7.103", "VERSION_NUMBER": "0.7.104",
"BUILD_DATE": "2026-08-08T11:27:48.503Z" "BUILD_DATE": "2026-08-17T10:13:32.771Z"
} }
+107 -25
View File
@@ -393,9 +393,7 @@ const versionInfo = await getVersion();
const SLIDESHOW_SECONDS_STORAGE_KEY = 'slideShowSeconds'; const SLIDESHOW_SECONDS_STORAGE_KEY = 'slideShowSeconds';
const SLIDESHOW_MODE_STORAGE_KEY = 'slideShowMode'; const SLIDESHOW_MODE_STORAGE_KEY = 'slideShowMode';
const INITIAL_VISIBLE_IMAGE_COUNT = 200; let visibleImageCount = 200; // updated to screen-fit estimate at load time
const MORE_IMAGES_CHUNK = 200;
let visibleImageCount = INITIAL_VISIBLE_IMAGE_COUNT;
const PUBLIC_READ_RELAYS = [ const PUBLIC_READ_RELAYS = [
'wss://relay.damus.io', 'wss://relay.damus.io',
@@ -668,6 +666,67 @@ const versionInfo = await getVersion();
return Math.max(1, Math.floor(width / minTileWidth)); return Math.max(1, Math.floor(width / minTileWidth));
} }
// Estimated width/height ratio of a typical image tile. 1.0 (square)
// is a reasonable middle-ground for photos, screenshots and memes.
const ESTIMATED_IMAGE_ASPECT_RATIO = 1.0;
// Show more than the strict fit so the masonry grid still looks full
// once real (varying) image heights are loaded.
const SCREEN_FILL_BUFFER = 2.5;
function estimateVisibleImageCount() {
const viewportWidth = window.innerWidth || 1024;
const viewportHeight = window.innerHeight || 768;
let headerHeight = 60;
let footerHeight = 20;
try {
const rootStyle = getComputedStyle(document.documentElement);
headerHeight = Math.max(
parseFloat(rootStyle.getPropertyValue('--header-min-height')) || 60,
parseFloat(rootStyle.getPropertyValue('--header-height')) || 0
);
footerHeight = Math.max(
parseFloat(rootStyle.getPropertyValue('--footer-min-height')) || 20,
parseFloat(rootStyle.getPropertyValue('--footer-height')) || 0
);
} catch (_error) {
// keep default header/footer estimates
}
const availableHeight = Math.max(200, viewportHeight - headerHeight - footerHeight);
const columnCount = getSlideColumnCount();
const gridWidth = divSlideGrid?.clientWidth || viewportWidth;
const tileWidth = Math.max(80, gridWidth / columnCount);
const tileHeight = tileWidth / ESTIMATED_IMAGE_ASPECT_RATIO + 12; // include gap/margin
const rowsThatFit = Math.max(1, Math.floor(availableHeight / tileHeight));
const estimatedCount = Math.ceil(columnCount * rowsThatFit * SCREEN_FILL_BUFFER);
return Math.max(columnCount, estimatedCount);
}
function createTileElements(posts, startIndex, endIndex, totalVisible) {
const columnCount = getSlideColumnCount();
const tiles = [];
for (let i = startIndex; i < endIndex; i += 1) {
const post = posts[i];
const tile = document.createElement('button');
tile.className = 'slideTile';
tile.type = 'button';
tile.dataset.index = String(i);
tile.title = `Open image ${i + 1} of ${totalVisible}`;
const img = document.createElement('img');
img.src = post.imageUrl;
img.alt = 'Post image';
img.loading = 'lazy';
tile.appendChild(img);
tile.addEventListener('click', () => openLightbox(i));
tiles.push({ tile, columnIndex: i % columnCount });
}
return tiles;
}
function renderGrid() { function renderGrid() {
divSlideGrid.innerHTML = ''; divSlideGrid.innerHTML = '';
if (imagePosts.length === 0) { if (imagePosts.length === 0) {
@@ -678,8 +737,6 @@ const versionInfo = await getVersion();
divSlideEmpty.style.display = 'none'; divSlideEmpty.style.display = 'none';
const visiblePosts = getVisibleImagePosts();
const visibleTotal = visiblePosts.length;
const columnCount = getSlideColumnCount(); const columnCount = getSlideColumnCount();
const columns = []; const columns = [];
for (let i = 0; i < columnCount; i += 1) { for (let i = 0; i < columnCount; i += 1) {
@@ -689,25 +746,41 @@ const versionInfo = await getVersion();
divSlideGrid.appendChild(col); divSlideGrid.appendChild(col);
} }
// Row-major visual ordering: const visiblePosts = getVisibleImagePosts();
// 0->col0, 1->col1, ... n->col(n%columnCount) const visibleTotal = visiblePosts.length;
visiblePosts.forEach((post, index) => { const tileEntries = createTileElements(visiblePosts, 0, visibleTotal, visibleTotal);
const tile = document.createElement('button'); tileEntries.forEach(({ tile, columnIndex }) => {
tile.className = 'slideTile'; columns[columnIndex].appendChild(tile);
tile.type = 'button'; });
tile.dataset.index = String(index);
tile.title = `Open image ${index + 1} of ${visibleTotal}`;
const img = document.createElement('img'); updateMoreButton();
img.src = post.imageUrl; }
img.alt = 'Post image';
img.loading = 'lazy';
tile.appendChild(img); function appendMoreTiles() {
tile.addEventListener('click', () => openLightbox(index)); if (imagePosts.length === 0) return;
divSlideEmpty.style.display = 'none';
const targetColumn = columns[index % columnCount]; const existingColumns = divSlideGrid.querySelectorAll('.slideColumn');
targetColumn.appendChild(tile); if (existingColumns.length === 0) {
renderGrid();
return;
}
// Count how many tiles already exist across all columns
let existingTileCount = 0;
existingColumns.forEach((col) => { existingTileCount += col.children.length; });
const visiblePosts = getVisibleImagePosts();
const newTotal = visiblePosts.length;
if (existingTileCount >= newTotal) {
updateMoreButton();
return;
}
const tileEntries = createTileElements(visiblePosts, existingTileCount, newTotal, newTotal);
tileEntries.forEach(({ tile, columnIndex }) => {
existingColumns[columnIndex].appendChild(tile);
}); });
updateMoreButton(); updateMoreButton();
@@ -878,8 +951,14 @@ const versionInfo = await getVersion();
} }
} }
function isReplyEvent(evt) {
if (!evt?.tags || !Array.isArray(evt.tags)) return false;
return evt.tags.some(tag => Array.isArray(tag) && tag[0] === 'e');
}
function upsertNoteIfImage(evt) { function upsertNoteIfImage(evt) {
if (!evt?.id || evt.kind !== 1 || evt.pubkey !== targetPubkey) return false; if (!evt?.id || evt.kind !== 1 || evt.pubkey !== targetPubkey) return false;
if (isReplyEvent(evt)) return false;
const imageUrl = extractFirstDirectImageUrl(evt.content); const imageUrl = extractFirstDirectImageUrl(evt.content);
if (!imageUrl) return false; if (!imageUrl) return false;
@@ -905,6 +984,9 @@ const versionInfo = await getVersion();
initialLoadTimer = null; initialLoadTimer = null;
} }
setLoadingState(false); setLoadingState(false);
// Estimate how many tiles the viewport can hold so we fill the screen
// without loading more than needed.
visibleImageCount = estimateVisibleImageCount();
renderGrid(); renderGrid();
} }
@@ -928,7 +1010,7 @@ const versionInfo = await getVersion();
{ {
kinds: [1], kinds: [1],
authors: [targetPubkey], authors: [targetPubkey],
limit: 2000 limit: 10000
}, },
{ {
closeOnEose: false, closeOnEose: false,
@@ -952,7 +1034,7 @@ const versionInfo = await getVersion();
publicPool = new window.NostrTools.SimplePool(); publicPool = new window.NostrTools.SimplePool();
publicSub = publicPool.subscribeMany( publicSub = publicPool.subscribeMany(
PUBLIC_READ_RELAYS, PUBLIC_READ_RELAYS,
[{ kinds: [1], authors: [targetPubkey], limit: 2000 }], [{ kinds: [1], authors: [targetPubkey], limit: 10000 }],
{ {
onevent: (evt) => { onevent: (evt) => {
const changed = upsertNoteIfImage(evt); const changed = upsertNoteIfImage(evt);
@@ -1077,8 +1159,8 @@ const versionInfo = await getVersion();
if (btnSlideMore) { if (btnSlideMore) {
btnSlideMore.addEventListener('click', () => { btnSlideMore.addEventListener('click', () => {
visibleImageCount += MORE_IMAGES_CHUNK; visibleImageCount += estimateVisibleImageCount();
renderGrid(); appendMoreTiles();
}); });
} }