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

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