Increase slide-show subscription limit to 10000 and improve image loading
This commit is contained in:
+7
-3
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user