Files
jacuzzi/frontend/js/feed.js
2026-06-18 13:42:51 +00:00

324 lines
13 KiB
JavaScript

window.App = window.App || {};
App.feed = App.feed || {};
(function() {
const state = App.state;
let observer = null;
let sentinelObserver = null;
const getScroller = () => document.getElementById('feed-scroll');
const destroySlidePlayback = function(slide) {
const video = slide.querySelector('.feed-video');
slide.classList.remove('is-active');
const fill = slide.querySelector('.feed-timeline-fill');
if (fill) fill.style.width = '0%';
const handle = slide.querySelector('.feed-timeline-handle');
if (handle) handle.style.left = '0%';
if (!video) return;
if (video._hlsPlayer) {
video._hlsPlayer.destroy();
video._hlsPlayer = null;
}
video.pause();
video.removeAttribute('src');
video.load();
slide.classList.remove('is-loaded');
};
const setTimelinePosition = function(slide, ratio) {
const fill = slide.querySelector('.feed-timeline-fill');
const handle = slide.querySelector('.feed-timeline-handle');
const pct = `${Math.min(1, Math.max(0, ratio)) * 100}%`;
if (fill) fill.style.width = pct;
if (handle) handle.style.left = pct;
};
const seekFromPointer = function(slide, video, timeline, clientX) {
if (!isFinite(video.duration) || video.duration <= 0) return;
const rect = timeline.getBoundingClientRect();
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
const clamped = Math.min(1, Math.max(0, ratio));
video.currentTime = clamped * video.duration;
setTimelinePosition(slide, clamped);
};
const bindTimeline = function(slide, video) {
const timeline = slide.querySelector('.feed-timeline');
if (!timeline) return;
let scrubbing = false;
video.addEventListener('timeupdate', () => {
if (scrubbing || !isFinite(video.duration) || video.duration <= 0) return;
setTimelinePosition(slide, video.currentTime / video.duration);
});
timeline.addEventListener('pointerdown', (event) => {
scrubbing = true;
timeline.classList.add('is-scrubbing');
timeline.setPointerCapture(event.pointerId);
seekFromPointer(slide, video, timeline, event.clientX);
event.preventDefault();
event.stopPropagation();
});
timeline.addEventListener('pointermove', (event) => {
if (!scrubbing) return;
seekFromPointer(slide, video, timeline, event.clientX);
event.preventDefault();
event.stopPropagation();
});
const stopScrubbing = (event) => {
if (!scrubbing) return;
scrubbing = false;
timeline.classList.remove('is-scrubbing');
if (timeline.hasPointerCapture(event.pointerId)) {
timeline.releasePointerCapture(event.pointerId);
}
event.stopPropagation();
};
timeline.addEventListener('pointerup', stopScrubbing);
timeline.addEventListener('pointercancel', stopScrubbing);
};
const PRELOAD_COUNT = 2;
const loadSlideSource = function(slide, videoData, autoplay) {
const video = slide.querySelector('.feed-video');
if (!video) return;
if (slide.classList.contains('is-loaded')) {
if (autoplay) {
video.muted = state.feedMuted;
const playPromise = video.play();
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
}
return;
}
slide.classList.add('is-loaded');
const resolved = App.videos.resolveStreamSource(videoData);
if (!resolved.url) return;
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : '';
const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}`;
const isHls = /\.m3u8($|\?)/i.test(resolved.url);
const canUseHls = !!(window.Hls && window.Hls.isSupported());
video.muted = state.feedMuted;
video.preload = 'auto';
if (isHls && canUseHls) {
const hls = new window.Hls();
video._hlsPlayer = hls;
hls.loadSource(streamUrl);
hls.attachMedia(video);
hls.on(window.Hls.Events.ERROR, (event, data) => {
if (data && data.fatal && video._hlsPlayer === hls) {
hls.destroy();
video._hlsPlayer = null;
}
});
} else {
video.src = streamUrl;
}
if (autoplay) {
const playPromise = video.play();
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
}
};
const activateSlide = function(slide, videoData) {
loadSlideSource(slide, videoData, true);
};
// Keeps exactly [active, active+1, active+2] loaded: preloads the next
// PRELOAD_COUNT slides and tears down everything else. Centralizing this
// here (instead of letting each slide's own enter/leave intersection
// entry decide) avoids a race where the initial batch of "not
// intersecting" entries for not-yet-visible slides undoes the preload
// we just triggered for them.
const syncWindow = function(activeSlide) {
const scroller = getScroller();
if (!scroller) return;
const slides = Array.from(scroller.querySelectorAll('.feed-slide'));
const index = slides.indexOf(activeSlide);
if (index === -1) return;
const keep = new Set();
for (let i = index; i <= index + PRELOAD_COUNT && i < slides.length; i++) {
keep.add(slides[i]);
}
slides.forEach((slide) => {
if (keep.has(slide)) return;
if (slide.classList.contains('is-loaded')) {
destroySlidePlayback(slide);
}
});
for (let i = index + 1; i <= index + PRELOAD_COUNT && i < slides.length; i++) {
loadSlideSource(slides[i], slides[i]._videoData, false);
}
};
App.feed.isOpen = function() {
return !!state.feedOpen;
};
App.feed.renderSlides = function() {
const scroller = getScroller();
if (!scroller) return;
(state.loadedVideos || []).forEach((v) => {
if (state.feedRenderedIds.has(v.id)) return;
state.feedRenderedIds.add(v.id);
const slide = document.createElement('div');
slide.className = 'feed-slide';
slide.dataset.videoId = v.id;
const uploaderText = v.uploader || '';
slide.innerHTML = `
<img class="feed-poster" src="${v.thumb || ''}" alt="">
<video class="feed-video" muted playsinline webkit-playsinline loop preload="none"></video>
<div class="feed-info">
<h4 class="feed-title">${v.title || ''}</h4>
${uploaderText ? `<p class="feed-uploader">${uploaderText}</p>` : ''}
</div>
<div class="feed-timeline" role="slider" aria-label="Seek">
<div class="feed-timeline-track">
<div class="feed-timeline-fill"></div>
<div class="feed-timeline-handle"></div>
</div>
</div>
`;
const poster = slide.querySelector('.feed-poster');
App.videos.attachNoReferrerRetry(poster);
slide._videoData = v;
bindTimeline(slide, slide.querySelector('.feed-video'));
scroller.insertBefore(slide, document.getElementById('feed-sentinel'));
if (observer) observer.observe(slide);
});
};
App.feed.reset = function() {
const scroller = getScroller();
document.querySelectorAll('.feed-slide').forEach((slide) => {
if (observer) observer.unobserve(slide);
destroySlidePlayback(slide);
slide.remove();
});
state.feedRenderedIds.clear();
state.feedActiveSlide = null;
if (scroller) scroller.scrollTop = 0;
};
App.feed.open = function(startVideoId) {
const container = document.getElementById('feed-view');
const scroller = getScroller();
if (!container || !scroller) return;
state.feedOpen = true;
if (App.player && typeof App.player.close === 'function') {
App.player.close();
}
if (!observer) {
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const slide = entry.target;
if (entry.isIntersecting && entry.intersectionRatio >= 0.6 && state.feedActiveSlide !== slide) {
const previousActive = state.feedActiveSlide;
state.feedActiveSlide = slide;
if (previousActive) previousActive.classList.remove('is-active');
slide.classList.add('is-active');
activateSlide(slide, slide._videoData);
syncWindow(slide);
}
});
}, { threshold: [0, 0.6, 1] });
}
if (!sentinelObserver) {
sentinelObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
App.videos.loadVideos();
}
}, { threshold: 0.01 });
const sentinel = document.getElementById('feed-sentinel');
if (sentinel) sentinelObserver.observe(sentinel);
}
container.classList.add('open');
container.setAttribute('aria-hidden', 'false');
document.body.classList.add('feed-mode-open');
document.body.style.overflow = 'hidden';
App.feed.renderSlides();
if (startVideoId != null) {
const targetSlide = Array.from(scroller.querySelectorAll('.feed-slide'))
.find((slide) => slide.dataset.videoId === String(startVideoId));
if (targetSlide) scroller.scrollTop = targetSlide.offsetTop;
}
document.querySelectorAll('.feed-slide').forEach((slide) => observer.observe(slide));
App.feed.updateToggleButton();
App.feed.updateMuteButton();
};
App.feed.close = function() {
const container = document.getElementById('feed-view');
if (!container) return;
state.feedOpen = false;
document.querySelectorAll('.feed-slide').forEach((slide) => destroySlidePlayback(slide));
state.feedActiveSlide = null;
container.classList.remove('open');
container.setAttribute('aria-hidden', 'true');
document.body.classList.remove('feed-mode-open');
document.body.style.overflow = 'auto';
App.feed.updateToggleButton();
};
App.feed.toggle = function() {
if (state.feedOpen) {
App.feed.close();
} else {
const focusedId = App.videos && typeof App.videos.getFocusedVideoId === 'function'
? App.videos.getFocusedVideoId()
: null;
App.feed.open(focusedId);
}
};
App.feed.toggleMute = function() {
state.feedMuted = !state.feedMuted;
document.querySelectorAll('.feed-video').forEach((video) => {
video.muted = state.feedMuted;
});
App.feed.updateMuteButton();
};
App.feed.updateToggleButton = function() {
const btn = document.getElementById('mode-toggle-btn');
const icon = document.getElementById('mode-toggle-icon');
if (!btn || !icon) return;
const open = !!state.feedOpen;
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
const label = open ? 'Back to grid' : 'Switch to Reels view';
btn.title = label;
icon.alt = label;
icon.src = open
? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/squares-2x2.svg'
: 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/device-phone-mobile.svg';
};
App.feed.updateMuteButton = function() {
const icon = document.getElementById('feed-mute-icon');
if (!icon) return;
icon.src = state.feedMuted
? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg'
: 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-wave.svg';
icon.alt = state.feedMuted ? 'Unmute' : 'Mute';
};
})();