Skip to content
Open
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
67 changes: 12 additions & 55 deletions Frontend/src/Components/ContentPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -56,23 +61,9 @@ export default function ContentPage({
const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const contentItems = state.content.filter((video) => video.type === contentType);
const [selectedGames, setSelectedGames] = useState<string[]>(() => {
try {
const saved = localStorage.getItem(`${sectionId}-filters`);
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
});

const [sortOption, setSortOption] = useState<SortOption>(() => {
try {
const saved = localStorage.getItem(`${sectionId}-sort`);
return saved ? JSON.parse(saved) : 'newest';
} catch {
return 'newest';
}
});
const [selectedGames, setSelectedGames] = useState<string[]>(() => readSelectedGames(sectionId));

const [sortOption, setSortOption] = useState<SortOption>(() => readSortOption(sectionId));

const uniqueGames = useMemo(() => {
const games = contentItems.map((item) => item.game);
Expand All @@ -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);
Expand Down
108 changes: 108 additions & 0 deletions Frontend/src/Components/SectionView.ts
Original file line number Diff line number Diff line change
@@ -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<ContentType, string> = {
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));
}
100 changes: 100 additions & 0 deletions Frontend/src/Pages/video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SVGSVGElement, React.ComponentProps<typeof Icon>>(
(props, ref) => <Icon {...props} ref={ref} iconNode={crosshair2Dot} />,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -494,6 +498,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;
Expand Down Expand Up @@ -601,6 +661,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();
Expand Down Expand Up @@ -2164,26 +2242,48 @@ export default function VideoComponent({ video }: { video: Content }) {
<div className="flex items-center justify-between gap-4 py-1 shrink-0">
<div className="flex items-center gap-3">
<div className="flex items-center border rounded-lg join bg-base-300 border-base-400">
<button
onClick={handlePreviousVideo}
disabled={!hasPrevious}
className="h-10 text-gray-300 btn btn-sm btn-secondary hover:text-accent join-item disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Previous video"
data-tip="Previous video"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
onClick={() => skipTime(-5)}
className="h-10 text-gray-300 btn btn-sm btn-secondary hover:text-accent join-item"
aria-label="Back 5 seconds"
data-tip="Back 5s"
>
<RotateCcw className="w-5 h-5" />
</button>
<button
onClick={handlePlayPause}
className="h-10 text-gray-300 btn btn-sm btn-secondary hover:text-accent join-item"
aria-label={isPlaying ? 'Pause' : 'Play'}
data-tip={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</button>
<button
onClick={() => skipTime(5)}
className="h-10 text-gray-300 btn btn-sm btn-secondary hover:text-accent join-item"
aria-label="Forward 5 seconds"
data-tip="Forward 5s"
>
<RotateCw className="w-5 h-5" />
</button>
<button
onClick={handleNextVideo}
disabled={!hasNext}
className="h-10 text-gray-300 btn btn-sm btn-secondary hover:text-accent join-item disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Next video"
data-tip="Next video"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
{(video.type === 'Clip' || video.type === 'Highlight') && (
<>
Expand Down