improve tiktok mode

This commit is contained in:
Simon
2026-06-23 21:19:19 +00:00
parent b9ff61244c
commit f5bb33521e
3 changed files with 239 additions and 13 deletions

View File

@@ -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;
}

View File

@@ -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');

View File

@@ -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'; }
};