Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion frontend/src/components/FeedItem.jsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -18,6 +19,21 @@ function FeedItem({ item }) {
posterUrl={item?.thumbnail || ''}
className="feed-item__video"
controls={false}
autoPlay={isActive}
/>
</div>
);
}

if (first.kind === 'embed' && first.provider === 'RedGIFs' && first.id) {
return (
<div className="feed-item">
<VideoPlayer
mp4Url={getRedgifsStreamUrl(first.id)}
posterUrl={first.posterUrl || item?.thumbnail || ''}
className="feed-item__video"
controls={false}
autoPlay={isActive}
/>
</div>
);
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/components/FeedItem.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import FeedItem from '@/components/FeedItem';

vi.mock('@/components/VideoPlayer', () => ({
default: (props) => <div data-testid="video-player" data-props={JSON.stringify(props)} />
}));

function playerProps(view) {
return JSON.parse(view.getByTestId('video-player').dataset.props);
}

describe('FeedItem', () => {
it('plays RedGIFs embeds through the stream proxy like the lightbox does', () => {
const view = render(
<FeedItem
isActive
item={{
id: 'rg1',
type: 'video',
thumbnail: 'https://thumb.test/p.png',
externalVideoProvider: 'RedGIFs',
externalVideoId: 'clipid',
externalVideoEmbedUrl: 'https://www.redgifs.com/ifr/clipid',
externalVideoPosterUrl: 'https://poster.test/p.png'
}}
/>
);
const props = playerProps(view);
expect(props.mp4Url).toContain('/api/external/redgifs/clipid/stream');
expect(props.posterUrl).toBe('https://poster.test/p.png');
expect(props.controls).toBe(false);
expect(props.autoPlay).toBe(true);
});

it('only autoplays the active item', () => {
const view = render(
<FeedItem
isActive={false}
item={{ id: 'v1', type: 'video', videoUrl: 'https://v.redd.it/x/fallback.mp4' }}
/>
);
expect(playerProps(view).autoPlay).toBe(false);
});
});
38 changes: 33 additions & 5 deletions frontend/src/components/FeedMode.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,40 @@
import { useMemo, useRef, useState } from 'react';
import FeedItem from './FeedItem';
import { computeFeedWindow } from '../utils/feedWindow';
import { isFeedRenderable } from '../utils/media';
import '../styles/feed.css';

function FeedMode({ items }) {
function FeedMode({ items: allItems }) {
const containerRef = useRef(null);
const [activeIndex, setActiveIndex] = useState(0);
const items = useMemo(() => allItems.filter(isFeedRenderable), [allItems]);

// Each item is exactly one viewport tall with mandatory snap, so the
// active index is plain division; no IntersectionObserver needed.
function handleScroll() {
const node = containerRef.current;
if (!node || node.clientHeight === 0) return;
const index = Math.round(node.scrollTop / node.clientHeight);
setActiveIndex(Math.max(0, Math.min(items.length - 1, index)));
}

const { start, end } = computeFeedWindow(activeIndex, items.length);

return (
<div className="feed-mode" role="region" aria-label="Feed mode">
{items.map((item) => (
<FeedItem key={item.id} item={item} />
))}
<div
className="feed-mode"
role="region"
aria-label="Feed mode"
ref={containerRef}
onScroll={handleScroll}
>
{items.map((item, index) =>
index >= start && index <= end ? (
<FeedItem key={item.id} item={item} isActive={index === activeIndex} />
) : (
<div key={item.id} className="feed-item" aria-hidden="true" />
)
)}
</div>
);
}
Expand Down
84 changes: 84 additions & 0 deletions frontend/src/components/FeedMode.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<FeedMode items={imageItems(8)} />);
expect(getContainer(view).children.length).toBe(8);
});

it('mounts media only for the active item and its neighbours', () => {
const view = render(<FeedMode items={imageItems(8)} />);
// 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(<FeedMode items={imageItems(8)} />);
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(<FeedMode items={imageItems(8)} />);
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(<FeedMode items={[]} />);
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(<FeedMode items={items} />);
expect(getContainer(view).children.length).toBe(2);
expect(view.container.querySelectorAll('img').length).toBe(2);
});
});
10 changes: 6 additions & 4 deletions frontend/src/components/VideoPlayer.jsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
Expand Down Expand Up @@ -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 <video ref={videoRef} playsInline preload="metadata" className={className} style={{ display: 'none' }} />;
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/hooks/useMode.js
Original file line number Diff line number Diff line change
@@ -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']);
Expand Down Expand Up @@ -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) => {
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/utils/feedWindow.js
Original file line number Diff line number Diff line change
@@ -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)
};
}
33 changes: 33 additions & 0 deletions frontend/src/utils/feedWindow.test.js
Original file line number Diff line number Diff line change
@@ -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 });
});
});
12 changes: 12 additions & 0 deletions frontend/src/utils/media.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/utils/media.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading