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 ( +
+
); diff --git a/frontend/src/components/FeedItem.test.jsx b/frontend/src/components/FeedItem.test.jsx new file mode 100644 index 0000000..f0b2706 --- /dev/null +++ b/frontend/src/components/FeedItem.test.jsx @@ -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) =>
+})); + +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( + + ); + 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( + + ); + expect(playerProps(view).autoPlay).toBe(false); + }); +}); diff --git a/frontend/src/components/FeedMode.jsx b/frontend/src/components/FeedMode.jsx index 3197202..68e2298 100644 --- a/frontend/src/components/FeedMode.jsx +++ b/frontend/src/components/FeedMode.jsx @@ -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 ( -
- {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