improve tiktok mode
This commit is contained in:
@@ -1414,7 +1414,7 @@ body.theme-light .error-toast {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 10px 24px var(--shadow);
|
box-shadow: 0 10px 24px var(--shadow);
|
||||||
z-index: 2600;
|
z-index: 2600;
|
||||||
transition: transform 0.2s ease, background 0.2s ease;
|
transition: transform 0.2s ease, background 0.2s ease, opacity 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-toggle-btn:hover {
|
.mode-toggle-btn:hover {
|
||||||
@@ -1516,12 +1516,26 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
|||||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent);
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-title {
|
.feed-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-title-text {
|
||||||
|
display: inline-block;
|
||||||
|
padding-right: 28px;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-title.is-marquee .feed-title-text {
|
||||||
|
animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-uploader {
|
.feed-uploader {
|
||||||
@@ -1602,7 +1616,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 2600;
|
z-index: 2600;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease, opacity 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-mute-btn:hover {
|
.feed-mute-btn:hover {
|
||||||
@@ -1612,3 +1626,48 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
|||||||
.feed-mute-btn .icon-svg {
|
.feed-mute-btn .icon-svg {
|
||||||
filter: invert(100%) saturate(0%) !important;
|
filter: invert(100%) saturate(0%) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Per-slide favorite (heart) button on the feed HUD right rail. */
|
||||||
|
.feed-fav-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 210px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 6;
|
||||||
|
transition: background 0.2s ease, transform 0.2s ease, opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-fav-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-fav-btn.is-favorite {
|
||||||
|
color: #ff3b30;
|
||||||
|
border-color: rgba(255, 59, 48, 0.6);
|
||||||
|
background: rgba(255, 59, 48, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reels/TikTok mode: HUD auto-hides after a short idle. Elements stay
|
||||||
|
interactive (pointer-events untouched) so the buttons keep working while
|
||||||
|
invisible; any pointer/scroll activity reveals them again (see App.feed). */
|
||||||
|
body.feed-hud-idle .feed-info,
|
||||||
|
body.feed-hud-idle .feed-timeline,
|
||||||
|
body.feed-hud-idle .feed-mute-btn,
|
||||||
|
body.feed-hud-idle .feed-fav-btn,
|
||||||
|
body.feed-hud-idle .mode-toggle-btn {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,26 @@ App.feed = App.feed || {};
|
|||||||
let scrollBound = false;
|
let scrollBound = false;
|
||||||
let scrollRaf = null;
|
let scrollRaf = null;
|
||||||
|
|
||||||
|
// HUD auto-hide: the reels HUD fades out after this much inactivity and
|
||||||
|
// reappears on any pointer movement / tap / scroll. Buttons keep their
|
||||||
|
// pointer-events while hidden, so they stay clickable even when invisible.
|
||||||
|
const HUD_IDLE_MS = 1000;
|
||||||
|
let hudIdleTimer = null;
|
||||||
|
let hudActivityBound = false;
|
||||||
|
|
||||||
|
const scheduleHudHide = function() {
|
||||||
|
if (hudIdleTimer) clearTimeout(hudIdleTimer);
|
||||||
|
hudIdleTimer = setTimeout(() => {
|
||||||
|
hudIdleTimer = null;
|
||||||
|
if (state.feedOpen) document.body.classList.add('feed-hud-idle');
|
||||||
|
}, HUD_IDLE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const wakeHud = function() {
|
||||||
|
document.body.classList.remove('feed-hud-idle');
|
||||||
|
if (state.feedOpen) scheduleHudHide();
|
||||||
|
};
|
||||||
|
|
||||||
const getScroller = () => document.getElementById('feed-scroll');
|
const getScroller = () => document.getElementById('feed-scroll');
|
||||||
const getSentinel = () => document.getElementById('feed-sentinel');
|
const getSentinel = () => document.getElementById('feed-sentinel');
|
||||||
const getTopSpacer = () => document.getElementById('feed-top-spacer');
|
const getTopSpacer = () => document.getElementById('feed-top-spacer');
|
||||||
@@ -47,9 +67,51 @@ App.feed = App.feed || {};
|
|||||||
return Math.min(total - 1, Math.max(0, index));
|
return Math.min(total - 1, Math.max(0, index));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Remembers playback position per video id so scrolling away and back
|
||||||
|
// resumes where the user left off. Slides kept in the window are merely
|
||||||
|
// paused (instant resume); slides whose <video> is torn down to free
|
||||||
|
// resources still have their position restored on reload via applyResume.
|
||||||
|
const KEEP_BEHIND = 2; // slides behind the active one kept loaded & paused
|
||||||
|
const resumeTimes = new Map();
|
||||||
|
|
||||||
|
const slideVideoId = (slide) => (slide && slide._videoData ? slide._videoData.id : null);
|
||||||
|
|
||||||
|
const rememberTime = function(slide, video) {
|
||||||
|
if (!slide || !video || slide.classList.contains('is-live')) return;
|
||||||
|
const id = slideVideoId(slide);
|
||||||
|
if (id == null) return;
|
||||||
|
const t = video.currentTime;
|
||||||
|
if (isFinite(t) && t > 0.5) resumeTimes.set(id, t);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyResume = function(video, videoId, isLive) {
|
||||||
|
if (!video || isLive || videoId == null) return;
|
||||||
|
const t = resumeTimes.get(videoId);
|
||||||
|
if (t == null || t <= 0) return;
|
||||||
|
const seek = () => {
|
||||||
|
let target = t;
|
||||||
|
if (isFinite(video.duration) && video.duration > 0) {
|
||||||
|
target = Math.min(t, video.duration - 0.25);
|
||||||
|
}
|
||||||
|
try { video.currentTime = Math.max(0, target); } catch (err) { /* ignore */ }
|
||||||
|
};
|
||||||
|
if (video.readyState >= 1) seek();
|
||||||
|
else video.addEventListener('loadedmetadata', seek, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pauses a slide but keeps its <video> loaded so returning to it resumes
|
||||||
|
// instantly from the exact frame it was paused on.
|
||||||
|
const pauseSlide = function(slide) {
|
||||||
|
const video = slide.querySelector('.feed-video');
|
||||||
|
slide.classList.remove('is-active');
|
||||||
|
if (video && !video.paused) video.pause();
|
||||||
|
rememberTime(slide, video);
|
||||||
|
};
|
||||||
|
|
||||||
const destroySlidePlayback = function(slide) {
|
const destroySlidePlayback = function(slide) {
|
||||||
const video = slide.querySelector('.feed-video');
|
const video = slide.querySelector('.feed-video');
|
||||||
slide.classList.remove('is-active');
|
slide.classList.remove('is-active');
|
||||||
|
rememberTime(slide, video);
|
||||||
const fill = slide.querySelector('.feed-timeline-fill');
|
const fill = slide.querySelector('.feed-timeline-fill');
|
||||||
if (fill) fill.style.width = '0%';
|
if (fill) fill.style.width = '0%';
|
||||||
const handle = slide.querySelector('.feed-timeline-handle');
|
const handle = slide.querySelector('.feed-timeline-handle');
|
||||||
@@ -65,6 +127,28 @@ App.feed = App.feed || {};
|
|||||||
slide.classList.remove('is-loaded');
|
slide.classList.remove('is-loaded');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Single-line feed title that scrolls horizontally when it overflows.
|
||||||
|
// Driven off the overflow distance so every title scrolls at the same
|
||||||
|
// gentle speed, matching the grid-card marquee (see App.videos.measureTitle).
|
||||||
|
const measureFeedTitle = function(slide) {
|
||||||
|
if (!slide) return;
|
||||||
|
const wrap = slide.querySelector('.feed-title');
|
||||||
|
const text = slide.querySelector('.feed-title-text');
|
||||||
|
if (!wrap || !text) return;
|
||||||
|
const overflow = text.scrollWidth - wrap.clientWidth;
|
||||||
|
if (overflow > 4) {
|
||||||
|
const distance = overflow + 16;
|
||||||
|
const MARQUEE_SPEED = 28; // px per second
|
||||||
|
const duration = Math.max(6, distance / MARQUEE_SPEED);
|
||||||
|
text.style.setProperty('--marquee-distance', `${distance}px`);
|
||||||
|
text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
|
||||||
|
wrap.classList.add('is-marquee');
|
||||||
|
} else {
|
||||||
|
wrap.classList.remove('is-marquee');
|
||||||
|
text.style.removeProperty('--marquee-distance');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setTimelinePosition = function(slide, ratio) {
|
const setTimelinePosition = function(slide, ratio) {
|
||||||
const fill = slide.querySelector('.feed-timeline-fill');
|
const fill = slide.querySelector('.feed-timeline-fill');
|
||||||
const handle = slide.querySelector('.feed-timeline-handle');
|
const handle = slide.querySelector('.feed-timeline-handle');
|
||||||
@@ -144,6 +228,7 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
video.muted = state.feedMuted;
|
video.muted = state.feedMuted;
|
||||||
video.preload = 'auto';
|
video.preload = 'auto';
|
||||||
|
applyResume(video, videoData && videoData.id, resolved.isLive);
|
||||||
|
|
||||||
const startPlay = () => {
|
const startPlay = () => {
|
||||||
if (!autoplay) return;
|
if (!autoplay) return;
|
||||||
@@ -208,12 +293,14 @@ App.feed = App.feed || {};
|
|||||||
slide._index = index;
|
slide._index = index;
|
||||||
const uploaderText = v.uploader || '';
|
const uploaderText = v.uploader || '';
|
||||||
const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : '';
|
const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : '';
|
||||||
|
const favKey = App.favorites ? App.favorites.getKey(v) : null;
|
||||||
slide.innerHTML = `
|
slide.innerHTML = `
|
||||||
<img class="feed-poster" src="${v.thumb || ''}" alt="" loading="lazy" decoding="async">
|
<img class="feed-poster" src="${v.thumb || ''}" alt="" loading="lazy" decoding="async">
|
||||||
<video class="feed-video" muted playsinline webkit-playsinline loop preload="none"></video>
|
<video class="feed-video" muted playsinline webkit-playsinline loop preload="none"></video>
|
||||||
${liveBadge}
|
${liveBadge}
|
||||||
|
${favKey ? `<button class="favorite-btn feed-fav-btn" type="button" data-fav-key="${favKey}"></button>` : ''}
|
||||||
<div class="feed-info">
|
<div class="feed-info">
|
||||||
<h4 class="feed-title">${v.title || ''}</h4>
|
<h4 class="feed-title"><span class="feed-title-text">${v.title || ''}</span></h4>
|
||||||
${uploaderText ? `<p class="feed-uploader">${uploaderText}</p>` : ''}
|
${uploaderText ? `<p class="feed-uploader">${uploaderText}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="feed-timeline" role="slider" aria-label="Seek">
|
<div class="feed-timeline" role="slider" aria-label="Seek">
|
||||||
@@ -227,6 +314,15 @@ App.feed = App.feed || {};
|
|||||||
App.videos.attachNoReferrerRetry(poster);
|
App.videos.attachNoReferrerRetry(poster);
|
||||||
bindTimeline(slide, slide.querySelector('.feed-video'));
|
bindTimeline(slide, slide.querySelector('.feed-video'));
|
||||||
|
|
||||||
|
const favBtn = slide.querySelector('.feed-fav-btn');
|
||||||
|
if (favBtn && App.favorites) {
|
||||||
|
App.favorites.setButtonState(favBtn, App.favorites.getSet().has(favKey));
|
||||||
|
favBtn.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
App.favorites.toggle(v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Insert before the rendered slide with the next-highest index so DOM
|
// Insert before the rendered slide with the next-highest index so DOM
|
||||||
// order always matches index order; fall back to the sentinel.
|
// order always matches index order; fall back to the sentinel.
|
||||||
let ref = getSentinel();
|
let ref = getSentinel();
|
||||||
@@ -297,12 +393,19 @@ App.feed = App.feed || {};
|
|||||||
});
|
});
|
||||||
|
|
||||||
const activeSlide = slidesByIndex.get(clamped);
|
const activeSlide = slidesByIndex.get(clamped);
|
||||||
if (activeSlide) loadSlideSource(activeSlide, activeSlide._videoData, true);
|
if (activeSlide) {
|
||||||
|
loadSlideSource(activeSlide, activeSlide._videoData, true);
|
||||||
|
requestAnimationFrame(() => measureFeedTitle(activeSlide));
|
||||||
|
}
|
||||||
|
|
||||||
slidesByIndex.forEach((slide, i) => {
|
slidesByIndex.forEach((slide, i) => {
|
||||||
if (i === clamped) return;
|
if (i === clamped) return;
|
||||||
if (i > clamped && i <= clamped + PRELOAD_COUNT) {
|
if (i > clamped && i <= clamped + PRELOAD_COUNT) {
|
||||||
loadSlideSource(slide, slide._videoData, false);
|
loadSlideSource(slide, slide._videoData, false);
|
||||||
|
} else if (i >= clamped - KEEP_BEHIND && i < clamped) {
|
||||||
|
// Recently-watched slides stay loaded but paused so scrolling
|
||||||
|
// back resumes seamlessly from where it was paused.
|
||||||
|
if (slide.classList.contains('is-loaded')) pauseSlide(slide);
|
||||||
} else if (slide.classList.contains('is-loaded')) {
|
} else if (slide.classList.contains('is-loaded')) {
|
||||||
destroySlidePlayback(slide);
|
destroySlidePlayback(slide);
|
||||||
}
|
}
|
||||||
@@ -312,6 +415,7 @@ App.feed = App.feed || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onScroll = function() {
|
const onScroll = function() {
|
||||||
|
wakeHud();
|
||||||
if (scrollRaf) return;
|
if (scrollRaf) return;
|
||||||
scrollRaf = requestAnimationFrame(() => {
|
scrollRaf = requestAnimationFrame(() => {
|
||||||
scrollRaf = null;
|
scrollRaf = null;
|
||||||
@@ -333,6 +437,8 @@ App.feed = App.feed || {};
|
|||||||
if (spacer) spacer.style.height = `${start * h}px`;
|
if (spacer) spacer.style.height = `${start * h}px`;
|
||||||
const scroller = getScroller();
|
const scroller = getScroller();
|
||||||
if (scroller) scroller.scrollTop = state.feedActiveIndex * h;
|
if (scroller) scroller.scrollTop = state.feedActiveIndex * h;
|
||||||
|
const activeSlide = slidesByIndex.get(state.feedActiveIndex);
|
||||||
|
if (activeSlide) measureFeedTitle(activeSlide);
|
||||||
};
|
};
|
||||||
|
|
||||||
App.feed.isOpen = function() {
|
App.feed.isOpen = function() {
|
||||||
@@ -353,6 +459,7 @@ App.feed = App.feed || {};
|
|||||||
slide.remove();
|
slide.remove();
|
||||||
});
|
});
|
||||||
slidesByIndex.clear();
|
slidesByIndex.clear();
|
||||||
|
resumeTimes.clear();
|
||||||
state.feedActiveIndex = -1;
|
state.feedActiveIndex = -1;
|
||||||
const spacer = getTopSpacer();
|
const spacer = getTopSpacer();
|
||||||
if (spacer) spacer.style.height = '0px';
|
if (spacer) spacer.style.height = '0px';
|
||||||
@@ -381,6 +488,13 @@ App.feed = App.feed || {};
|
|||||||
scrollBound = true;
|
scrollBound = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hudActivityBound) {
|
||||||
|
container.addEventListener('mousemove', wakeHud, { passive: true });
|
||||||
|
container.addEventListener('pointerdown', wakeHud, { passive: true });
|
||||||
|
container.addEventListener('touchstart', wakeHud, { passive: true });
|
||||||
|
hudActivityBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Start from whichever grid video the user was looking at.
|
// Start from whichever grid video the user was looking at.
|
||||||
let startIndex = 0;
|
let startIndex = 0;
|
||||||
if (startVideoId != null) {
|
if (startVideoId != null) {
|
||||||
@@ -396,12 +510,18 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
App.feed.updateToggleButton();
|
App.feed.updateToggleButton();
|
||||||
App.feed.updateMuteButton();
|
App.feed.updateMuteButton();
|
||||||
|
wakeHud();
|
||||||
};
|
};
|
||||||
|
|
||||||
App.feed.close = function() {
|
App.feed.close = function() {
|
||||||
const container = document.getElementById('feed-view');
|
const container = document.getElementById('feed-view');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
state.feedOpen = false;
|
state.feedOpen = false;
|
||||||
|
if (hudIdleTimer) {
|
||||||
|
clearTimeout(hudIdleTimer);
|
||||||
|
hudIdleTimer = null;
|
||||||
|
}
|
||||||
|
document.body.classList.remove('feed-hud-idle');
|
||||||
slidesByIndex.forEach((slide) => destroySlidePlayback(slide));
|
slidesByIndex.forEach((slide) => destroySlidePlayback(slide));
|
||||||
container.classList.remove('open');
|
container.classList.remove('open');
|
||||||
container.setAttribute('aria-hidden', 'true');
|
container.setAttribute('aria-hidden', 'true');
|
||||||
|
|||||||
@@ -539,14 +539,18 @@ App.videos = App.videos || {};
|
|||||||
// * mounting/unmounting a card never moves any other card -- positions are
|
// * mounting/unmounting a card never moves any other card -- positions are
|
||||||
// assigned once and never change -- so there is no scroll jank.
|
// assigned once and never change -- so there is no scroll jank.
|
||||||
//
|
//
|
||||||
// Card heights are deterministic (single-line title, fixed 16:9 thumbnail,
|
// Thumbnails keep their natural aspect ratio (not cropped), so a card's real
|
||||||
// optional tag/uploader/duration rows), so we measure one card per distinct
|
// height isn't known until its image loads. We place each card with a 16:9
|
||||||
// "shape" and reuse that height. Packing is shortest-column-first.
|
// estimate first, then once the image loads we correct that card's height
|
||||||
|
// and shift only the cards below it *in the same column* (each column is an
|
||||||
|
// independent vertical stack), so a correction never disturbs other columns
|
||||||
|
// or anything above it. Columns are assigned once and never change.
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
App.virtualGrid = (function() {
|
App.virtualGrid = (function() {
|
||||||
const mounted = new Map(); // loadedVideos index -> card element
|
const mounted = new Map(); // loadedVideos index -> card element
|
||||||
const layout = []; // index -> { top, left, width, height }
|
const layout = []; // index -> { top, left, width, height, col, posInCol }
|
||||||
const heightCache = new Map(); // shape signature -> measured px height
|
const heightCache = new Map(); // shape signature -> estimated px height
|
||||||
|
let colItems = []; // col -> ordered list of item indices in that column
|
||||||
let colBottoms = []; // running bottom y of each column
|
let colBottoms = []; // running bottom y of each column
|
||||||
let cols = 0, colWidth = 0, gap = 16, padX = 24, padY = 24;
|
let cols = 0, colWidth = 0, gap = 16, padX = 24, padY = 24;
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
@@ -582,8 +586,10 @@ App.videos = App.videos || {};
|
|||||||
v.uploader ? 1 : 0, (v.duration > 0) ? 1 : 0].join('|');
|
v.uploader ? 1 : 0, (v.duration > 0) ? 1 : 0].join('|');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Height of a card of `v`'s shape at the current column width. Measured
|
// Estimated height of a card of `v`'s shape at the current column width,
|
||||||
// once per shape via a hidden probe, then cached.
|
// using the CSS 16:9 thumbnail placeholder (the image isn't loaded in the
|
||||||
|
// probe). Measured once per shape, then cached. The real height replaces
|
||||||
|
// this per-card once the thumbnail loads (see correct()).
|
||||||
const heightOf = function(v) {
|
const heightOf = function(v) {
|
||||||
const sig = signatureOf(v);
|
const sig = signatureOf(v);
|
||||||
const cached = heightCache.get(sig);
|
const cached = heightCache.get(sig);
|
||||||
@@ -611,10 +617,12 @@ App.videos = App.videos || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Assigns positions to items [start, end). Earlier items keep their
|
// Assigns positions to items [start, end). Earlier items keep their
|
||||||
// positions because colBottoms carries forward unchanged.
|
// positions because colBottoms carries forward unchanged. Each item is
|
||||||
|
// assigned to the currently-shortest column and stays there for good.
|
||||||
const packFrom = function(start) {
|
const packFrom = function(start) {
|
||||||
if (!cols) { if (!measureMetrics()) return; }
|
if (!cols) { if (!measureMetrics()) return; }
|
||||||
if (!colBottoms.length) colBottoms = new Array(cols).fill(padY);
|
if (!colBottoms.length) colBottoms = new Array(cols).fill(padY);
|
||||||
|
if (!colItems.length) colItems = Array.from({ length: cols }, () => []);
|
||||||
for (let i = start; i < state.loadedVideos.length; i++) {
|
for (let i = start; i < state.loadedVideos.length; i++) {
|
||||||
const v = state.loadedVideos[i];
|
const v = state.loadedVideos[i];
|
||||||
const h = heightOf(v);
|
const h = heightOf(v);
|
||||||
@@ -627,13 +635,39 @@ App.videos = App.videos || {};
|
|||||||
top,
|
top,
|
||||||
left: padX + col * (colWidth + gap),
|
left: padX + col * (colWidth + gap),
|
||||||
width: colWidth,
|
width: colWidth,
|
||||||
height: h
|
height: h,
|
||||||
|
col,
|
||||||
|
posInCol: colItems[col].length
|
||||||
};
|
};
|
||||||
|
colItems[col].push(i);
|
||||||
colBottoms[col] = top + h + gap;
|
colBottoms[col] = top + h + gap;
|
||||||
}
|
}
|
||||||
setContainerHeight();
|
setContainerHeight();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Replaces item i's estimated height with its real (post-image-load)
|
||||||
|
// height and slides every card below it in the same column by the delta.
|
||||||
|
// Other columns and everything above are untouched -> no global reflow.
|
||||||
|
const correct = function(i) {
|
||||||
|
const card = mounted.get(i);
|
||||||
|
const l = layout[i];
|
||||||
|
if (!card || !l) return;
|
||||||
|
const real = card.getBoundingClientRect().height;
|
||||||
|
if (!real || Math.abs(real - l.height) < 1) return;
|
||||||
|
const delta = real - l.height;
|
||||||
|
l.height = real;
|
||||||
|
const list = colItems[l.col];
|
||||||
|
for (let k = l.posInCol + 1; k < list.length; k++) {
|
||||||
|
const j = list[k];
|
||||||
|
layout[j].top += delta;
|
||||||
|
const mc = mounted.get(j);
|
||||||
|
if (mc) mc.style.top = layout[j].top + 'px';
|
||||||
|
}
|
||||||
|
colBottoms[l.col] += delta;
|
||||||
|
setContainerHeight();
|
||||||
|
scheduleUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
const place = function(card, l) {
|
const place = function(card, l) {
|
||||||
card.style.position = 'absolute';
|
card.style.position = 'absolute';
|
||||||
card.style.top = l.top + 'px';
|
card.style.top = l.top + 'px';
|
||||||
@@ -650,6 +684,17 @@ App.videos = App.videos || {};
|
|||||||
place(card, l);
|
place(card, l);
|
||||||
grid().appendChild(card);
|
grid().appendChild(card);
|
||||||
mounted.set(i, card);
|
mounted.set(i, card);
|
||||||
|
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
|
||||||
|
// its true aspect ratio, then correct this card's height.
|
||||||
|
const img = card.querySelector('img');
|
||||||
|
if (img) {
|
||||||
|
const reveal = () => {
|
||||||
|
img.style.aspectRatio = 'auto';
|
||||||
|
if (mounted.get(i) === card) correct(i);
|
||||||
|
};
|
||||||
|
if (img.complete && img.naturalHeight > 0) requestAnimationFrame(reveal);
|
||||||
|
else img.addEventListener('load', reveal);
|
||||||
|
}
|
||||||
// Marquee + direct-playability probe only matter for on-screen cards.
|
// Marquee + direct-playability probe only matter for on-screen cards.
|
||||||
requestAnimationFrame(() => { if (mounted.get(i) === card) measureTitle(card); });
|
requestAnimationFrame(() => { if (mounted.get(i) === card) measureTitle(card); });
|
||||||
probeObserver.observe(card);
|
probeObserver.observe(card);
|
||||||
@@ -699,6 +744,7 @@ App.videos = App.videos || {};
|
|||||||
mounted.forEach((card, i) => unmount(i));
|
mounted.forEach((card, i) => unmount(i));
|
||||||
layout.length = 0;
|
layout.length = 0;
|
||||||
colBottoms = new Array(cols).fill(padY);
|
colBottoms = new Array(cols).fill(padY);
|
||||||
|
colItems = Array.from({ length: cols }, () => []);
|
||||||
packFrom(0);
|
packFrom(0);
|
||||||
update();
|
update();
|
||||||
};
|
};
|
||||||
@@ -720,6 +766,7 @@ App.videos = App.videos || {};
|
|||||||
mounted.clear();
|
mounted.clear();
|
||||||
layout.length = 0;
|
layout.length = 0;
|
||||||
colBottoms = [];
|
colBottoms = [];
|
||||||
|
colItems = [];
|
||||||
const el = grid();
|
const el = grid();
|
||||||
if (el) { el.innerHTML = ''; el.style.height = '0px'; }
|
if (el) { el.innerHTML = ''; el.style.height = '0px'; }
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user