improve tiktok mode
This commit is contained in:
@@ -1414,7 +1414,7 @@ body.theme-light .error-toast {
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 24px var(--shadow);
|
||||
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 {
|
||||
@@ -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);
|
||||
color: #fff;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.feed-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
@@ -1602,7 +1616,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 2600;
|
||||
transition: background 0.2s ease;
|
||||
transition: background 0.2s ease, opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.feed-mute-btn:hover {
|
||||
@@ -1612,3 +1626,48 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
.feed-mute-btn .icon-svg {
|
||||
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 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 getSentinel = () => document.getElementById('feed-sentinel');
|
||||
const getTopSpacer = () => document.getElementById('feed-top-spacer');
|
||||
@@ -47,9 +67,51 @@ App.feed = App.feed || {};
|
||||
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 video = slide.querySelector('.feed-video');
|
||||
slide.classList.remove('is-active');
|
||||
rememberTime(slide, video);
|
||||
const fill = slide.querySelector('.feed-timeline-fill');
|
||||
if (fill) fill.style.width = '0%';
|
||||
const handle = slide.querySelector('.feed-timeline-handle');
|
||||
@@ -65,6 +127,28 @@ App.feed = App.feed || {};
|
||||
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 fill = slide.querySelector('.feed-timeline-fill');
|
||||
const handle = slide.querySelector('.feed-timeline-handle');
|
||||
@@ -144,6 +228,7 @@ App.feed = App.feed || {};
|
||||
|
||||
video.muted = state.feedMuted;
|
||||
video.preload = 'auto';
|
||||
applyResume(video, videoData && videoData.id, resolved.isLive);
|
||||
|
||||
const startPlay = () => {
|
||||
if (!autoplay) return;
|
||||
@@ -208,12 +293,14 @@ App.feed = App.feed || {};
|
||||
slide._index = index;
|
||||
const uploaderText = v.uploader || '';
|
||||
const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : '';
|
||||
const favKey = App.favorites ? App.favorites.getKey(v) : null;
|
||||
slide.innerHTML = `
|
||||
<img class="feed-poster" src="${v.thumb || ''}" alt="" loading="lazy" decoding="async">
|
||||
<video class="feed-video" muted playsinline webkit-playsinline loop preload="none"></video>
|
||||
${liveBadge}
|
||||
${favKey ? `<button class="favorite-btn feed-fav-btn" type="button" data-fav-key="${favKey}"></button>` : ''}
|
||||
<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>` : ''}
|
||||
</div>
|
||||
<div class="feed-timeline" role="slider" aria-label="Seek">
|
||||
@@ -227,6 +314,15 @@ App.feed = App.feed || {};
|
||||
App.videos.attachNoReferrerRetry(poster);
|
||||
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
|
||||
// order always matches index order; fall back to the sentinel.
|
||||
let ref = getSentinel();
|
||||
@@ -297,12 +393,19 @@ App.feed = App.feed || {};
|
||||
});
|
||||
|
||||
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) => {
|
||||
if (i === clamped) return;
|
||||
if (i > clamped && i <= clamped + PRELOAD_COUNT) {
|
||||
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')) {
|
||||
destroySlidePlayback(slide);
|
||||
}
|
||||
@@ -312,6 +415,7 @@ App.feed = App.feed || {};
|
||||
};
|
||||
|
||||
const onScroll = function() {
|
||||
wakeHud();
|
||||
if (scrollRaf) return;
|
||||
scrollRaf = requestAnimationFrame(() => {
|
||||
scrollRaf = null;
|
||||
@@ -333,6 +437,8 @@ App.feed = App.feed || {};
|
||||
if (spacer) spacer.style.height = `${start * h}px`;
|
||||
const scroller = getScroller();
|
||||
if (scroller) scroller.scrollTop = state.feedActiveIndex * h;
|
||||
const activeSlide = slidesByIndex.get(state.feedActiveIndex);
|
||||
if (activeSlide) measureFeedTitle(activeSlide);
|
||||
};
|
||||
|
||||
App.feed.isOpen = function() {
|
||||
@@ -353,6 +459,7 @@ App.feed = App.feed || {};
|
||||
slide.remove();
|
||||
});
|
||||
slidesByIndex.clear();
|
||||
resumeTimes.clear();
|
||||
state.feedActiveIndex = -1;
|
||||
const spacer = getTopSpacer();
|
||||
if (spacer) spacer.style.height = '0px';
|
||||
@@ -381,6 +488,13 @@ App.feed = App.feed || {};
|
||||
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.
|
||||
let startIndex = 0;
|
||||
if (startVideoId != null) {
|
||||
@@ -396,12 +510,18 @@ App.feed = App.feed || {};
|
||||
|
||||
App.feed.updateToggleButton();
|
||||
App.feed.updateMuteButton();
|
||||
wakeHud();
|
||||
};
|
||||
|
||||
App.feed.close = function() {
|
||||
const container = document.getElementById('feed-view');
|
||||
if (!container) return;
|
||||
state.feedOpen = false;
|
||||
if (hudIdleTimer) {
|
||||
clearTimeout(hudIdleTimer);
|
||||
hudIdleTimer = null;
|
||||
}
|
||||
document.body.classList.remove('feed-hud-idle');
|
||||
slidesByIndex.forEach((slide) => destroySlidePlayback(slide));
|
||||
container.classList.remove('open');
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
|
||||
@@ -539,14 +539,18 @@ App.videos = App.videos || {};
|
||||
// * mounting/unmounting a card never moves any other card -- positions are
|
||||
// assigned once and never change -- so there is no scroll jank.
|
||||
//
|
||||
// Card heights are deterministic (single-line title, fixed 16:9 thumbnail,
|
||||
// optional tag/uploader/duration rows), so we measure one card per distinct
|
||||
// "shape" and reuse that height. Packing is shortest-column-first.
|
||||
// Thumbnails keep their natural aspect ratio (not cropped), so a card's real
|
||||
// height isn't known until its image loads. We place each card with a 16:9
|
||||
// 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() {
|
||||
const mounted = new Map(); // loadedVideos index -> card element
|
||||
const layout = []; // index -> { top, left, width, height }
|
||||
const heightCache = new Map(); // shape signature -> measured px height
|
||||
const layout = []; // index -> { top, left, width, height, col, posInCol }
|
||||
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 cols = 0, colWidth = 0, gap = 16, padX = 24, padY = 24;
|
||||
let initialized = false;
|
||||
@@ -582,8 +586,10 @@ App.videos = App.videos || {};
|
||||
v.uploader ? 1 : 0, (v.duration > 0) ? 1 : 0].join('|');
|
||||
};
|
||||
|
||||
// Height of a card of `v`'s shape at the current column width. Measured
|
||||
// once per shape via a hidden probe, then cached.
|
||||
// Estimated height of a card of `v`'s shape at the current column width,
|
||||
// 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 sig = signatureOf(v);
|
||||
const cached = heightCache.get(sig);
|
||||
@@ -611,10 +617,12 @@ App.videos = App.videos || {};
|
||||
};
|
||||
|
||||
// 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) {
|
||||
if (!cols) { if (!measureMetrics()) return; }
|
||||
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++) {
|
||||
const v = state.loadedVideos[i];
|
||||
const h = heightOf(v);
|
||||
@@ -627,13 +635,39 @@ App.videos = App.videos || {};
|
||||
top,
|
||||
left: padX + col * (colWidth + gap),
|
||||
width: colWidth,
|
||||
height: h
|
||||
height: h,
|
||||
col,
|
||||
posInCol: colItems[col].length
|
||||
};
|
||||
colItems[col].push(i);
|
||||
colBottoms[col] = top + h + gap;
|
||||
}
|
||||
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) {
|
||||
card.style.position = 'absolute';
|
||||
card.style.top = l.top + 'px';
|
||||
@@ -650,6 +684,17 @@ App.videos = App.videos || {};
|
||||
place(card, l);
|
||||
grid().appendChild(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.
|
||||
requestAnimationFrame(() => { if (mounted.get(i) === card) measureTitle(card); });
|
||||
probeObserver.observe(card);
|
||||
@@ -699,6 +744,7 @@ App.videos = App.videos || {};
|
||||
mounted.forEach((card, i) => unmount(i));
|
||||
layout.length = 0;
|
||||
colBottoms = new Array(cols).fill(padY);
|
||||
colItems = Array.from({ length: cols }, () => []);
|
||||
packFrom(0);
|
||||
update();
|
||||
};
|
||||
@@ -720,6 +766,7 @@ App.videos = App.videos || {};
|
||||
mounted.clear();
|
||||
layout.length = 0;
|
||||
colBottoms = [];
|
||||
colItems = [];
|
||||
const el = grid();
|
||||
if (el) { el.innerHTML = ''; el.style.height = '0px'; }
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user