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
163 changes: 127 additions & 36 deletions next-app/src/components/flash/FlashCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Hls from 'hls.js';
import { Heart, MessageCircle, Share2, Trophy, Volume2, VolumeX, Loader2, Flag } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { viewVideo } from '@/lib/clientApi';
import { getStreamUrl } from '@/lib/streamUrl';
import { getStreamUrl, fetchStreamSignedUrl } from '@/lib/streamUrl';
import { useTrackHistory } from '@/hooks/useLibrary';
import { useReport } from '@/context/ReportContext';

Expand All @@ -24,6 +24,9 @@ const FlashCard = ({
muted,
toggleMute,
shouldRender = true,
isWarm = false,
prefetchedStreamUrl = null,
isFastStart = false,
onPrefetchComments,
}) => {
const router = useRouter();
Expand All @@ -41,7 +44,11 @@ const FlashCard = ({
const [isScrubbing, setIsScrubbing] = useState(false);
const [hearts, setHearts] = useState([]);
const [videoDimensions, setVideoDimensions] = useState(null);
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const hlsRef = useRef(null);
const wasRenderedRef = useRef(shouldRender);
const wasWarmRef = useRef(isWarm);
const hlsUrlRef = useRef(null);

// Interaction Tracking
const entryTime = useRef(0);
Expand All @@ -51,8 +58,9 @@ const FlashCard = ({
const smartStart = 0.25;
const smartEnd = 0.85;

// Stream Proxy URL
const streamUrl = useMemo(() => getStreamUrl(video.video_url, video.id), [video.video_url, video.id]);
// Stream URL — prefer prefetched signed URL, fall back to legacy proxy
const legacyStreamUrl = useMemo(() => getStreamUrl(video.video_url, video.id), [video.video_url, video.id]);
const streamUrl = prefetchedStreamUrl || legacyStreamUrl;

useEffect(() => {
setVideoDimensions(null);
Expand All @@ -66,58 +74,120 @@ const FlashCard = ({
}
}, []);

// ─── Effect 1: HLS Initialisation ────────────────────────────────────────
// Runs only when the video source or render eligibility changes.
// Loads the manifest + starts buffering immediately, even before this card is active.
const handleLoadedData = useCallback(() => {
setHasLoadedOnce(true);
}, []);

// ─── Effect 1a: HLS Initialisation ──────────────────────────────────────
// Creates HLS when shouldRender is true and no instance exists.
// Instance is preserved when shouldRender toggles (warm pool).
// Destroyed only on unmount (1b) or when leaving warm/render window (1c).
useEffect(() => {
if (!videoRef.current || !shouldRender) return;
if (!videoRef.current || !shouldRender || hlsRef.current) return;

if (Hls.isSupported() && streamUrl?.includes('.m3u8')) {
const url = prefetchedStreamUrl || legacyStreamUrl;
if (!url) return;

if (Hls.isSupported() && url?.includes('.m3u8')) {
const hls = new Hls({
capLevelToPlayerSize: false,
startLevel: 0,
maxBufferLength: 30,
maxMaxBufferLength: 60,
maxBufferLength: isFastStart ? 5 : 10,
maxMaxBufferLength: isFastStart ? 15 : 30,
maxBufferSize: isFastStart ? 5 * 1024 * 1024 : 10 * 1024 * 1024,
abrEwmaDefaultEstimate: 3000000,
startFragPrefetch: true,
lowLatencyMode: false,
progressive: true,
autoStartLoad: true,
});
hls.loadSource(streamUrl);
hls.loadSource(url);
hls.attachMedia(videoRef.current);
hlsRef.current = hls;
hlsUrlRef.current = url;

hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
break;
default:
if (!data.fatal) return;
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR: {
// 403 → signed URL expired: refetch and reload
if (data.response?.code === 403) {
fetchStreamSignedUrl(video.id, null, null)
.then((result) => {
if (hlsRef.current && videoRef.current) {
hlsRef.current.destroy();
const fresh = new Hls({
capLevelToPlayerSize: false,
startLevel: 0,
maxBufferLength: isFastStart ? 5 : 10,
maxMaxBufferLength: isFastStart ? 15 : 30,
maxBufferSize: isFastStart ? 5 * 1024 * 1024 : 10 * 1024 * 1024,
abrEwmaDefaultEstimate: 3000000,
startFragPrefetch: true,
lowLatencyMode: false,
progressive: true,
autoStartLoad: true,
});
fresh.loadSource(result.url);
fresh.attachMedia(videoRef.current);
hlsRef.current = fresh;
hlsUrlRef.current = result.url;
}
})
.catch(() => {});
break;
}
hls.startLoad();
break;
}
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
break;
default:
break;
}
});
} else if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
videoRef.current.src = streamUrl;
videoRef.current.load();
} else {
videoRef.current.src = streamUrl;
} else if (url) {
videoRef.current.src = url;
videoRef.current.load();
}
}, [legacyStreamUrl, prefetchedStreamUrl, shouldRender, isFastStart, video.id]);

// ─── Effect 1b: HLS cleanup on unmount only ─────────────────────────────
useEffect(() => {
return () => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
};
}, [streamUrl, shouldRender]);
}, []);

// ─── Effect 1c: Destroy HLS when leaving warm or render window ───────────
useEffect(() => {
const leftRenderWindow = wasRenderedRef.current && !shouldRender;
const leftWarmWindow = wasWarmRef.current && !isWarm;
wasRenderedRef.current = shouldRender;
wasWarmRef.current = isWarm;

if ((leftRenderWindow || leftWarmWindow) && hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
hlsUrlRef.current = null;
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.currentTime = 0;
}
setPlaying(false);
setProgress(0);
setHasLoadedOnce(false);
}
}, [shouldRender, isWarm]);

// ─── Effect 2: Play / Pause Control ──────────────────────────────────────
// Runs when active state or mute changes. HLS is already loaded; just play or pause.
// When active: resume from current position (HLS already loaded).
// When inactive AND warm: just pause, preserve position.
// When inactive AND not warm: full reset (also handled by Effect 1c).
useEffect(() => {
if (!videoRef.current) return;
let viewTimer = null;
Expand Down Expand Up @@ -171,16 +241,20 @@ const FlashCard = ({
}

videoRef.current.pause();
videoRef.current.currentTime = 0;
if (!isWarm) {
videoRef.current.currentTime = 0;
}
setPlaying(false);
setProgress(0);
if (!isWarm) {
setProgress(0);
}
setIsEngaged(false);
}

return () => {
if (viewTimer) clearTimeout(viewTimer);
};
}, [isActive, muted, video.id, video.status]);
}, [isActive, muted, video.id, video.status, isWarm]);

const handleTimeUpdate = (e) => {
const { currentTime: curTime, duration: dur } = e.target;
Expand Down Expand Up @@ -306,7 +380,14 @@ const FlashCard = ({
className={s.videoWrapper}
onClick={handleMainClick}
>
{/* Background Layer (Static fallback if needed) */}
{/* Thumbnail poster — always behind video, fades out once first frame loads */}
<div
className={`${s.thumbnailPoster} ${hasLoadedOnce ? s.thumbnailHidden : ''}`}
style={video.thumbnail_url
? { backgroundImage: `url(${video.thumbnail_url})` }
: { backgroundColor: '#1c1c1e' }
}
/>

{shouldRender ? (
<video
Expand All @@ -316,6 +397,7 @@ const FlashCard = ({
playsInline
muted={muted}
onLoadedMetadata={handleMetadata}
onLoadedData={handleLoadedData}
onTimeUpdate={handleTimeUpdate}
onWaiting={() => setIsBuffering(true)}
onPlaying={() => setIsBuffering(false)}
Expand All @@ -327,9 +409,10 @@ const FlashCard = ({
crossOrigin="anonymous"
/>
) : (
<div
style={{
backgroundImage: `url(${video.thumbnail_url})`,
<div
style={{
backgroundImage: video.thumbnail_url ? `url(${video.thumbnail_url})` : undefined,
backgroundColor: video.thumbnail_url ? undefined : '#1c1c1e',
backgroundSize: 'cover',
backgroundPosition: 'center',
width: '100%',
Expand All @@ -338,11 +421,19 @@ const FlashCard = ({
top: 0,
left: 0,
zIndex: 1
}}
}}
/>
)}

{isBuffering && (
{/* Cold-start loading indicator — small spinner, no dark background */}
{isActive && !hasLoadedOnce && shouldRender && (
<div className={s.loadingOverlay}>
<Loader2 className={s.spinner} size={28} />
</div>
)}

{/* Mid-playback buffering — only after first frame has loaded */}
{isBuffering && hasLoadedOnce && (
<div className={s.bufferingOverlay}>
<Loader2 className={s.spinner} size={48} />
</div>
Expand Down
56 changes: 54 additions & 2 deletions next-app/src/components/flash/FlashFeed.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
fetchFlashVideosPage,
fetchRecommendedFlash,
} from '@/lib/clientApi';
import { fetchStreamSignedUrl } from '@/lib/streamUrl';
import { adaptiveEngine } from '@/services/adaptiveEngine';
import { adaptiveDiscovery } from '@/services/adaptiveDiscovery';
import { flashFeedManager } from '@/services/flashFeedManager';
Expand Down Expand Up @@ -90,6 +91,35 @@ export default function FlashFeed({
const SWIPE_THRESHOLD = 40;
const bootstrappedRef = useRef(initialClips.length > 0);

// ─── Signed URL Cache ──────────────────────────────────────────────
const signedUrlCacheRef = useRef(new Map());
const prefetchInFlightRef = useRef(new Map());

const prefetchSignedUrl = useCallback(async (videoId) => {
if (!videoId) return;
if (signedUrlCacheRef.current.has(videoId)) return;
if (prefetchInFlightRef.current.has(videoId)) return;

const promise = fetchStreamSignedUrl(videoId, null, resolveToken(token))
.then((result) => {
const ttl = (result.expires_at || 3600) * 1000;
signedUrlCacheRef.current.set(videoId, {
url: result.url,
expiresAt: Date.now() + ttl - 30_000,
});
})
.catch(() => {})
.finally(() => prefetchInFlightRef.current.delete(videoId));

prefetchInFlightRef.current.set(videoId, promise);
}, [token]);

const getCachedStreamUrl = useCallback((videoId) => {
const cached = signedUrlCacheRef.current.get(videoId);
if (cached && cached.expiresAt > Date.now()) return cached.url;
return null;
}, []);

const handleFeedTypeChange = (type) => {
setFeedType(type);
setActiveCategory('');
Expand Down Expand Up @@ -313,6 +343,22 @@ export default function FlashFeed({
return unsubscribe;
}, []);

// ─── Prefetch signed URLs for warm window (active ±2) ────────────
useEffect(() => {
if (loading || clips.length === 0) return;
const activeIndex = clips.findIndex(
(c) => c.id.toString() === activeVideoId?.toString()
);
if (activeIndex === -1) return;

for (let offset = -2; offset <= 2; offset++) {
const idx = activeIndex + offset;
if (idx >= 0 && idx < clips.length) {
prefetchSignedUrl(clips[idx].id);
}
}
}, [activeVideoId, clips, loading, prefetchSignedUrl]);

const fetchInitialFeed = useCallback(async () => {
// Keep SSR clips on first mount for /flash and /flash/[id]
if (bootstrappedRef.current) {
Expand Down Expand Up @@ -422,12 +468,15 @@ export default function FlashFeed({

const visibleClips = useMemo(() => {
const activeIndex = clips.findIndex((c) => c.id.toString() === activeVideoId?.toString());
const buffer = scrollVelocity > 1.5 ? 3 : 2;
const buffer = 3;
return clips.map((clip, index) => ({
...clip,
shouldRender: Math.abs(activeIndex - index) <= buffer,
isWarm: Math.abs(activeIndex - index) <= 1,
prefetchedStreamUrl: getCachedStreamUrl(clip.id),
isFastStart: index === activeIndex,
}));
}, [clips, activeVideoId, scrollVelocity]);
}, [clips, activeVideoId, getCachedStreamUrl]);

const activeClip = useMemo(
() => clips.find((c) => c.id.toString() === activeVideoId?.toString()),
Expand Down Expand Up @@ -577,6 +626,9 @@ export default function FlashFeed({
muted={muted}
toggleMute={() => setMuted((m) => !m)}
shouldRender={clip.shouldRender}
isWarm={clip.isWarm}
prefetchedStreamUrl={clip.prefetchedStreamUrl}
isFastStart={clip.isFastStart}
onPrefetchComments={prefetchComments}
/>
</div>
Expand Down
Loading
Loading