improve tiktok mode
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user