changed style and increased performance
This commit is contained in:
@@ -781,7 +781,9 @@ body.theme-light .setting-item select option {
|
|||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.grid-container {
|
.grid-container {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
/* Exactly two larger cards per row on phones, rather than packing in
|
||||||
|
several small ones. */
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
grid-auto-rows: 10px;
|
grid-auto-rows: 10px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -887,6 +889,14 @@ body.theme-light .setting-item select option {
|
|||||||
box-shadow: 0 6px 16px var(--shadow);
|
box-shadow: 0 6px 16px var(--shadow);
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
/* Off-screen cards are kept in the DOM (so infinite scroll and scroll
|
||||||
|
position are untouched) but the browser skips their style, layout, and
|
||||||
|
paint work. This is what keeps a grid of hundreds of cards smooth on
|
||||||
|
mobile. `auto` lets the browser remember each card's real rendered height
|
||||||
|
so the scroll height stays stable; the fallback is only a first-paint
|
||||||
|
estimate for cards that have never been on screen. */
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-card:hover {
|
.video-card:hover {
|
||||||
@@ -924,7 +934,7 @@ body.theme-light .setting-item select option {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.video-card.is-title-active .video-title-text {
|
.video-card.is-title-active .video-title-text {
|
||||||
animation: video-title-marquee 10s linear infinite;
|
animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -195,20 +195,36 @@ App.session = App.session || {};
|
|||||||
const config = JSON.parse(localStorage.getItem('config'));
|
const config = JSON.parse(localStorage.getItem('config'));
|
||||||
if (!config || !config.servers) return;
|
if (!config || !config.servers) return;
|
||||||
|
|
||||||
|
const fetchDirectStatus = async (server) => {
|
||||||
|
const directUrl = `${server.replace(/\/$/, '')}/api/status`;
|
||||||
|
const response = await fetch(directUrl);
|
||||||
|
if (!response.ok) throw new Error(`Direct status request failed: ${response.status}`);
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchProxiedStatus = async (server) => {
|
||||||
|
const response = await fetch(`/api/status`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
server: server
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Proxied status request failed: ${response.status}`);
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
const statusPromises = config.servers.map(async (serverObj) => {
|
const statusPromises = config.servers.map(async (serverObj) => {
|
||||||
const server = Object.keys(serverObj)[0];
|
const server = Object.keys(serverObj)[0];
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/status`, {
|
// Try a direct request first, then fall back to the server-side proxy.
|
||||||
method: "POST",
|
try {
|
||||||
body: JSON.stringify({
|
serverObj[server] = await fetchDirectStatus(server);
|
||||||
server: server
|
} catch (directErr) {
|
||||||
}),
|
serverObj[server] = await fetchProxiedStatus(server);
|
||||||
headers: {
|
}
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const status = await response.json();
|
|
||||||
serverObj[server] = status;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
serverObj[server] = {
|
serverObj[server] = {
|
||||||
online: false,
|
online: false,
|
||||||
|
|||||||
@@ -71,10 +71,26 @@ App.videos = App.videos || {};
|
|||||||
const overflow = titleText.scrollWidth - titleWrap.clientWidth;
|
const overflow = titleText.scrollWidth - titleWrap.clientWidth;
|
||||||
if (overflow > 4) {
|
if (overflow > 4) {
|
||||||
card.classList.add('has-marquee');
|
card.classList.add('has-marquee');
|
||||||
titleText.style.setProperty('--marquee-distance', `${overflow + 12}px`);
|
const distance = overflow + 12;
|
||||||
|
titleText.style.setProperty('--marquee-distance', `${distance}px`);
|
||||||
|
// Drive the duration off the distance so every title scrolls at the
|
||||||
|
// same gentle speed (px/sec) instead of a fixed duration that made
|
||||||
|
// longer titles whip past. A floor keeps short titles from snapping.
|
||||||
|
const MARQUEE_SPEED = 28; // px per second
|
||||||
|
const MARQUEE_MIN_DURATION = 6; // seconds
|
||||||
|
const duration = Math.max(MARQUEE_MIN_DURATION, distance / MARQUEE_SPEED);
|
||||||
|
titleText.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
|
||||||
|
// Only marquee cards need the scroll-position observer that picks the
|
||||||
|
// centered card to animate; observing every card made scrolling a
|
||||||
|
// large grid needlessly expensive.
|
||||||
|
if (titleObserver) titleObserver.observe(card);
|
||||||
} else {
|
} else {
|
||||||
card.classList.remove('has-marquee', 'is-title-active');
|
card.classList.remove('has-marquee', 'is-title-active');
|
||||||
titleText.style.removeProperty('--marquee-distance');
|
titleText.style.removeProperty('--marquee-distance');
|
||||||
|
if (titleObserver) {
|
||||||
|
titleObserver.unobserve(card);
|
||||||
|
titleVisibility.delete(card);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updateTitleActive(card);
|
updateTitleActive(card);
|
||||||
};
|
};
|
||||||
@@ -87,6 +103,9 @@ App.videos = App.videos || {};
|
|||||||
document.querySelectorAll('.video-card').forEach((card) => {
|
document.querySelectorAll('.video-card').forEach((card) => {
|
||||||
measureTitle(card);
|
measureTitle(card);
|
||||||
});
|
});
|
||||||
|
// Column width may have changed, re-wrapping titles and changing
|
||||||
|
// card heights, so re-run the (batched) full masonry pass.
|
||||||
|
App.videos.applyMasonryLayout();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -322,7 +341,9 @@ App.videos = App.videos || {};
|
|||||||
const thumb = card.querySelector('img');
|
const thumb = card.querySelector('img');
|
||||||
App.videos.attachNoReferrerRetry(thumb);
|
App.videos.attachNoReferrerRetry(thumb);
|
||||||
if (thumb) {
|
if (thumb) {
|
||||||
thumb.addEventListener('load', App.videos.scheduleMasonryLayout);
|
// Only this card's height can change when its own thumbnail
|
||||||
|
// loads, so reposition just this card instead of the whole grid.
|
||||||
|
thumb.addEventListener('load', () => App.videos.layoutCard(card));
|
||||||
}
|
}
|
||||||
const favoriteBtn = card.querySelector('.favorite-btn');
|
const favoriteBtn = card.querySelector('.favorite-btn');
|
||||||
if (favoriteBtn && favoriteKey) {
|
if (favoriteBtn && favoriteKey) {
|
||||||
@@ -337,6 +358,7 @@ App.videos = App.videos || {};
|
|||||||
if (titleWrap && titleText) {
|
if (titleWrap && titleText) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
measureTitle(card);
|
measureTitle(card);
|
||||||
|
App.videos.layoutCard(card);
|
||||||
});
|
});
|
||||||
card.addEventListener('focusin', () => {
|
card.addEventListener('focusin', () => {
|
||||||
card.dataset.titleFocused = '1';
|
card.dataset.titleFocused = '1';
|
||||||
@@ -355,9 +377,9 @@ App.videos = App.videos || {};
|
|||||||
card.dataset.titleHovered = '0';
|
card.dataset.titleHovered = '0';
|
||||||
updateTitleActive(card);
|
updateTitleActive(card);
|
||||||
});
|
});
|
||||||
} else if (titleObserver) {
|
|
||||||
titleObserver.observe(card);
|
|
||||||
}
|
}
|
||||||
|
// On touch devices the marquee observer is attached lazily by
|
||||||
|
// measureTitle, but only for cards whose title actually overflows.
|
||||||
}
|
}
|
||||||
const uploaderBtn = card.querySelector('.uploader-link');
|
const uploaderBtn = card.querySelector('.uploader-link');
|
||||||
if (uploaderBtn) {
|
if (uploaderBtn) {
|
||||||
@@ -415,7 +437,8 @@ App.videos = App.videos || {};
|
|||||||
state.renderedVideoIds.add(v.id);
|
state.renderedVideoIds.add(v.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
App.videos.scheduleMasonryLayout();
|
// Each new card lays itself out via its own rAF / image-load handler
|
||||||
|
// above, so there is no need to relayout the whole grid here.
|
||||||
if (App.feed && typeof App.feed.renderSlides === 'function') {
|
if (App.feed && typeof App.feed.renderSlides === 'function') {
|
||||||
App.feed.renderSlides();
|
App.feed.renderSlides();
|
||||||
}
|
}
|
||||||
@@ -506,29 +529,64 @@ App.videos = App.videos || {};
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let masonryRaf = null;
|
// Grid track geometry is identical for every card and only changes when the
|
||||||
App.videos.scheduleMasonryLayout = function() {
|
// viewport crosses a breakpoint, so we read it from the DOM once and cache
|
||||||
if (masonryRaf) {
|
// it. Reading getComputedStyle per card (per image load) was a needless
|
||||||
cancelAnimationFrame(masonryRaf);
|
// layout read on the hot path.
|
||||||
}
|
let cachedGridMetrics = null;
|
||||||
masonryRaf = requestAnimationFrame(() => {
|
const getGridMetrics = function() {
|
||||||
masonryRaf = null;
|
if (cachedGridMetrics) return cachedGridMetrics;
|
||||||
App.videos.applyMasonryLayout();
|
const grid = document.getElementById('video-grid');
|
||||||
});
|
if (!grid) return null;
|
||||||
|
const styles = window.getComputedStyle(grid);
|
||||||
|
if (styles.display !== 'grid') return null;
|
||||||
|
const rowHeight = parseInt(styles.getPropertyValue('grid-auto-rows'), 10);
|
||||||
|
const rowGap = parseInt(styles.getPropertyValue('row-gap') || styles.getPropertyValue('gap'), 10) || 0;
|
||||||
|
if (!rowHeight) return null;
|
||||||
|
cachedGridMetrics = { rowHeight, rowGap };
|
||||||
|
return cachedGridMetrics;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const spanFor = function(itemHeight, metrics) {
|
||||||
|
return Math.ceil((itemHeight + metrics.rowGap) / (metrics.rowHeight + metrics.rowGap));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Masonry placement for a single card. Each card's row span is independent
|
||||||
|
// of its siblings, so a newly loaded thumbnail only needs to re-measure its
|
||||||
|
// own card -- not relayout the entire (potentially huge) grid. This is the
|
||||||
|
// O(1) replacement for the old whole-grid pass that ran on every image load.
|
||||||
|
App.videos.layoutCard = function(card) {
|
||||||
|
if (!card) return;
|
||||||
|
const metrics = getGridMetrics();
|
||||||
|
if (!metrics) return;
|
||||||
|
const itemHeight = card.getBoundingClientRect().height;
|
||||||
|
if (!itemHeight) return;
|
||||||
|
card.style.gridRowEnd = `span ${spanFor(itemHeight, metrics)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full relayout, used only when the column width actually changes (resize /
|
||||||
|
// breakpoint), since that re-wraps titles and changes every card's height.
|
||||||
|
// Reads are batched ahead of writes so we don't thrash layout per card the
|
||||||
|
// way the old per-item read-then-write loop did.
|
||||||
|
let masonryRaf = null;
|
||||||
App.videos.applyMasonryLayout = function() {
|
App.videos.applyMasonryLayout = function() {
|
||||||
const grid = document.getElementById('video-grid');
|
const grid = document.getElementById('video-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
const styles = window.getComputedStyle(grid);
|
cachedGridMetrics = null;
|
||||||
if (styles.display !== 'grid') return;
|
const metrics = getGridMetrics();
|
||||||
const rowHeight = parseInt(styles.getPropertyValue('grid-auto-rows'), 10);
|
if (!metrics) return;
|
||||||
const rowGap = parseInt(styles.getPropertyValue('row-gap') || styles.getPropertyValue('gap'), 10) || 0;
|
const cards = Array.from(grid.children);
|
||||||
if (!rowHeight) return;
|
const heights = cards.map((item) => item.getBoundingClientRect().height);
|
||||||
Array.from(grid.children).forEach((item) => {
|
cards.forEach((item, i) => {
|
||||||
const itemHeight = item.getBoundingClientRect().height;
|
if (heights[i]) item.style.gridRowEnd = `span ${spanFor(heights[i], metrics)}`;
|
||||||
const span = Math.ceil((itemHeight + rowGap) / (rowHeight + rowGap));
|
});
|
||||||
item.style.gridRowEnd = `span ${span}`;
|
};
|
||||||
|
|
||||||
|
App.videos.scheduleMasonryLayout = function() {
|
||||||
|
if (masonryRaf) cancelAnimationFrame(masonryRaf);
|
||||||
|
masonryRaf = requestAnimationFrame(() => {
|
||||||
|
masonryRaf = null;
|
||||||
|
App.videos.applyMasonryLayout();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user