Files
dotcom-arcade/frontend/src/views/GameDetailView.tsx
T
branderson 4e21ec1529 feat(streaming): play streamed games in the player and keep their saves in the browser
Games the backend reports as streamable get a Play button. The player starts
a stream session, embeds the stream, and keeps the game's saves in
IndexedDB: sent when the session starts and updated as the server pushes
changes.
2026-10-11 01:01:27 -07:00

445 lines
17 KiB
TypeScript

import { useParams, Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useGame } from '@/hooks/useGames';
import { useDownload } from '@/hooks/useDownload';
import { useGameStats } from '@/hooks/usePlayTracking';
import { useSharedAccessToken } from '@/hooks/useSharedAccessToken';
import { sharedPlaylistsApi } from '@/lib/api/sharedPlaylists';
import { useAuthStore } from '@/store/auth';
import { Play, ImageIcon, Loader2, Clock } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { GameInfoGrid } from '@/components/game/GameInfoGrid';
import { useDateTimeFormat } from '@/hooks/useDateTimeFormat';
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
import { LazyImage } from '@/components/common/LazyImage';
import { Breadcrumbs, BreadcrumbContext, BreadcrumbItem } from '@/components/common/Breadcrumbs';
import { buildSharedGameUrl } from '@/hooks/useSharedPlaylistAccess';
import { getGameLogoUrl, getGameScreenshotUrl } from '@/utils/gameUtils';
import { logger } from '@/lib/logger';
import { formatDuration } from '@/lib/cron-utils';
import {
getBreadcrumbContextFromPath,
getSectionFromPath,
DEFAULT_BREADCRUMB_CONTEXT,
} from '@/lib/sectionRoutes';
import { buildFilterSearchParams } from '@/lib/filterUrlCompression';
import { GameRatingSection } from '@/components/game/GameRatingSection';
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
export function GameDetailView() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const shareToken = searchParams.get('shareToken');
const { isAuthenticated } = useAuthStore();
const { enableRatings } = useFeatureFlags();
const { generateToken, hasValidToken, isGenerating } = useSharedAccessToken();
// Derive breadcrumb context: URL path takes precedence, then state, then default
const urlBreadcrumbContext = getBreadcrumbContextFromPath(location.pathname);
const stateBreadcrumbContext = location.state?.breadcrumbContext as BreadcrumbContext | undefined;
const breadcrumbContext =
urlBreadcrumbContext ?? stateBreadcrumbContext ?? DEFAULT_BREADCRUMB_CONTEXT;
// Get current section for building play URLs
const currentSection = getSectionFromPath(location.pathname);
useEffect(() => {
if (shareToken && !isAuthenticated && !hasValidToken) {
generateToken(shareToken).catch(logger.error);
}
}, [shareToken, isAuthenticated, hasValidToken, generateToken]);
const canFetchGame = !shareToken || isAuthenticated || hasValidToken;
const {
data: game,
isLoading: isLoadingGame,
error,
refetch,
} = useGame(id ?? '', { enabled: canFetchGame });
const { data: sharedPlaylist } = useQuery({
queryKey: ['sharedPlaylist', shareToken],
queryFn: () => sharedPlaylistsApi.getByToken(shareToken ?? ''),
enabled: !!shareToken,
retry: false,
});
const isLoading = isLoadingGame || isGenerating;
const { progress, isDownloading, startDownload } = useDownload(id ?? '');
const { data: gameStatsData } = useGameStats();
const { formatDate } = useDateTimeFormat();
const [logoError, setLogoError] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
const [shouldAutoPlay, setShouldAutoPlay] = useState(false);
const formatRelativeTime = useCallback(
(dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} ${diffMins === 1 ? 'minute' : 'minutes'} ago`;
if (diffHours < 24) return `${diffHours} ${diffHours === 1 ? 'hour' : 'hours'} ago`;
if (diffDays < 7) return `${diffDays} ${diffDays === 1 ? 'day' : 'days'} ago`;
return formatDate(dateString);
},
[formatDate]
);
const currentGameStats = useMemo(() => {
if (!gameStatsData?.data || !id) return null;
return gameStatsData.data.find((stats) => stats.gameId === id);
}, [gameStatsData, id]);
useEffect(() => {
setLogoError(false);
setDownloadError(null);
setShouldAutoPlay(false);
}, [id]);
const isRefetchingAfterDownload = useRef(false);
useEffect(() => {
if (progress?.status === 'complete' && !isRefetchingAfterDownload.current) {
isRefetchingAfterDownload.current = true;
refetch().finally(() => {
isRefetchingAfterDownload.current = false;
});
}
if (progress?.status === 'error') {
setDownloadError(progress.error || 'Download failed');
setShouldAutoPlay(false);
}
}, [progress, refetch]);
useEffect(() => {
if (
shouldAutoPlay &&
game &&
game.presentOnDisk !== 0 &&
!isDownloading &&
!isRefetchingAfterDownload.current
) {
setShouldAutoPlay(false);
// Build play URL: use section-based route if on section page, otherwise shared/legacy
const playUrl = shareToken
? buildSharedGameUrl(`/games/${game.id}/play`, shareToken)
: currentSection
? `${currentSection.prefix}/${game.id}/play`
: `/games/${game.id}/play`;
const detailUrl = shareToken
? buildSharedGameUrl(`/games/${game.id}`, shareToken)
: location.pathname;
navigate(playUrl, {
state: {
playerBreadcrumbContext: {
breadcrumbContext,
gameTitle: game.title,
gameDetailHref: detailUrl,
shareToken: shareToken ?? null,
sharedPlaylistTitle: sharedPlaylist?.title ?? null,
sharedPlaylistHref: shareToken ? `/playlists/shared/${shareToken}` : null,
},
},
});
}
}, [
shouldAutoPlay,
game,
isDownloading,
navigate,
shareToken,
breadcrumbContext,
sharedPlaylist,
currentSection,
location.pathname,
]);
const handleStartDownload = async () => {
try {
setDownloadError(null);
setShouldAutoPlay(true);
await startDownload();
} catch (error) {
setDownloadError(error instanceof Error ? error.message : 'Failed to start download');
setShouldAutoPlay(false);
}
};
if (isLoading) {
return (
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<p className="mt-4 text-muted-foreground">Loading game...</p>
</div>
);
}
if (error || !game) {
return (
<div className="text-center py-12">
<p className="text-destructive">Error loading game</p>
<button
onClick={() => navigate(-1)}
className="text-primary hover:underline mt-4 inline-block"
>
Go Back
</button>
</div>
);
}
const screenshotUrl = getGameScreenshotUrl(game.id) || null;
const logoUrl = getGameLogoUrl(game.id) || null;
// presentOnDisk: null = no data needed, 0 = needs download, 1 = downloaded
const needsDataDownload = game.presentOnDisk === 0;
// Build breadcrumb items, handling optional parent context
const buildBreadcrumbItems = () => {
if (shareToken && sharedPlaylist) {
return [
{ label: sharedPlaylist.title, href: `/playlists/shared/${shareToken}` },
{ label: game.title, active: true },
];
}
if (shareToken) {
return [{ label: game.title, active: true }];
}
// Build items from context, including parent if present
const items: BreadcrumbItem[] = [];
if (breadcrumbContext.parent) {
items.push({
label: breadcrumbContext.parent.label,
href: breadcrumbContext.parent.href,
icon: breadcrumbContext.parent.icon,
});
}
items.push({
label: breadcrumbContext.label,
href: breadcrumbContext.href,
icon: breadcrumbContext.icon,
});
items.push({ label: game.title, active: true });
return items;
};
const breadcrumbItems = buildBreadcrumbItems();
return (
<ErrorBoundary>
<div className="max-w-7xl mx-auto space-y-6">
<Breadcrumbs items={breadcrumbItems} />
<div className="bg-card rounded-lg p-6 space-y-6 border border-border shadow-md">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1">
{logoUrl && !logoError ? (
<div className="flex-shrink-0 w-20 h-20 bg-muted rounded-lg overflow-hidden flex items-center justify-center p-2 relative">
<LazyImage
src={logoUrl}
alt={`${game.title} logo`}
width="80"
height="80"
skeleton={
<div className="absolute inset-0 flex items-center justify-center bg-muted/50 backdrop-blur-sm">
<Loader2 size={20} className="text-muted-foreground animate-spin" />
</div>
}
className="w-full h-full object-contain relative z-10"
onError={() => setLogoError(true)}
/>
</div>
) : null}
<div className="flex-1 min-w-0">
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold mb-2 break-words">
{game.title}
</h1>
{game.alternateTitles ? (
<p className="text-muted-foreground text-sm break-words">
Also known as: {game.alternateTitles}
</p>
) : null}
{currentGameStats?.lastPlayedAt || currentGameStats?.totalPlaytimeSeconds ? (
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
{currentGameStats?.lastPlayedAt ? (
<div className="flex items-center gap-1.5">
<Clock size={14} />
<span>Last played {formatRelativeTime(currentGameStats.lastPlayedAt)}</span>
</div>
) : null}
{currentGameStats?.totalPlaytimeSeconds ? (
<div className="flex items-center gap-1.5">
<span>•</span>
<span>{formatDuration(currentGameStats.totalPlaytimeSeconds)} played</span>
</div>
) : null}
</div>
) : null}
</div>
</div>
<div className="text-center space-y-2 flex-shrink-0">
{game.platformName === 'Flash' || game.platformName === 'HTML5' || game.streamable ? (
<>
{isDownloading ? (
<button
disabled
className="inline-flex items-center justify-center p-4 sm:p-5 md:p-6 bg-primary text-white rounded-xl shadow-lg cursor-wait opacity-75 transition-all w-full sm:w-auto"
title="Preparing game..."
>
<Loader2 className="w-7 h-7 sm:w-8 sm:h-8 animate-spin" />
</button>
) : needsDataDownload ? (
<button
onClick={handleStartDownload}
className="group inline-flex items-center justify-center p-4 sm:p-5 md:p-6 bg-primary hover:bg-primary/90 text-white rounded-xl shadow-lg hover:shadow-xl hover:shadow-primary/20 transition-all hover:scale-105 active:scale-95 ring-2 ring-primary/20 hover:ring-primary/40 w-full sm:w-auto"
title="Play Game"
>
<Play
size={28}
className="sm:w-8 sm:h-8 transition-transform group-hover:scale-110"
fill="currentColor"
/>
</button>
) : (
<Link
to={
shareToken
? buildSharedGameUrl(`/games/${game.id}/play`, shareToken)
: currentSection
? `${currentSection.prefix}/${game.id}/play`
: `/games/${game.id}/play`
}
state={{
playerBreadcrumbContext: {
breadcrumbContext,
gameTitle: game.title,
gameDetailHref: shareToken
? buildSharedGameUrl(`/games/${game.id}`, shareToken)
: location.pathname,
shareToken: shareToken ?? null,
sharedPlaylistTitle: sharedPlaylist?.title ?? null,
sharedPlaylistHref: shareToken ? `/playlists/shared/${shareToken}` : null,
},
}}
className="group inline-flex items-center justify-center p-4 sm:p-5 md:p-6 bg-primary hover:bg-primary/90 text-white rounded-xl shadow-lg hover:shadow-xl hover:shadow-primary/20 transition-all hover:scale-105 active:scale-95 ring-2 ring-primary/20 hover:ring-primary/40 w-full sm:w-auto"
title="Play Game"
>
<Play
size={28}
className="sm:w-8 sm:h-8 transition-transform group-hover:scale-110"
fill="currentColor"
/>
</Link>
)}
{downloadError ? (
<p className="text-sm text-destructive font-medium">{downloadError}</p>
) : null}
</>
) : (
<Badge
variant="secondary"
className="px-4 py-2 text-sm sm:text-base font-medium flex items-center gap-2"
>
<Play size={16} className="sm:w-5 sm:h-5" />
Play on Flashpoint Launcher
</Badge>
)}
</div>
</div>
{screenshotUrl ? (
<div className="aspect-video bg-muted rounded-lg overflow-hidden flex items-center justify-center relative">
<LazyImage
src={screenshotUrl}
alt={`${game.title} screenshot`}
width="1280"
height="720"
skeleton={
<div className="absolute inset-0 flex items-center justify-center bg-muted/50 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3">
<Loader2 size={48} className="text-muted-foreground animate-spin" />
<span className="text-sm text-muted-foreground">Loading screenshot...</span>
</div>
</div>
}
fallback={
<div className="flex flex-col items-center text-muted-foreground">
<ImageIcon size={64} className="mb-2 opacity-50" />
<span className="text-sm">Screenshot not available</span>
</div>
}
className="w-full h-full object-contain relative z-10"
/>
</div>
) : null}
{enableRatings ? (
<div className="flex flex-col lg:flex-row py-4 border-y border-border">
<GameRatingSection
gameId={game.id}
layout="detail"
className="pb-4 lg:pb-0 lg:pr-6"
/>
<div className="flex-1 min-w-0 border-t pt-4 lg:border-t-0 lg:pt-0 lg:border-l lg:pl-6 border-border">
<GameInfoGrid game={game} className="border-y-0 py-0 lg:grid-cols-3" />
</div>
</div>
) : (
<GameInfoGrid game={game} />
)}
{game.originalDescription ? (
<div>
<h2 className="text-lg font-semibold mb-2">Description</h2>
<p className="leading-relaxed">{game.originalDescription}</p>
</div>
) : null}
{game.tagsStr ? (
<div>
<h2 className="text-lg font-semibold mb-2">Tags</h2>
<div className="flex flex-wrap gap-2">
{game.tagsStr.split(';').map((tag) => {
const trimmedTag = tag.trim();
const tagUrl = `/browse?${buildFilterSearchParams({ tags: trimmedTag, fo: 'Tag' }).toString()}`;
return (
<Link key={trimmedTag} to={tagUrl}>
<Badge
variant="tag"
className="cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
>
{trimmedTag}
</Badge>
</Link>
);
})}
</div>
</div>
) : null}
{game.notes ? (
<div className="bg-muted/50 rounded p-4">
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Notes</h3>
<p className="text-sm">{game.notes}</p>
</div>
) : null}
</div>
</div>
</ErrorBoundary>
);
}