@@ -145,6 +158,7 @@
+
diff --git a/frontend/js/feed.js b/frontend/js/feed.js
new file mode 100644
index 0000000..06cd17f
--- /dev/null
+++ b/frontend/js/feed.js
@@ -0,0 +1,312 @@
+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 streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}`;
+ 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 = `
+

+
+
+
${v.title || ''}
+ ${uploaderText ? `
${uploaderText}
` : ''}
+
+
+ `;
+ 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() {
+ 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);
+ }
+
+ App.feed.renderSlides();
+ document.querySelectorAll('.feed-slide').forEach((slide) => observer.observe(slide));
+
+ container.classList.add('open');
+ container.setAttribute('aria-hidden', 'false');
+ document.body.classList.add('feed-mode-open');
+ document.body.style.overflow = 'hidden';
+
+ 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 {
+ App.feed.open();
+ }
+ };
+
+ 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';
+ };
+})();
diff --git a/frontend/js/state.js b/frontend/js/state.js
index 606dbae..08bcf20 100644
--- a/frontend/js/state.js
+++ b/frontend/js/state.js
@@ -13,7 +13,12 @@ App.state = {
playerMode: 'modal',
playerHome: null,
onFullscreenChange: null,
- onWebkitEndFullscreen: null
+ onWebkitEndFullscreen: null,
+ loadedVideos: [],
+ feedOpen: false,
+ feedMuted: true,
+ feedRenderedIds: new Set(),
+ feedActiveSlide: null
};
// Local storage keys used across modules.
diff --git a/frontend/js/ui.js b/frontend/js/ui.js
index a0e73ce..cb9b6b0 100644
--- a/frontend/js/ui.js
+++ b/frontend/js/ui.js
@@ -501,6 +501,20 @@ App.ui = App.ui || {};
window.closePlayer = App.player.close;
window.handleSearch = App.videos.handleSearch;
+ const modeToggleBtn = document.getElementById('mode-toggle-btn');
+ if (modeToggleBtn) {
+ modeToggleBtn.onclick = () => {
+ App.feed.toggle();
+ };
+ }
+
+ const feedMuteBtn = document.getElementById('feed-mute-btn');
+ if (feedMuteBtn) {
+ feedMuteBtn.onclick = () => {
+ App.feed.toggleMute();
+ };
+ }
+
const searchInput = document.getElementById('search-input');
const clearSearchBtn = document.getElementById('search-clear-btn');
if (searchInput && clearSearchBtn) {
@@ -528,6 +542,9 @@ App.ui = App.ui || {};
App.ui.closeDrawers();
App.ui.closeInfo();
App.videos.closeAllMenus();
+ if (App.feed.isOpen()) {
+ App.feed.close();
+ }
}
});
diff --git a/frontend/js/videos.js b/frontend/js/videos.js
index be259ff..5d27f9a 100644
--- a/frontend/js/videos.js
+++ b/frontend/js/videos.js
@@ -199,6 +199,7 @@ App.videos = App.videos || {};
const favoritesSet = App.favorites.getSet();
items.forEach(v => {
if (state.renderedVideoIds.has(v.id)) return;
+ state.loadedVideos.push(v);
const card = document.createElement('div');
card.className = 'video-card';
@@ -317,6 +318,9 @@ App.videos = App.videos || {};
});
App.videos.scheduleMasonryLayout();
+ if (App.feed && typeof App.feed.renderSlides === 'function') {
+ App.feed.renderSlides();
+ }
App.videos.ensureViewportFilled();
};
@@ -333,8 +337,12 @@ App.videos = App.videos || {};
state.currentPage = 1;
state.hasNextPage = true;
state.renderedVideoIds.clear();
+ state.loadedVideos = [];
const grid = document.getElementById('video-grid');
if (grid) grid.innerHTML = "";
+ if (App.feed && typeof App.feed.reset === 'function') {
+ App.feed.reset();
+ }
App.videos.updateLoadMoreState();
App.videos.loadVideos();
};
@@ -348,8 +356,12 @@ App.videos = App.videos || {};
state.currentPage = 1;
state.hasNextPage = true;
state.renderedVideoIds.clear();
+ state.loadedVideos = [];
const grid = document.getElementById('video-grid');
if (grid) grid.innerHTML = "";
+ if (App.feed && typeof App.feed.reset === 'function') {
+ App.feed.reset();
+ }
App.videos.updateLoadMoreState();
App.videos.loadVideos();
};