diff --git a/frontend/src/components/FeedItem.jsx b/frontend/src/components/FeedItem.jsx
index dbb8461..1029e0d 100644
--- a/frontend/src/components/FeedItem.jsx
+++ b/frontend/src/components/FeedItem.jsx
@@ -1,7 +1,8 @@
import VideoPlayer from './VideoPlayer';
import { getModalItems } from '../utils/media';
+import { getRedgifsStreamUrl } from '../utils/api';
-function FeedItem({ item }) {
+function FeedItem({ item, isActive = false }) {
const modalItems = getModalItems(item);
const first = modalItems[0];
if (!first) return null;
@@ -18,6 +19,21 @@ function FeedItem({ item }) {
posterUrl={item?.thumbnail || ''}
className="feed-item__video"
controls={false}
+ autoPlay={isActive}
+ />
+
+ );
+ }
+
+ if (first.kind === 'embed' && first.provider === 'RedGIFs' && first.id) {
+ return (
+
- {items.map((item) => (
-
- ))}
+
+ {items.map((item, index) =>
+ index >= start && index <= end ? (
+
+ ) : (
+
+ )
+ )}
);
}
diff --git a/frontend/src/components/FeedMode.test.jsx b/frontend/src/components/FeedMode.test.jsx
new file mode 100644
index 0000000..5a8fc14
--- /dev/null
+++ b/frontend/src/components/FeedMode.test.jsx
@@ -0,0 +1,84 @@
+import { describe, it, expect } from 'vitest';
+import { render, fireEvent } from '@testing-library/react';
+import FeedMode from '@/components/FeedMode';
+
+function imageItems(count) {
+ return Array.from({ length: count }, (_, i) => ({
+ id: `img_${i}`,
+ type: 'image',
+ mediaUrl: `https://example.test/${i}.jpg`
+ }));
+}
+
+function getContainer(view) {
+ return view.getByRole('region', { name: 'Feed mode' });
+}
+
+function scrollToIndex(container, index, viewportHeight = 700) {
+ Object.defineProperty(container, 'clientHeight', {
+ configurable: true,
+ value: viewportHeight
+ });
+ Object.defineProperty(container, 'scrollTop', {
+ configurable: true,
+ writable: true,
+ value: index * viewportHeight
+ });
+ fireEvent.scroll(container);
+}
+
+describe('FeedMode windowing', () => {
+ it('keeps one slot per item so scroll geometry is unchanged', () => {
+ const view = render(
);
+ expect(getContainer(view).children.length).toBe(8);
+ });
+
+ it('mounts media only for the active item and its neighbours', () => {
+ const view = render(
);
+ // Active index 0 on entry: items 0 and 1 are real, the rest placeholders.
+ expect(view.container.querySelectorAll('img').length).toBe(2);
+ });
+
+ it('moves the window as the user scrolls', () => {
+ const view = render(
);
+ const container = getContainer(view);
+
+ scrollToIndex(container, 4);
+ const imgs = [...view.container.querySelectorAll('img')];
+ expect(imgs.map((el) => el.getAttribute('src'))).toEqual([
+ 'https://example.test/3.jpg',
+ 'https://example.test/4.jpg',
+ 'https://example.test/5.jpg'
+ ]);
+ });
+
+ it('clips the window at the end of the list', () => {
+ const view = render(
);
+ const container = getContainer(view);
+
+ scrollToIndex(container, 7);
+ expect(view.container.querySelectorAll('img').length).toBe(2);
+ });
+
+ it('renders nothing special for an empty list', () => {
+ const view = render(
);
+ expect(getContainer(view).children.length).toBe(0);
+ });
+
+ it('excludes unplayable items so every slot can render', () => {
+ const items = [
+ ...imageItems(2),
+ { id: 'aud_0', type: 'audio', mediaUrl: 'https://example.test/a.mp3' },
+ {
+ id: 'yt_0',
+ type: 'video',
+ externalVideoProvider: 'YouTube',
+ externalVideoId: 'ytid',
+ externalVideoEmbedUrl: 'https://www.youtube.com/embed/ytid'
+ }
+ ];
+ const view = render(
);
+ expect(getContainer(view).children.length).toBe(2);
+ expect(view.container.querySelectorAll('img').length).toBe(2);
+ });
+});
diff --git a/frontend/src/components/VideoPlayer.jsx b/frontend/src/components/VideoPlayer.jsx
index 1c31dbb..7d1de65 100644
--- a/frontend/src/components/VideoPlayer.jsx
+++ b/frontend/src/components/VideoPlayer.jsx
@@ -1,4 +1,4 @@
-import Hls from 'hls.js';
+import { loadHls } from '../utils/videoLoaders';
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
const isDev = import.meta.env.DEV;
@@ -459,7 +459,7 @@ const VideoPlayer = forwardRef(function VideoPlayer(
video.muted = shouldStartMuted;
video.playsInline = true;
video.autoplay = autoPlay && !prebufferOnly;
- video.controls = !prebufferOnly;
+ video.controls = !prebufferOnly && controls;
video.poster = posterUrl || '';
if (shouldUseCompanionAudio && companionAudio) {
@@ -481,7 +481,9 @@ const VideoPlayer = forwardRef(function VideoPlayer(
return;
}
- if (Hls.isSupported()) {
+ const Hls = await loadHls();
+ if (cancelled) return;
+ if (Hls && Hls.isSupported()) {
if (isDev) console.log('[VideoPlayer] using HLS');
updateDiagnostics({ playbackMode: 'hls', audioTracksDetected: 0, autoplayAttempted: true, loadingState: 'loading' });
const hls = new Hls();
@@ -536,7 +538,7 @@ const VideoPlayer = forwardRef(function VideoPlayer(
}
cleanupPlayers();
};
- }, [mp4Url, hlsUrl, dashUrl, sanitizedCompanionAudioUrls, hasAudio, sourceKind, posterUrl, autoPlay, loop, prebufferOnly, allowUnmutedAutoplay, preferredMuted, onMutedChange, onDiagnostics, reloadNonce, shouldUseCompanionAudio, directUrlOnly]);
+ }, [mp4Url, hlsUrl, dashUrl, sanitizedCompanionAudioUrls, hasAudio, sourceKind, posterUrl, autoPlay, loop, prebufferOnly, controls, allowUnmutedAutoplay, preferredMuted, onMutedChange, onDiagnostics, reloadNonce, shouldUseCompanionAudio, directUrlOnly]);
if (prebufferOnly) {
return
;
diff --git a/frontend/src/hooks/useMode.js b/frontend/src/hooks/useMode.js
index bacff1b..e35b102 100644
--- a/frontend/src/hooks/useMode.js
+++ b/frontend/src/hooks/useMode.js
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
+import { restoreScrollWhenReady } from '../utils/scrollRestore';
const MODE_KEY = 'nightfeed:mode';
const VALID_MODES = new Set(['grid', 'feed']);
@@ -62,12 +63,13 @@ export function useMode() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- // Restore scroll when returning to grid from feed.
+ // Restore scroll when returning to grid from feed. Waits for the grid to
+ // lay out tall enough first; see restoreScrollWhenReady for why.
useEffect(() => {
- if (mode !== 'grid') return;
+ if (mode !== 'grid') return undefined;
const y = gridScrollYRef.current;
- if (y <= 0) return;
- requestAnimationFrame(() => window.scrollTo(0, y));
+ if (y <= 0) return undefined;
+ return restoreScrollWhenReady(y);
}, [mode]);
const setMode = useCallback((next) => {
diff --git a/frontend/src/utils/feedWindow.js b/frontend/src/utils/feedWindow.js
new file mode 100644
index 0000000..686bba4
--- /dev/null
+++ b/frontend/src/utils/feedWindow.js
@@ -0,0 +1,11 @@
+// The range of feed items worth mounting: the active one plus `radius`
+// neighbours on each side, clipped to the list. Everything outside renders
+// as an empty same-height placeholder so scroll geometry stays intact.
+export function computeFeedWindow(activeIndex, itemCount, radius = 1) {
+ if (itemCount <= 0) return { start: 0, end: -1 };
+ const clamped = Math.max(0, Math.min(itemCount - 1, activeIndex));
+ return {
+ start: Math.max(0, clamped - radius),
+ end: Math.min(itemCount - 1, clamped + radius)
+ };
+}
diff --git a/frontend/src/utils/feedWindow.test.js b/frontend/src/utils/feedWindow.test.js
new file mode 100644
index 0000000..fe1362b
--- /dev/null
+++ b/frontend/src/utils/feedWindow.test.js
@@ -0,0 +1,33 @@
+import { describe, it, expect } from 'vitest';
+import { computeFeedWindow } from '@/utils/feedWindow';
+
+describe('computeFeedWindow', () => {
+ it('windows around a middle item', () => {
+ expect(computeFeedWindow(5, 10)).toEqual({ start: 4, end: 6 });
+ });
+
+ it('clips at the start of the list', () => {
+ expect(computeFeedWindow(0, 10)).toEqual({ start: 0, end: 1 });
+ });
+
+ it('clips at the end of the list', () => {
+ expect(computeFeedWindow(9, 10)).toEqual({ start: 8, end: 9 });
+ });
+
+ it('handles a single-item list', () => {
+ expect(computeFeedWindow(0, 1)).toEqual({ start: 0, end: 0 });
+ });
+
+ it('returns an empty range for an empty list', () => {
+ expect(computeFeedWindow(0, 0)).toEqual({ start: 0, end: -1 });
+ });
+
+ it('clamps an out-of-range active index', () => {
+ expect(computeFeedWindow(99, 10)).toEqual({ start: 8, end: 9 });
+ expect(computeFeedWindow(-5, 10)).toEqual({ start: 0, end: 1 });
+ });
+
+ it('honours a custom radius', () => {
+ expect(computeFeedWindow(5, 10, 2)).toEqual({ start: 3, end: 7 });
+ });
+});
diff --git a/frontend/src/utils/media.js b/frontend/src/utils/media.js
index db40196..a3e131f 100644
--- a/frontend/src/utils/media.js
+++ b/frontend/src/utils/media.js
@@ -80,6 +80,18 @@ export function getModalItems(post) {
return [{ kind: 'image', url: post.mediaUrl }];
}
+// Whether feed mode can put this item on screen. Embeds only qualify for
+// RedGIFs, which plays natively through the backend stream proxy (the same
+// path the lightbox uses). Items failing this are filtered out before
+// windowing so every feed slot is exactly one viewport tall.
+export function isFeedRenderable(post) {
+ const first = getModalItems(post)[0];
+ if (!first) return false;
+ if (first.kind === 'embed') return first.provider === 'RedGIFs' && Boolean(first.id);
+ if (first.kind === 'audio') return false;
+ return Boolean(first.url);
+}
+
export function canDownloadUrl(url) {
if (!url) return false;
try {
diff --git a/frontend/src/utils/media.test.js b/frontend/src/utils/media.test.js
new file mode 100644
index 0000000..5e7951b
--- /dev/null
+++ b/frontend/src/utils/media.test.js
@@ -0,0 +1,45 @@
+import { describe, it, expect } from 'vitest';
+import { isFeedRenderable } from '@/utils/media';
+
+describe('isFeedRenderable', () => {
+ it('accepts native video items', () => {
+ expect(
+ isFeedRenderable({ type: 'video', videoUrl: 'https://v.redd.it/x/fallback.mp4' })
+ ).toBe(true);
+ });
+
+ it('accepts image items', () => {
+ expect(isFeedRenderable({ type: 'image', mediaUrl: 'https://i.redd.it/x.jpg' })).toBe(true);
+ });
+
+ it('accepts RedGIFs embeds, which play through the stream proxy', () => {
+ expect(
+ isFeedRenderable({
+ type: 'video',
+ externalVideoProvider: 'RedGIFs',
+ externalVideoId: 'clipid',
+ externalVideoEmbedUrl: 'https://www.redgifs.com/ifr/clipid'
+ })
+ ).toBe(true);
+ });
+
+ it('rejects non-RedGIFs embeds', () => {
+ expect(
+ isFeedRenderable({
+ type: 'video',
+ externalVideoProvider: 'YouTube',
+ externalVideoId: 'ytid',
+ externalVideoEmbedUrl: 'https://www.youtube.com/embed/ytid'
+ })
+ ).toBe(false);
+ });
+
+ it('rejects audio items', () => {
+ expect(isFeedRenderable({ type: 'audio', mediaUrl: 'https://x.test/a.mp3' })).toBe(false);
+ });
+
+ it('rejects items with nothing to show', () => {
+ expect(isFeedRenderable({ type: 'image', mediaUrl: null })).toBe(false);
+ expect(isFeedRenderable(null)).toBe(false);
+ });
+});
diff --git a/frontend/src/utils/scrollRestore.js b/frontend/src/utils/scrollRestore.js
new file mode 100644
index 0000000..bfcd131
--- /dev/null
+++ b/frontend/src/utils/scrollRestore.js
@@ -0,0 +1,29 @@
+const POLL_INTERVAL_MS = 50;
+const DEFAULT_DEADLINE_MS = 1000;
+
+function canAccommodate(y) {
+ return document.documentElement.scrollHeight - window.innerHeight >= y;
+}
+
+// Restores window scroll to y once layout is tall enough to land there.
+// A single requestAnimationFrame is not enough on mobile: the grid has not
+// finished laying out at the new viewport height when feed mode unmounts,
+// so an immediate scrollTo silently caps at 0. Polls with timers rather
+// than requestAnimationFrame because browsers throttle rAF on occluded or
+// backgrounded pages and the restore would then never fire. Scrolls
+// best-effort at the deadline. Returns a cancel function.
+export function restoreScrollWhenReady(y, { deadlineMs = DEFAULT_DEADLINE_MS } = {}) {
+ if (canAccommodate(y)) {
+ window.scrollTo(0, y);
+ return () => {};
+ }
+
+ const deadline = Date.now() + deadlineMs;
+ const timer = setInterval(() => {
+ if (!canAccommodate(y) && Date.now() < deadline) return;
+ clearInterval(timer);
+ window.scrollTo(0, y);
+ }, POLL_INTERVAL_MS);
+
+ return () => clearInterval(timer);
+}
diff --git a/frontend/src/utils/scrollRestore.test.js b/frontend/src/utils/scrollRestore.test.js
new file mode 100644
index 0000000..af07db5
--- /dev/null
+++ b/frontend/src/utils/scrollRestore.test.js
@@ -0,0 +1,65 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { restoreScrollWhenReady } from '@/utils/scrollRestore';
+
+function setScrollHeight(value) {
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
+ configurable: true,
+ value
+ });
+}
+
+describe('restoreScrollWhenReady', () => {
+ let scrollToSpy;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ scrollToSpy = vi.fn();
+ vi.stubGlobal('scrollTo', scrollToSpy);
+ Object.defineProperty(window, 'innerHeight', {
+ configurable: true,
+ value: 800
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ });
+
+ it('scrolls immediately when the page is already tall enough', () => {
+ setScrollHeight(2000);
+ restoreScrollWhenReady(600);
+ expect(scrollToSpy).toHaveBeenCalledWith(0, 600);
+ });
+
+ it('waits until layout can accommodate the target, then scrolls', () => {
+ setScrollHeight(800);
+ restoreScrollWhenReady(600);
+ expect(scrollToSpy).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(120);
+ expect(scrollToSpy).not.toHaveBeenCalled();
+
+ setScrollHeight(2000);
+ vi.advanceTimersByTime(60);
+ expect(scrollToSpy).toHaveBeenCalledWith(0, 600);
+ expect(scrollToSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('gives up at the deadline and scrolls best-effort', () => {
+ setScrollHeight(800);
+ restoreScrollWhenReady(600, { deadlineMs: 300 });
+ vi.advanceTimersByTime(1000);
+ expect(scrollToSpy).toHaveBeenCalledTimes(1);
+ expect(scrollToSpy).toHaveBeenCalledWith(0, 600);
+ });
+
+ it('cancel stops a pending restore', () => {
+ setScrollHeight(800);
+ const cancel = restoreScrollWhenReady(600);
+ cancel();
+ setScrollHeight(2000);
+ vi.advanceTimersByTime(5000);
+ expect(scrollToSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/utils/videoLoaders.js b/frontend/src/utils/videoLoaders.js
new file mode 100644
index 0000000..427cb2f
--- /dev/null
+++ b/frontend/src/utils/videoLoaders.js
@@ -0,0 +1,11 @@
+// Media engine loaders, dynamically imported so the libraries stay out of
+// the main bundle (hls.js alone is ~400 KB minified). Mirrors the dashjs
+// dynamic import that already lives in VideoPlayer's DASH path.
+export async function loadHls() {
+ try {
+ const mod = await import('hls.js');
+ return mod.default || mod;
+ } catch {
+ return null;
+ }
+}
diff --git a/frontend/src/utils/videoLoaders.test.js b/frontend/src/utils/videoLoaders.test.js
new file mode 100644
index 0000000..efd4a91
--- /dev/null
+++ b/frontend/src/utils/videoLoaders.test.js
@@ -0,0 +1,14 @@
+import { describe, it, expect, vi } from 'vitest';
+import { loadHls } from '@/utils/videoLoaders';
+
+vi.mock('hls.js', () => ({
+ default: { isSupported: () => true, marker: 'mock-hls' }
+}));
+
+describe('loadHls', () => {
+ it('resolves the hls.js default export', async () => {
+ const Hls = await loadHls();
+ expect(Hls.marker).toBe('mock-hls');
+ expect(Hls.isSupported()).toBe(true);
+ });
+});
diff --git a/frontend/tests/e2e/_helpers.js b/frontend/tests/e2e/_helpers.js
index 6a2f64f..5ac5aea 100644
--- a/frontend/tests/e2e/_helpers.js
+++ b/frontend/tests/e2e/_helpers.js
@@ -8,6 +8,7 @@ const subredditFixturePath = path.join(here, 'fixtures', 'subreddit-pics.json');
const subredditFixture = JSON.parse(fs.readFileSync(subredditFixturePath, 'utf-8'));
const MINIMAL_MP4_BODY = Buffer.alloc(0);
+const TINY_VIDEO_BODY = fs.readFileSync(path.join(here, 'fixtures', 'tiny-video.mp4'));
export async function stubBackend(page) {
await page.route('**/api/subreddit/**', async (route) => {
@@ -18,6 +19,24 @@ export async function stubBackend(page) {
});
});
+ // Ancillary Reddit endpoints still hit upstream and 403/429 under repeated
+ // runs, which flakes otherwise-deterministic specs. Stub them empty.
+ await page.route('**/api/reddit/search-subreddits*', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ items: [] })
+ });
+ });
+
+ await page.route('**/api/reddit/related/*', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ items: [] })
+ });
+ });
+
await page.route('**/api/external/redgifs/*', async (route) => {
if (route.request().url().includes('/stream')) return route.fallback();
await route.fulfill({
@@ -34,4 +53,14 @@ export async function stubBackend(page) {
body: MINIMAL_MP4_BODY
});
});
+
+ // The fixture's synthetic native-video item points here; serve a real
+ // playable clip so