diff --git a/frontend/js/feed.js b/frontend/js/feed.js
index 638ce7c..638410b 100644
--- a/frontend/js/feed.js
+++ b/frontend/js/feed.js
@@ -76,6 +76,22 @@ App.feed = App.feed || {};
return Math.min(total - 1, Math.max(0, index));
};
+ // True when the user wants a finished clip to replay; false when it should
+ // auto-advance to the next video. Defaults to looping (see storage).
+ const shouldLoop = function() {
+ return App.storage.getFeedEndBehavior() !== 'scroll';
+ };
+
+ // Smoothly scrolls to the slide after `fromIndex`; the scroll-snap container
+ // fires onScroll, which promotes the new slide to active. No-ops at the end
+ // of the list so the final clip simply stops on its last frame.
+ const advanceToNext = function(fromIndex) {
+ const next = clampIndex(fromIndex + 1);
+ if (next < 0 || next === fromIndex) return;
+ const scroller = getScroller();
+ if (scroller) scroller.scrollTo({ top: next * slideHeight(), behavior: 'smooth' });
+ };
+
// 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
is torn down to free
@@ -302,7 +318,7 @@ App.feed = App.feed || {};
const favKey = App.favorites ? App.favorites.getKey(v) : null;
slide.innerHTML = `
-
+
${liveBadge}
${favKey ? ` ` : ''}
@@ -318,7 +334,18 @@ App.feed = App.feed || {};
`;
const poster = slide.querySelector('.feed-poster');
App.videos.attachNoReferrerRetry(poster);
- bindTimeline(slide, slide.querySelector('.feed-video'));
+ const slideVideo = slide.querySelector('.feed-video');
+ bindTimeline(slide, slideVideo);
+
+ // On video end, either loop (handled by the `loop` flag, so `ended`
+ // never fires) or auto-scroll to the next clip. We only advance for the
+ // active slide so a preloaded neighbour ending early can't hijack focus.
+ slideVideo.loop = shouldLoop();
+ slideVideo.addEventListener('ended', () => {
+ if (shouldLoop()) return;
+ if (!slide.classList.contains('is-active')) return;
+ advanceToNext(slide._index);
+ });
const favBtn = slide.querySelector('.feed-fav-btn');
if (favBtn && App.favorites) {
@@ -489,6 +516,16 @@ App.feed = App.feed || {};
return !!state.feedOpen;
};
+ // Re-applies the on-video-end preference to every rendered slide so toggling
+ // the setting takes effect immediately, without needing to reopen the feed.
+ App.feed.applyEndBehavior = function() {
+ const loop = shouldLoop();
+ slidesByIndex.forEach((slide) => {
+ const video = slide.querySelector('.feed-video');
+ if (video) video.loop = loop;
+ });
+ };
+
// Called whenever new video JSON is appended (e.g. after a prefetch). Lets
// the open feed pick up newly buffered slides and extend its window if the
// active slide is near the end.
diff --git a/frontend/js/main.js b/frontend/js/main.js
index 4355d88..99b5f63 100644
--- a/frontend/js/main.js
+++ b/frontend/js/main.js
@@ -6,6 +6,7 @@ window.App = window.App || {};
await App.storage.ensureDefaults();
App.ui.applyTheme();
App.ui.applyPreferredQuality();
+ App.ui.applyFeedEndBehavior();
App.ui.renderMenu();
App.favorites.renderBar();
App.ui.bindGlobalHandlers();
diff --git a/frontend/js/state.js b/frontend/js/state.js
index b50f7b7..a09fe2d 100644
--- a/frontend/js/state.js
+++ b/frontend/js/state.js
@@ -26,7 +26,8 @@ App.state = {
App.constants = {
FAVORITES_KEY: 'favorites',
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
- PREFERRED_QUALITY_KEY: 'preferredQuality'
+ PREFERRED_QUALITY_KEY: 'preferredQuality',
+ FEED_END_BEHAVIOR_KEY: 'feedEndBehavior'
};
// Lazily injects hls.js the first time a stream actually needs it. Sessions
diff --git a/frontend/js/storage.js b/frontend/js/storage.js
index 1f71f93..f8dd861 100644
--- a/frontend/js/storage.js
+++ b/frontend/js/storage.js
@@ -3,7 +3,7 @@ App.storage = App.storage || {};
App.session = App.session || {};
(function() {
- const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY, PREFERRED_QUALITY_KEY } = App.constants;
+ const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY, PREFERRED_QUALITY_KEY, FEED_END_BEHAVIOR_KEY } = App.constants;
// Basic localStorage helpers.
App.storage.getConfig = function() {
@@ -38,6 +38,16 @@ App.session = App.session || {};
localStorage.setItem(PREFERRED_QUALITY_KEY, nextQuality);
};
+ // Reels/TikTok mode behavior when a video reaches its end: 'loop' replays
+ // the same clip; 'scroll' advances to the next video. Defaults to 'loop'.
+ App.storage.getFeedEndBehavior = function() {
+ return localStorage.getItem(FEED_END_BEHAVIOR_KEY) === 'scroll' ? 'scroll' : 'loop';
+ };
+
+ App.storage.setFeedEndBehavior = function(nextBehavior) {
+ localStorage.setItem(FEED_END_BEHAVIOR_KEY, nextBehavior === 'scroll' ? 'scroll' : 'loop');
+ };
+
App.storage.getServerEntries = function() {
const config = App.storage.getConfig();
if (!config.servers || !Array.isArray(config.servers)) return [];
@@ -187,6 +197,9 @@ App.session = App.session || {};
if (!localStorage.getItem(FAVORITES_VISIBILITY_KEY)) {
localStorage.setItem(FAVORITES_VISIBILITY_KEY, 'true');
}
+ if (!localStorage.getItem(FEED_END_BEHAVIOR_KEY)) {
+ localStorage.setItem(FEED_END_BEHAVIOR_KEY, 'loop');
+ }
await App.storage.initializeServerStatus();
};
diff --git a/frontend/js/ui.js b/frontend/js/ui.js
index 26e53c3..ab2130c 100644
--- a/frontend/js/ui.js
+++ b/frontend/js/ui.js
@@ -16,6 +16,11 @@ App.ui = App.ui || {};
if (select) select.value = App.storage.getPreferredQuality();
};
+ App.ui.applyFeedEndBehavior = function() {
+ const select = document.getElementById('feed-end-select');
+ if (select) select.value = App.storage.getFeedEndBehavior();
+ };
+
// Toast helper for playback + network errors.
App.ui.showError = function(message) {
const toast = document.getElementById('error-toast');
@@ -281,6 +286,17 @@ App.ui = App.ui || {};
};
}
+ const feedEndSelect = document.getElementById('feed-end-select');
+ if (feedEndSelect) {
+ feedEndSelect.value = App.storage.getFeedEndBehavior();
+ feedEndSelect.onchange = () => {
+ App.storage.setFeedEndBehavior(feedEndSelect.value);
+ if (App.feed && typeof App.feed.applyEndBehavior === 'function') {
+ App.feed.applyEndBehavior();
+ }
+ };
+ }
+
if (favoritesToggle) {
favoritesToggle.checked = App.favorites.isVisible();
favoritesToggle.onchange = () => {