feat(app): expose local sharing and verified transfers
Add a durable, acknowledged Local network sharing switch with fail-closed hydration, serialized mutation, and redacted ephemeral-identity diagnostics. Keep local Call-to-Play state available while gating every network action on the effective sharing generation. Render revisioned verification, invalid-source retry, and sticky source exhaustion states. Preserve opaque attempt IDs through progress delivery so out-of-order webview events cannot attach stale bytes to a successor transfer, and keep terminal exhaustion visible after the last source departs. Own listeners, native invokes, persistence, dialogs, and companion-window creation through webview close. Late creation is settled and cleaned before the parent realm is destroyed. Test Plan: - `just frontend-test` -- passed (91/91) - `just build` -- passed with TypeScript, Vite, and release Tauri compilation - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `git diff --cached --check` -- passed
This commit is contained in:
42 files changed
+7457
-2101
No files matched your search
@@ -1,264 +1,443 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import { TopBar } from '../components/topbar/TopBar';
|
||||
import { KebabItem } from '../components/topbar/KebabMenu';
|
||||
import { ResultsBar } from '../components/grid/ResultsBar';
|
||||
import { GameGrid } from '../components/grid/GameGrid';
|
||||
import { GameDetailModal } from '../components/modals/GameDetailModal';
|
||||
import { ConfirmRemoveDownloadModal } from '../components/modals/ConfirmRemoveDownloadModal';
|
||||
import { SettingsDialog } from '../components/modals/SettingsDialog';
|
||||
import { NoDirectoryState } from '../components/empty/NoDirectoryState';
|
||||
import { EmptyResultsState } from '../components/empty/EmptyResultsState';
|
||||
import { CallToPlayTicker } from '../components/calltoplay/CallToPlayTicker';
|
||||
import { CallToPlayOverlay } from '../components/calltoplay/CallToPlayOverlay';
|
||||
import { TopBar } from "../components/topbar/TopBar";
|
||||
import { KebabItem } from "../components/topbar/KebabMenu";
|
||||
import { ResultsBar } from "../components/grid/ResultsBar";
|
||||
import { GameGrid } from "../components/grid/GameGrid";
|
||||
import { GameDetailModal } from "../components/modals/GameDetailModal";
|
||||
import { ConfirmRemoveDownloadModal } from "../components/modals/ConfirmRemoveDownloadModal";
|
||||
import { SettingsDialog } from "../components/modals/SettingsDialog";
|
||||
import { NoDirectoryState } from "../components/empty/NoDirectoryState";
|
||||
import { EmptyResultsState } from "../components/empty/EmptyResultsState";
|
||||
import { CallToPlayTicker } from "../components/calltoplay/CallToPlayTicker";
|
||||
import { CallToPlayOverlay } from "../components/calltoplay/CallToPlayOverlay";
|
||||
|
||||
import { useGameDirectory } from '../hooks/useGameDirectory';
|
||||
import { useGames } from '../hooks/useGames';
|
||||
import { useGameActions } from '../hooks/useGameActions';
|
||||
import { useThumbnails } from '../hooks/useThumbnails';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { useCallToPlay } from '../hooks/useCallToPlay';
|
||||
import { useGameDirectory } from "../hooks/useGameDirectory";
|
||||
import { useGames } from "../hooks/useGames";
|
||||
import { useGameActions } from "../hooks/useGameActions";
|
||||
import { useThumbnails } from "../hooks/useThumbnails";
|
||||
import { useSettings } from "../hooks/useSettings";
|
||||
import { useCallToPlay } from "../hooks/useCallToPlay";
|
||||
import { useStreamInstallCapability } from "../hooks/useStreamInstallCapability";
|
||||
import { useLocalNetworkSharing } from "../hooks/useLocalNetworkSharing";
|
||||
import { useIdentityDiagnostic } from "../hooks/useIdentityDiagnostic";
|
||||
|
||||
import { Game } from '../lib/types';
|
||||
import { applyFilterAndSort, countByFilter, needsUpdate } from '../lib/gameState';
|
||||
import { Game } from "../lib/types";
|
||||
import {
|
||||
AsyncOwner,
|
||||
ownCompanionWindowCreation,
|
||||
windowAsyncScope,
|
||||
} from "../lib/asyncOwnership";
|
||||
import {
|
||||
isLocalNetworkSharingActive,
|
||||
localNetworkSharingNotice,
|
||||
} from "../lib/localNetworkSharing";
|
||||
import {
|
||||
EPHEMERAL_IDENTITY_NOTICE,
|
||||
IDENTITY_STATUS_UNAVAILABLE_NOTICE,
|
||||
} from "../lib/identityDiagnostic";
|
||||
import {
|
||||
applyFilterAndSort,
|
||||
countByFilter,
|
||||
needsUpdate,
|
||||
} from "../lib/gameState";
|
||||
|
||||
const openLogsWindow = async () => {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
|
||||
try {
|
||||
const existing = await WebviewWindow.getByLabel('unpack-logs');
|
||||
if (existing) {
|
||||
await existing.setFocus();
|
||||
return;
|
||||
}
|
||||
const win = new WebviewWindow('unpack-logs', {
|
||||
url: '/?view=unpack-logs',
|
||||
title: 'Unpack Logs',
|
||||
width: 900,
|
||||
height: 700,
|
||||
resizable: true,
|
||||
});
|
||||
await win.once<unknown>('tauri://error', (event) => {
|
||||
console.error('Error opening unpack logs window:', event.payload);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error opening unpack logs window:', err);
|
||||
interface CompanionWindowOptions {
|
||||
label: string;
|
||||
url: string;
|
||||
title: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const openCompanionWindow = async (
|
||||
owner: AsyncOwner,
|
||||
options: CompanionWindowOptions,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
let windowModule:
|
||||
| typeof import("@tauri-apps/api/webviewWindow")
|
||||
| undefined;
|
||||
if (
|
||||
!await owner.applyIfActive(
|
||||
() => import("@tauri-apps/api/webviewWindow"),
|
||||
(loaded) => {
|
||||
windowModule = loaded;
|
||||
},
|
||||
) || windowModule === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { WebviewWindow } = windowModule;
|
||||
const existingResult: Array<
|
||||
Awaited<ReturnType<typeof WebviewWindow.getByLabel>>
|
||||
> = [];
|
||||
if (
|
||||
!await owner.applyIfActive(
|
||||
() => WebviewWindow.getByLabel(options.label),
|
||||
(found) => {
|
||||
existingResult.push(found);
|
||||
},
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const existingWindow = existingResult[0];
|
||||
if (existingWindow) {
|
||||
await owner.applyIfActive(() => existingWindow.setFocus(), () => {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!owner.isActive()) return;
|
||||
const win = new WebviewWindow(options.label, {
|
||||
url: options.url,
|
||||
title: options.title,
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
resizable: true,
|
||||
});
|
||||
const result = await ownCompanionWindowCreation(owner, {
|
||||
registerCreated: (handler) => win.once("tauri://created", handler),
|
||||
registerError: (handler) =>
|
||||
win.once<unknown>("tauri://error", (event) => handler(event.payload)),
|
||||
destroy: () => win.destroy(),
|
||||
});
|
||||
if (!owner.isActive()) return;
|
||||
if (result.kind === "error") {
|
||||
console.error(`Error opening ${options.title}:`, result.payload);
|
||||
} else if (result.kind === "registration-error") {
|
||||
console.error(
|
||||
`Error owning ${options.title} creation:`,
|
||||
result.error,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (owner.isActive()) console.error(`Error opening ${options.title}:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
const openMainLogsWindow = async () => {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
|
||||
try {
|
||||
const existing = await WebviewWindow.getByLabel('main-logs');
|
||||
if (existing) {
|
||||
await existing.setFocus();
|
||||
return;
|
||||
}
|
||||
const win = new WebviewWindow('main-logs', {
|
||||
url: '/?view=main-logs',
|
||||
title: 'Application Logs',
|
||||
width: 980,
|
||||
height: 720,
|
||||
resizable: true,
|
||||
});
|
||||
await win.once<unknown>('tauri://error', (event) => {
|
||||
console.error('Error opening application logs window:', event.payload);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error opening application logs window:', err);
|
||||
}
|
||||
const UNPACK_LOG_WINDOW: CompanionWindowOptions = {
|
||||
label: "unpack-logs",
|
||||
url: "/?view=unpack-logs",
|
||||
title: "Unpack Logs",
|
||||
width: 900,
|
||||
height: 700,
|
||||
};
|
||||
|
||||
const MAIN_LOG_WINDOW: CompanionWindowOptions = {
|
||||
label: "main-logs",
|
||||
url: "/?view=main-logs",
|
||||
title: "Application Logs",
|
||||
width: 980,
|
||||
height: 720,
|
||||
};
|
||||
|
||||
export const MainWindow = () => {
|
||||
const { settings, set: setSetting } = useSettings();
|
||||
const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory();
|
||||
const games = useGames(rescan);
|
||||
const actions = useGameActions(games, settings);
|
||||
const thumbnails = useThumbnails();
|
||||
const callToPlay = useCallToPlay(settings.username);
|
||||
const { settings, set: setSetting, ready: settingsReady } = useSettings();
|
||||
const localNetworkSharing = useLocalNetworkSharing();
|
||||
const identityDiagnostic = useIdentityDiagnostic();
|
||||
const {
|
||||
gameDir,
|
||||
ready: gameDirectoryReady,
|
||||
hasGameDirectory,
|
||||
setGameDir,
|
||||
rescan,
|
||||
} = useGameDirectory(localNetworkSharing.ready);
|
||||
const games = useGames(rescan);
|
||||
const actions = useGameActions(games, settings);
|
||||
const thumbnails = useThumbnails(games.games.map((game) => game.id));
|
||||
const sharingActive = isLocalNetworkSharingActive(
|
||||
localNetworkSharing.snapshot,
|
||||
);
|
||||
const callToPlay = useCallToPlay(settings.username, sharingActive);
|
||||
const windowActionOwnerRef = useRef<AsyncOwner | null>(null);
|
||||
|
||||
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
|
||||
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
|
||||
const visibleGames = useMemo(
|
||||
() => hasGameDirectory ? games.games : [],
|
||||
[games.games, hasGameDirectory],
|
||||
);
|
||||
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
|
||||
useEffect(() => {
|
||||
const owner = new AsyncOwner((error) => {
|
||||
console.error("Failed to clean up a main-window action:", error);
|
||||
});
|
||||
windowActionOwnerRef.current = owner;
|
||||
return () => {
|
||||
if (windowActionOwnerRef.current === owner) {
|
||||
windowActionOwnerRef.current = null;
|
||||
}
|
||||
windowAsyncScope.adopt(owner.dispose());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Query is local UI state (no need to persist).
|
||||
const [query, setQuery] = useState('');
|
||||
const filteredGames = useMemo(
|
||||
() => applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
|
||||
[visibleGames, settings.filter, settings.sort, query],
|
||||
);
|
||||
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||
const supportsStreamedInstall = useStreamInstallCapability(openGameId);
|
||||
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [callToPlayOpen, setCallToPlayOpen] = useState(false);
|
||||
const [focusedCallId, setFocusedCallId] = useState<string | null>(null);
|
||||
const visibleGames = useMemo(
|
||||
() => hasGameDirectory ? games.games : [],
|
||||
[games.games, hasGameDirectory],
|
||||
);
|
||||
const counts = useMemo(() => countByFilter(visibleGames), [visibleGames]);
|
||||
|
||||
const openGame = useMemo<Game | null>(
|
||||
() => openGameId ? games.games.find(g => g.id === openGameId) ?? null : null,
|
||||
[openGameId, games.games],
|
||||
);
|
||||
const removeGame = useMemo<Game | null>(
|
||||
() => removeGameId ? games.games.find(g => g.id === removeGameId) ?? null : null,
|
||||
[removeGameId, games.games],
|
||||
);
|
||||
// Query is local UI state (no need to persist).
|
||||
const [query, setQuery] = useState("");
|
||||
const filteredGames = useMemo(
|
||||
() =>
|
||||
applyFilterAndSort(visibleGames, settings.filter, settings.sort, query),
|
||||
[visibleGames, settings.filter, settings.sort, query],
|
||||
);
|
||||
|
||||
const pickDirectory = useCallback(async () => {
|
||||
const picked = await open({ multiple: false, directory: true });
|
||||
if (typeof picked === 'string' && picked) setGameDir(picked);
|
||||
}, [setGameDir]);
|
||||
const openGame = useMemo<Game | null>(
|
||||
() =>
|
||||
openGameId ? games.games.find((g) => g.id === openGameId) ?? null : null,
|
||||
[openGameId, games.games],
|
||||
);
|
||||
const removeGame = useMemo<Game | null>(
|
||||
() =>
|
||||
removeGameId
|
||||
? games.games.find((g) => g.id === removeGameId) ?? null
|
||||
: null,
|
||||
[removeGameId, games.games],
|
||||
);
|
||||
|
||||
const handlePrimary = useCallback((game: Game) => {
|
||||
if (game.installed) {
|
||||
if (needsUpdate(game)) actions.update(game.id);
|
||||
else actions.play(game.id);
|
||||
} else {
|
||||
actions.install(game.id);
|
||||
const pickDirectory = useCallback(async () => {
|
||||
const owner = windowActionOwnerRef.current;
|
||||
if (owner === null || !owner.isActive()) return;
|
||||
|
||||
try {
|
||||
await owner.applyIfActive(
|
||||
() => open({ multiple: false, directory: true }),
|
||||
(picked) => {
|
||||
if (typeof picked === "string" && picked) setGameDir(picked);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (owner.isActive()) {
|
||||
console.error("Failed to choose a game directory:", error);
|
||||
}
|
||||
}
|
||||
}, [setGameDir]);
|
||||
|
||||
const handlePrimary = useCallback((game: Game) => {
|
||||
if (game.installed) {
|
||||
if (needsUpdate(game)) actions.update(game.id);
|
||||
else actions.play(game.id);
|
||||
} else {
|
||||
actions.install(game.id);
|
||||
}
|
||||
}, [actions]);
|
||||
|
||||
const handleUninstall = useCallback((game: Game) => {
|
||||
actions.uninstall(game.id);
|
||||
}, [actions]);
|
||||
|
||||
const handleRemoveDownload = useCallback((game: Game) => {
|
||||
setRemoveGameId(game.id);
|
||||
}, []);
|
||||
|
||||
const confirmRemoveDownload = useCallback((game: Game) => {
|
||||
actions.removeDownload(game.id);
|
||||
setRemoveGameId(null);
|
||||
setOpenGameId((current) => current === game.id ? null : current);
|
||||
}, [actions]);
|
||||
|
||||
const kebabItems: ReadonlyArray<KebabItem> = useMemo(() => [
|
||||
{ kind: "item", label: "Settings", onClick: () => setSettingsOpen(true) },
|
||||
{ kind: "item", label: "Refresh library", onClick: () => rescan() },
|
||||
{ kind: "separator" },
|
||||
{
|
||||
kind: "item",
|
||||
label: "Application logs",
|
||||
onClick: () => {
|
||||
const owner = windowActionOwnerRef.current;
|
||||
if (owner) {
|
||||
windowAsyncScope.adopt(openCompanionWindow(owner, MAIN_LOG_WINDOW));
|
||||
}
|
||||
}, [actions]);
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "item",
|
||||
label: "Unpack logs",
|
||||
onClick: () => {
|
||||
const owner = windowActionOwnerRef.current;
|
||||
if (owner) {
|
||||
windowAsyncScope.adopt(openCompanionWindow(owner, UNPACK_LOG_WINDOW));
|
||||
}
|
||||
},
|
||||
},
|
||||
], [rescan]);
|
||||
|
||||
const handleUninstall = useCallback((game: Game) => {
|
||||
actions.uninstall(game.id);
|
||||
}, [actions]);
|
||||
const rootStyle = { "--accent": settings.accent } as React.CSSProperties;
|
||||
const className = [
|
||||
"launcher",
|
||||
`bg-${settings.bg}`,
|
||||
`density-${settings.density}`,
|
||||
].join(" ");
|
||||
|
||||
const handleRemoveDownload = useCallback((game: Game) => {
|
||||
setRemoveGameId(game.id);
|
||||
}, []);
|
||||
if (
|
||||
!settingsReady ||
|
||||
!localNetworkSharing.ready ||
|
||||
!identityDiagnostic.ready ||
|
||||
!gameDirectoryReady
|
||||
) {
|
||||
return <div className={className} style={rootStyle} aria-busy="true" />;
|
||||
}
|
||||
|
||||
const confirmRemoveDownload = useCallback((game: Game) => {
|
||||
actions.removeDownload(game.id);
|
||||
setRemoveGameId(null);
|
||||
setOpenGameId(current => current === game.id ? null : current);
|
||||
}, [actions]);
|
||||
|
||||
const kebabItems: ReadonlyArray<KebabItem> = useMemo(() => [
|
||||
{ kind: 'item', label: 'Settings', onClick: () => setSettingsOpen(true) },
|
||||
{ kind: 'item', label: 'Refresh library', onClick: () => rescan() },
|
||||
{ kind: 'separator' },
|
||||
{ kind: 'item', label: 'Application logs', onClick: () => void openMainLogsWindow() },
|
||||
{ kind: 'item', label: 'Unpack logs', onClick: () => void openLogsWindow() },
|
||||
], [rescan]);
|
||||
|
||||
const rootStyle = { '--accent': settings.accent } as React.CSSProperties;
|
||||
const className = [
|
||||
'launcher',
|
||||
`bg-${settings.bg}`,
|
||||
`density-${settings.density}`,
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<div className={className} style={rootStyle}>
|
||||
<TopBar
|
||||
accent={settings.accent}
|
||||
peerCount={games.totalPeerCount}
|
||||
filter={settings.filter}
|
||||
setFilter={(v) => setSetting('filter', v)}
|
||||
counts={counts}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
sort={settings.sort}
|
||||
setSort={(v) => setSetting('sort', v)}
|
||||
kebabItems={kebabItems}
|
||||
nominations={callToPlay.nominations}
|
||||
onOpenCallToPlay={() => {
|
||||
setFocusedCallId(null);
|
||||
setCallToPlayOpen(true);
|
||||
}}
|
||||
/>
|
||||
<main className="grid-wrap">
|
||||
<CallToPlayTicker
|
||||
nominations={callToPlay.nominations}
|
||||
games={games.games}
|
||||
accent={settings.accent}
|
||||
onOpen={(callId) => {
|
||||
setFocusedCallId(callId);
|
||||
setCallToPlayOpen(true);
|
||||
}}
|
||||
/>
|
||||
{hasGameDirectory ? (
|
||||
<>
|
||||
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
||||
{filteredGames.length === 0 ? (
|
||||
visibleGames.length === 0 ? (
|
||||
<EmptyResultsState
|
||||
title="Scanning for games"
|
||||
hint="Looking for game bundles in your selected directory…"
|
||||
/>
|
||||
) : (
|
||||
<EmptyResultsState
|
||||
title="Nothing matches"
|
||||
hint="No games match the current filter or search query."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<GameGrid
|
||||
games={filteredGames}
|
||||
aspect={settings.aspect}
|
||||
getThumbnail={thumbnails.get}
|
||||
onOpen={(g) => setOpenGameId(g.id)}
|
||||
onPrimary={handlePrimary}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<NoDirectoryState onChooseDirectory={() => void pickDirectory()} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{openGame && (
|
||||
<GameDetailModal
|
||||
game={openGame}
|
||||
thumbnailUrl={thumbnails.get(openGame.id)}
|
||||
onClose={() => setOpenGameId(null)}
|
||||
onPrimary={handlePrimary}
|
||||
onStreamInstall={(g) => actions.streamInstall(g.id)}
|
||||
onUninstall={handleUninstall}
|
||||
onRemoveDownload={handleRemoveDownload}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
onStartServer={(g) => actions.startServer(g.id)}
|
||||
onViewFiles={(g) => actions.viewFiles(g.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{removeGame && (
|
||||
<ConfirmRemoveDownloadModal
|
||||
game={removeGame}
|
||||
onCancel={() => setRemoveGameId(null)}
|
||||
onConfirm={confirmRemoveDownload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{settingsOpen && (
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
gameDir={gameDir}
|
||||
hasGameDirectory={hasGameDirectory}
|
||||
onPickDirectory={() => void pickDirectory()}
|
||||
onChange={setSetting}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{callToPlayOpen && (
|
||||
<CallToPlayOverlay
|
||||
nominations={callToPlay.nominations}
|
||||
games={games.games}
|
||||
actorId={callToPlay.actorId}
|
||||
actions={callToPlay.actions}
|
||||
focusId={focusedCallId}
|
||||
transportReady={callToPlay.transportReady}
|
||||
error={callToPlay.error}
|
||||
getThumbnail={thumbnails.get}
|
||||
totalPeerCount={games.totalPeerCount}
|
||||
onLaunch={handlePrimary}
|
||||
onClose={() => {
|
||||
setCallToPlayOpen(false);
|
||||
setFocusedCallId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<div className={className} style={rootStyle}>
|
||||
<TopBar
|
||||
accent={settings.accent}
|
||||
peerCount={games.totalPeerCount}
|
||||
filter={settings.filter}
|
||||
setFilter={(v) => setSetting("filter", v)}
|
||||
counts={counts}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
sort={settings.sort}
|
||||
setSort={(v) => setSetting("sort", v)}
|
||||
kebabItems={kebabItems}
|
||||
nominations={callToPlay.nominations}
|
||||
onOpenCallToPlay={() => {
|
||||
setFocusedCallId(null);
|
||||
setCallToPlayOpen(true);
|
||||
}}
|
||||
/>
|
||||
<main className="grid-wrap">
|
||||
<div
|
||||
className={`network-notice network-notice-sharing is-${localNetworkSharing.snapshot.phase}`}
|
||||
role="status"
|
||||
>
|
||||
{localNetworkSharingNotice(localNetworkSharing.snapshot)}
|
||||
</div>
|
||||
);
|
||||
{localNetworkSharing.error &&
|
||||
localNetworkSharing.snapshot.persistenceProblem === null && (
|
||||
<div className="network-notice is-error" role="status">
|
||||
{localNetworkSharing.error}
|
||||
</div>
|
||||
)}
|
||||
{identityDiagnostic.snapshot.diagnostic === "ephemeral" && (
|
||||
<div className="network-notice identity-notice" role="status">
|
||||
{EPHEMERAL_IDENTITY_NOTICE}
|
||||
</div>
|
||||
)}
|
||||
{identityDiagnostic.unavailable && (
|
||||
<div className="network-notice identity-notice" role="status">
|
||||
{IDENTITY_STATUS_UNAVAILABLE_NOTICE}
|
||||
</div>
|
||||
)}
|
||||
{games.protocolMismatch && (
|
||||
<div className="network-notice" role="status">
|
||||
Nearby devices are running a different Lanspread version
|
||||
</div>
|
||||
)}
|
||||
<CallToPlayTicker
|
||||
nominations={callToPlay.nominations}
|
||||
games={games.games}
|
||||
accent={settings.accent}
|
||||
onOpen={(callId) => {
|
||||
setFocusedCallId(callId);
|
||||
setCallToPlayOpen(true);
|
||||
}}
|
||||
/>
|
||||
{hasGameDirectory
|
||||
? (
|
||||
<>
|
||||
<ResultsBar shown={filteredGames.length} total={counts.all} />
|
||||
{filteredGames.length === 0
|
||||
? (
|
||||
visibleGames.length === 0
|
||||
? (
|
||||
<EmptyResultsState
|
||||
title="Scanning for games"
|
||||
hint="Looking for game bundles in your selected directory…"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<EmptyResultsState
|
||||
title="Nothing matches"
|
||||
hint="No games match the current filter or search query."
|
||||
/>
|
||||
)
|
||||
)
|
||||
: (
|
||||
<GameGrid
|
||||
games={filteredGames}
|
||||
aspect={settings.aspect}
|
||||
getThumbnail={thumbnails.get}
|
||||
onOpen={(g) => setOpenGameId(g.id)}
|
||||
onPrimary={handlePrimary}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<NoDirectoryState
|
||||
onChooseDirectory={() => windowAsyncScope.adopt(pickDirectory())}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{openGame && (
|
||||
<GameDetailModal
|
||||
game={openGame}
|
||||
thumbnailUrl={thumbnails.get(openGame.id)}
|
||||
supportsStreamedInstall={supportsStreamedInstall}
|
||||
onClose={() => setOpenGameId(null)}
|
||||
onPrimary={handlePrimary}
|
||||
onStreamInstall={(g) => actions.streamInstall(g.id)}
|
||||
onUninstall={handleUninstall}
|
||||
onRemoveDownload={handleRemoveDownload}
|
||||
onCancelDownload={(g) => actions.cancelDownload(g.id)}
|
||||
onStartServer={(g) => actions.startServer(g.id)}
|
||||
onViewFiles={(g) => actions.viewFiles(g.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{removeGame && (
|
||||
<ConfirmRemoveDownloadModal
|
||||
game={removeGame}
|
||||
onCancel={() => setRemoveGameId(null)}
|
||||
onConfirm={confirmRemoveDownload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{settingsOpen && (
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
gameDir={gameDir}
|
||||
hasGameDirectory={hasGameDirectory}
|
||||
onPickDirectory={() => windowAsyncScope.adopt(pickDirectory())}
|
||||
onChange={setSetting}
|
||||
localNetworkSharing={localNetworkSharing.snapshot}
|
||||
localNetworkSharingReady={localNetworkSharing.ready}
|
||||
localNetworkSharingBusy={localNetworkSharing.busy}
|
||||
localNetworkSharingError={localNetworkSharing.error}
|
||||
onLocalNetworkSharingChange={localNetworkSharing.setEnabled}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{callToPlayOpen && (
|
||||
<CallToPlayOverlay
|
||||
nominations={callToPlay.nominations}
|
||||
games={games.games}
|
||||
actorId={callToPlay.actorId}
|
||||
actions={callToPlay.actions}
|
||||
focusId={focusedCallId}
|
||||
transportReady={callToPlay.transportReady && sharingActive}
|
||||
error={sharingActive
|
||||
? callToPlay.error
|
||||
: "Local network sharing is off."}
|
||||
getThumbnail={thumbnails.get}
|
||||
totalPeerCount={games.totalPeerCount}
|
||||
onLaunch={handlePrimary}
|
||||
onClose={() => {
|
||||
setCallToPlayOpen(false);
|
||||
setFocusedCallId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user