diff --git a/Frontend/src/Components/ContentPage.tsx b/Frontend/src/Components/ContentPage.tsx index 375ab2a..9f4322b 100644 --- a/Frontend/src/Components/ContentPage.tsx +++ b/Frontend/src/Components/ContentPage.tsx @@ -12,6 +12,11 @@ import ContentFilters, { SortOption } from './ContentFilters'; import { useModal } from '../Context/ModalContext'; import { useImports } from '../Context/ImportContext'; import Button from './Button'; +import { + filterAndSortContent, + readSelectedGames, + readSortOption, +} from './SectionView'; // Escape a filename for use inside a CSS attribute-selector string. Windows // filenames can't contain " or \, but escape defensively all the same. @@ -56,23 +61,9 @@ export default function ContentPage({ const highlightTimeoutRef = useRef | null>(null); const contentItems = state.content.filter((video) => video.type === contentType); - const [selectedGames, setSelectedGames] = useState(() => { - try { - const saved = localStorage.getItem(`${sectionId}-filters`); - return saved ? JSON.parse(saved) : []; - } catch { - return []; - } - }); - - const [sortOption, setSortOption] = useState(() => { - try { - const saved = localStorage.getItem(`${sectionId}-sort`); - return saved ? JSON.parse(saved) : 'newest'; - } catch { - return 'newest'; - } - }); + const [selectedGames, setSelectedGames] = useState(() => readSelectedGames(sectionId)); + + const [sortOption, setSortOption] = useState(() => readSortOption(sectionId)); const uniqueGames = useMemo(() => { const games = contentItems.map((item) => item.game); @@ -84,44 +75,10 @@ export default function ContentPage({ return uniqueGameList; }, [contentItems]); - const filteredItems = useMemo(() => { - let filtered = [...contentItems]; - - if (selectedGames.length > 0) { - filtered = filtered.filter((item) => { - if (selectedGames.includes('Imported') && item.isImported) { - return true; - } - return selectedGames.filter((g) => g !== 'Imported').includes(item.game); - }); - } - - filtered.sort((a, b) => { - switch (sortOption) { - case 'newest': - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - case 'oldest': - return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - case 'size': - return (b.fileSizeKb ?? 0) - (a.fileSizeKb ?? 0); - case 'duration': { - const toSecs = (dur: string) => - dur.split(':').reduce((acc, t) => 60 * acc + (parseInt(t, 10) || 0), 0); - return toSecs(b.duration) - toSecs(a.duration); - } - case 'game': { - const byGame = a.game.localeCompare(b.game); - return byGame !== 0 - ? byGame - : new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - } - default: - return 0; - } - }); - - return filtered; - }, [contentItems, selectedGames, sortOption]); + const filteredItems = useMemo( + () => filterAndSortContent(contentItems, selectedGames, sortOption), + [contentItems, selectedGames, sortOption], + ); const handleGameFilterChange = (games: string[]) => { setSelectedGames(games); diff --git a/Frontend/src/Components/SectionView.ts b/Frontend/src/Components/SectionView.ts new file mode 100644 index 0000000..dd9b3a9 --- /dev/null +++ b/Frontend/src/Components/SectionView.ts @@ -0,0 +1,108 @@ +import { Content, ContentType } from '../Models/types'; +import { SortOption } from './ContentFilters'; + +// Each content page in the UI has a stable sectionId used for localStorage +// keys and the scroll context. Keeping this map in one place means the +// video page, which doesn't know which section the user came from, can +// resolve a video's type back to the right filter/sort keys. +export const SECTION_ID_BY_CONTENT_TYPE: Record = { + Session: 'sessions', + Buffer: 'replayBuffer', + Clip: 'clips', + Highlight: 'highlights', + Lowlight: 'lowlights', +}; + +// Read the persisted game filter for a section. Returns an empty array when +// nothing has been saved or the value is malformed. +export function readSelectedGames(sectionId: string): string[] { + try { + const raw = localStorage.getItem(`${sectionId}-filters`); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((g): g is string => typeof g === 'string') : []; + } catch { + return []; + } +} + +// Read the persisted sort option for a section. Falls back to 'newest' when +// missing or unrecognised, matching the previous ContentPage default. +export function readSortOption(sectionId: string): SortOption { + try { + const raw = localStorage.getItem(`${sectionId}-sort`); + if (!raw) return 'newest'; + const parsed = JSON.parse(raw); + if ( + parsed === 'newest' || + parsed === 'oldest' || + parsed === 'size' || + parsed === 'duration' || + parsed === 'game' + ) { + return parsed; + } + return 'newest'; + } catch { + return 'newest'; + } +} + +// "HH:MM:SS(.mmm)" → total seconds. Used by the 'duration' sort branch. +function durationToSeconds(duration: string): number { + return duration + .split(':') + .reduce((acc, part) => 60 * acc + (parseInt(part, 10) || 0), 0); +} + +// Apply the game filter, then sort according to `sortOption`. This mirrors the +// previous ContentPage pipeline exactly so any call site (the content list OR +// the video player's prev/next nav) iterates the videos in the same order the +// user sees them on the page. +export function filterAndSortContent( + items: Content[], + selectedGames: string[], + sortOption: SortOption, +): Content[] { + let out = items; + + if (selectedGames.length > 0) { + out = out.filter((item) => { + if (selectedGames.includes('Imported') && item.isImported) { + return true; + } + const gameFilters = selectedGames.filter((g) => g !== 'Imported'); + return gameFilters.includes(item.game); + }); + } + + const sorted = [...out]; + sorted.sort((a, b) => { + switch (sortOption) { + case 'newest': + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + case 'oldest': + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + case 'size': + return (b.fileSizeKb ?? 0) - (a.fileSizeKb ?? 0); + case 'duration': + return durationToSeconds(b.duration) - durationToSeconds(a.duration); + case 'game': { + const byGame = a.game.localeCompare(b.game); + return byGame !== 0 + ? byGame + : new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + } + default: + return 0; + } + }); + return sorted; +} + +// Convenience wrapper that resolves both filters + sort from localStorage for +// a given section. The video page uses this on every render so prev/next +// always reflects whatever the user last had selected on the content list. +export function filterAndSortForSection(items: Content[], sectionId: string): Content[] { + return filterAndSortContent(items, readSelectedGames(sectionId), readSortOption(sectionId)); +} diff --git a/Frontend/src/Pages/video.tsx b/Frontend/src/Pages/video.tsx index 524650a..80e5524 100644 --- a/Frontend/src/Pages/video.tsx +++ b/Frontend/src/Pages/video.tsx @@ -42,11 +42,14 @@ import { Headphones, Copy, Check, + ChevronLeft, + ChevronRight, } from 'lucide-react'; import SegmentCard from '../Components/SegmentCard'; import { useAudioTracks } from '../Hooks/useAudioTracks'; import { AnimatePresence, motion } from 'framer-motion'; import Button from '../Components/Button'; +import { SECTION_ID_BY_CONTENT_TYPE, filterAndSortForSection } from '../Components/SectionView'; const Crosshair2Dot = React.forwardRef>( (props, ref) => , @@ -199,6 +202,7 @@ export default function VideoComponent({ video }: { video: Content }) { const { session } = useAuth(); const { uploads } = useUploads(); const { openModal, closeModal } = useModal(); + const { setSelectedVideo } = useSelectedVideo(); const { segments, addSegment, @@ -487,6 +491,62 @@ export default function VideoComponent({ video }: { video: Content }) { } }; + // Build the iteration list for prev/next by combining: + // 1. the user's filter+sort selection on the content page (persisted per + // section in localStorage), AND + // 2. the same-type slice of `appState.content`. + // This matches the order/cut the user sees on the content list, so e.g. + // pressing Next while sorted by Size steps to the next heaviest video of + // the same type — not the next-newest one. + const sectionId = SECTION_ID_BY_CONTENT_TYPE[video.type]; + const sameTypeVideos = useMemo(() => { + const sameType = appState.content.filter((v) => v.type === video.type); + return filterAndSortForSection(sameType, sectionId); + }, [appState.content, video.type, sectionId]); + + const currentVideoIndex = useMemo( + () => sameTypeVideos.findIndex((v) => v.fileName === video.fileName), + [sameTypeVideos, video.fileName], + ); + + const hasPrevious = currentVideoIndex > 0; + const hasNext = currentVideoIndex >= 0 && currentVideoIndex < sameTypeVideos.length - 1; + + const handlePreviousVideo = () => { + if (!hasPrevious) return; + setSelectedVideo(sameTypeVideos[currentVideoIndex - 1]); + }; + + const handleNextVideo = () => { + if (!hasNext) return; + setSelectedVideo(sameTypeVideos[currentVideoIndex + 1]); + }; + + // Refs holding the latest navigation state so the window-level keydown handler + // (registered once on mount) always reads current values without depending on + // a closure. The handlers below use navRef directly, so they're immune to + // stale-closure bugs that occur when `video` swaps but a previously attached + // listener still captures the old `currentVideoIndex`. + const navRef = useRef({ + sameTypeVideos, + currentVideoIndex, + }); + useEffect(() => { + navRef.current = { sameTypeVideos, currentVideoIndex }; + }, [sameTypeVideos, currentVideoIndex]); + + const navigatePrevFromRef = () => { + const { sameTypeVideos: list, currentVideoIndex: i } = navRef.current; + if (i <= 0) return; + setSelectedVideo(list[i - 1]); + }; + + const navigateNextFromRef = () => { + const { sameTypeVideos: list, currentVideoIndex: i } = navRef.current; + if (i < 0 || i >= list.length - 1) return; + setSelectedVideo(list[i + 1]); + }; + // Initialize video metadata and setup keyboard controls useEffect(() => { const vid = videoRef.current; @@ -594,6 +654,24 @@ export default function VideoComponent({ video }: { video: Content }) { showControlsTemporarily(); return; } + + // Page Up / Page Down: navigate previous / next video. Reads through + // navRef so the handler always sees the current index even after the + // video prop changes (handler is registered once on mount). + if ((e.key === 'PageUp' || e.code === 'PageUp') && !isTyping) { + if (e.repeat) return; + e.preventDefault(); + showControlsTemporarily(); + navigatePrevFromRef(); + return; + } + if ((e.key === 'PageDown' || e.code === 'PageDown') && !isTyping) { + if (e.repeat) return; + e.preventDefault(); + showControlsTemporarily(); + navigateNextFromRef(); + return; + } if (e.key === 'Escape' && isFullscreen) { e.preventDefault(); exitFullscreen(); @@ -2156,15 +2234,27 @@ export default function VideoComponent({ video }: { video: Content }) {
+ +
{(video.type === 'Clip' || video.type === 'Highlight') && ( <>