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,137 +1,160 @@
|
||||
import { CSSProperties, MouseEvent } from 'react';
|
||||
import { CSSProperties, MouseEvent } from "react";
|
||||
|
||||
import { Game } from '../lib/types';
|
||||
import { Game } from "../lib/types";
|
||||
import {
|
||||
downloadProgressPercent,
|
||||
formatDownloadBytes,
|
||||
formatDownloadEta,
|
||||
formatDownloadSpeed,
|
||||
formatDownloadSpeedShort,
|
||||
} from '../lib/gameState';
|
||||
downloadProgressAriaLabel,
|
||||
downloadProgressPercent,
|
||||
downloadProgressTransferLabel,
|
||||
formatDownloadBytes,
|
||||
formatDownloadEta,
|
||||
formatDownloadSpeed,
|
||||
formatDownloadSpeedShort,
|
||||
} from "../lib/gameState";
|
||||
|
||||
import { Icon } from './Icon';
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
size?: 'md' | 'lg';
|
||||
full?: boolean;
|
||||
onCancel?: (game: Game) => void;
|
||||
game: Game;
|
||||
size?: "md" | "lg";
|
||||
full?: boolean;
|
||||
onCancel?: (game: Game) => void;
|
||||
}
|
||||
|
||||
const progressStats = (game: Game) => {
|
||||
const progress = game.download_progress;
|
||||
const downloaded = progress?.downloaded_bytes ?? 0;
|
||||
const total = progress?.total_bytes ?? game.size;
|
||||
const speed = progress?.bytes_per_second ?? 0;
|
||||
const remaining = Math.max(0, total - downloaded);
|
||||
const etaSeconds = speed > 0 ? remaining / speed : Number.POSITIVE_INFINITY;
|
||||
const progress = game.download_progress;
|
||||
const downloaded = progress?.downloaded_bytes ?? 0;
|
||||
const total = progress?.total_bytes ?? game.size;
|
||||
const speed = progress?.bytes_per_second ?? 0;
|
||||
const remaining = Math.max(0, total - downloaded);
|
||||
const etaSeconds = speed > 0 ? remaining / speed : Number.POSITIVE_INFINITY;
|
||||
|
||||
return {
|
||||
pct: Math.min(99, Math.round(downloadProgressPercent(game))),
|
||||
downloaded,
|
||||
total,
|
||||
speed,
|
||||
eta: etaSeconds,
|
||||
activePeerCount: progress?.active_peer_count ?? 0,
|
||||
};
|
||||
return {
|
||||
pct: Math.min(99, Math.round(downloadProgressPercent(game))),
|
||||
downloaded,
|
||||
total,
|
||||
speed,
|
||||
eta: etaSeconds,
|
||||
activePeerCount: progress?.active_peer_count ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const DownloadProgress = ({ game, size = 'md', full = false, onCancel }: Props) => {
|
||||
const stats = progressStats(game);
|
||||
const progressStyle = {
|
||||
'--download-progress': `${stats.pct}%`,
|
||||
} as CSSProperties;
|
||||
const className = [
|
||||
'dl',
|
||||
size === 'lg' ? 'dl-lg' : 'dl-md',
|
||||
full ? 'dl-full' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
export const DownloadProgress = (
|
||||
{ game, size = "md", full = false, onCancel }: Props,
|
||||
) => {
|
||||
const stats = progressStats(game);
|
||||
const transferLabel = downloadProgressTransferLabel(game);
|
||||
const ariaLabel = downloadProgressAriaLabel(game);
|
||||
const progressStyle = {
|
||||
"--download-progress": `${stats.pct}%`,
|
||||
} as CSSProperties;
|
||||
const className = [
|
||||
"dl",
|
||||
size === "lg" ? "dl-lg" : "dl-md",
|
||||
full ? "dl-full" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
const handleCancel = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onCancel?.(game);
|
||||
};
|
||||
const handleCancel = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onCancel?.(game);
|
||||
};
|
||||
|
||||
if (size === 'lg') {
|
||||
const peerUnit = stats.activePeerCount === 1 ? 'peer' : 'peers';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="progressbar"
|
||||
aria-label={`Downloading ${game.name}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={stats.pct}
|
||||
style={progressStyle}
|
||||
>
|
||||
<div className="dl-fill" aria-hidden />
|
||||
<div className="dl-lg-grid">
|
||||
<div className="dl-lg-primary">
|
||||
<span className="dl-pulse" aria-hidden />
|
||||
<span className="dl-label">Downloading</span>
|
||||
</div>
|
||||
<div className="dl-lg-secondary">
|
||||
<span className="dl-bytes">
|
||||
<strong>{formatDownloadBytes(stats.downloaded)}</strong>
|
||||
<span className="dl-of"> / {formatDownloadBytes(stats.total)}</span>
|
||||
</span>
|
||||
<span className="dl-sep">·</span>
|
||||
<span className="dl-speed">{formatDownloadSpeed(stats.speed)}</span>
|
||||
{stats.activePeerCount > 0 && (
|
||||
<>
|
||||
<span className="dl-sep dl-sep-peers">·</span>
|
||||
<span
|
||||
className="dl-peers"
|
||||
title={`Downloading from ${stats.activePeerCount} ${peerUnit} on the LAN`}
|
||||
>
|
||||
<Icon.users />
|
||||
<span>{stats.activePeerCount}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="dl-sep dl-sep-eta">·</span>
|
||||
<span className="dl-eta">{formatDownloadEta(stats.eta)} left</span>
|
||||
</div>
|
||||
<div className="dl-lg-pct">
|
||||
{stats.pct}
|
||||
<span className="dl-pct-sym">%</span>
|
||||
</div>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className="dl-cancel"
|
||||
onClick={handleCancel}
|
||||
aria-label={`Cancel download of ${game.name}`}
|
||||
>
|
||||
<Icon.close />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (size === "lg") {
|
||||
const peerUnit = stats.activePeerCount === 1 ? "peer" : "peers";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="progressbar"
|
||||
aria-label={`Downloading ${game.name}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={stats.pct}
|
||||
title={`${stats.pct}% · ${formatDownloadSpeed(stats.speed)} · ${formatDownloadEta(stats.eta)} left`}
|
||||
style={progressStyle}
|
||||
>
|
||||
<div className="dl-fill" aria-hidden />
|
||||
<div className="dl-md-row">
|
||||
<span className="dl-pct">
|
||||
<span className="dl-pulse" aria-hidden />
|
||||
{stats.pct}
|
||||
<span className="dl-pct-sym">%</span>
|
||||
<div
|
||||
className={className}
|
||||
role="progressbar"
|
||||
aria-label={ariaLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={stats.pct}
|
||||
style={progressStyle}
|
||||
>
|
||||
<div className="dl-fill" aria-hidden />
|
||||
<div className="dl-lg-grid">
|
||||
<div className="dl-lg-primary">
|
||||
<span className="dl-pulse" aria-hidden />
|
||||
<span className="dl-label">{transferLabel ?? "Downloading"}</span>
|
||||
</div>
|
||||
<div className="dl-lg-secondary">
|
||||
<span className="dl-bytes">
|
||||
<strong>{formatDownloadBytes(stats.downloaded)}</strong>
|
||||
<span className="dl-of">
|
||||
/ {formatDownloadBytes(stats.total)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="dl-sep">·</span>
|
||||
<span className="dl-speed">{formatDownloadSpeed(stats.speed)}</span>
|
||||
{stats.activePeerCount > 0 && (
|
||||
<>
|
||||
<span className="dl-sep dl-sep-peers">·</span>
|
||||
<span
|
||||
className="dl-peers"
|
||||
title={`Downloading from ${stats.activePeerCount} ${peerUnit} on the LAN`}
|
||||
>
|
||||
<Icon.users />
|
||||
<span>{stats.activePeerCount}</span>
|
||||
</span>
|
||||
<span className="dl-speed">{formatDownloadSpeedShort(stats.speed)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<span className="dl-sep dl-sep-eta">·</span>
|
||||
<span className="dl-eta">{formatDownloadEta(stats.eta)} left</span>
|
||||
</div>
|
||||
<div className="dl-lg-pct">
|
||||
{stats.pct}
|
||||
<span className="dl-pct-sym">%</span>
|
||||
</div>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className="dl-cancel"
|
||||
onClick={handleCancel}
|
||||
aria-label={`Cancel download of ${game.name}`}
|
||||
>
|
||||
<Icon.close />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="progressbar"
|
||||
aria-label={ariaLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={stats.pct}
|
||||
title={`${transferLabel ? `${transferLabel} · ` : ""}${stats.pct}% · ${
|
||||
formatDownloadSpeed(stats.speed)
|
||||
} · ${formatDownloadEta(stats.eta)} left`}
|
||||
style={progressStyle}
|
||||
>
|
||||
<div className="dl-fill" aria-hidden />
|
||||
<div className="dl-md-row">
|
||||
{transferLabel
|
||||
? (
|
||||
<span className="dl-md-status">
|
||||
<span className="dl-pulse" aria-hidden />
|
||||
<span>{transferLabel}</span>
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<span className="dl-pct">
|
||||
<span className="dl-pulse" aria-hidden />
|
||||
{stats.pct}
|
||||
<span className="dl-pct-sym">%</span>
|
||||
</span>
|
||||
<span className="dl-speed">
|
||||
{formatDownloadSpeedShort(stats.speed)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,90 +1,109 @@
|
||||
import { JSX, KeyboardEvent } from 'react';
|
||||
import { JSX, KeyboardEvent } from "react";
|
||||
|
||||
import { Game } from '../../lib/types';
|
||||
import { CoverAspect } from '../../hooks/useSettings';
|
||||
import { formatBytes } from '../../lib/format';
|
||||
import { hasNewerLocalVersion } from '../../lib/gameState';
|
||||
import { Game } from "../../lib/types";
|
||||
import { CoverAspect } from "../../hooks/useSettings";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import {
|
||||
gameTransferStatusPresentation,
|
||||
hasNewerLocalVersion,
|
||||
} from "../../lib/gameState";
|
||||
|
||||
import { GameCover } from './GameCover';
|
||||
import { StateChip } from '../StateChip';
|
||||
import { ActionButton } from '../ActionButton';
|
||||
import { Icon } from '../Icon';
|
||||
import { GameCover } from "./GameCover";
|
||||
import { StateChip } from "../StateChip";
|
||||
import { ActionButton } from "../ActionButton";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
aspect: CoverAspect;
|
||||
thumbnailUrl: string | null;
|
||||
onOpen: (game: Game) => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
onCancelDownload: (game: Game) => void;
|
||||
game: Game;
|
||||
aspect: CoverAspect;
|
||||
thumbnailUrl: string | null;
|
||||
onOpen: (game: Game) => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
onCancelDownload: (game: Game) => void;
|
||||
}
|
||||
|
||||
const metaSeparator = (...parts: Array<string | null | undefined>): JSX.Element[] => {
|
||||
const filtered = parts.filter(Boolean) as string[];
|
||||
const out: JSX.Element[] = [];
|
||||
filtered.forEach((p, i) => {
|
||||
if (i > 0) out.push(<span key={`d${i}`} className="card-dot">·</span>);
|
||||
out.push(<span key={`p${i}`}>{p}</span>);
|
||||
});
|
||||
return out;
|
||||
const metaSeparator = (
|
||||
...parts: Array<string | null | undefined>
|
||||
): JSX.Element[] => {
|
||||
const filtered = parts.filter(Boolean) as string[];
|
||||
const out: JSX.Element[] = [];
|
||||
filtered.forEach((p, i) => {
|
||||
if (i > 0) out.push(<span key={`d${i}`} className="card-dot">·</span>);
|
||||
out.push(<span key={`p${i}`}>{p}</span>);
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
export const GameCard = ({
|
||||
game,
|
||||
aspect,
|
||||
thumbnailUrl,
|
||||
onOpen,
|
||||
onPrimary,
|
||||
onCancelDownload,
|
||||
game,
|
||||
aspect,
|
||||
thumbnailUrl,
|
||||
onOpen,
|
||||
onPrimary,
|
||||
onCancelDownload,
|
||||
}: Props) => {
|
||||
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onOpen(game);
|
||||
}
|
||||
};
|
||||
const newerThanExpected = hasNewerLocalVersion(game);
|
||||
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
|
||||
const statusMessage = hasOutbound
|
||||
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}`
|
||||
: (game.status_message ?? (newerThanExpected ? 'Newer than expected' : ''));
|
||||
const statusLevel = hasOutbound
|
||||
? 'info'
|
||||
: (game.status_level ?? (newerThanExpected ? 'warning' : undefined));
|
||||
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen(game);
|
||||
}
|
||||
};
|
||||
const newerThanExpected = hasNewerLocalVersion(game);
|
||||
const transferStatus = gameTransferStatusPresentation(game.transfer_status);
|
||||
const hasOutbound = game.active_outbound_transfers !== undefined &&
|
||||
game.active_outbound_transfers > 0;
|
||||
const statusMessage = transferStatus?.message ??
|
||||
(hasOutbound
|
||||
? `Sharing to ${game.active_outbound_transfers} peer${
|
||||
game.active_outbound_transfers === 1 ? "" : "s"
|
||||
}`
|
||||
: (game.status_message ??
|
||||
(newerThanExpected ? "Newer than expected" : "")));
|
||||
const statusLevel = transferStatus?.level ??
|
||||
(hasOutbound
|
||||
? "info"
|
||||
: (game.status_level ?? (newerThanExpected ? "warning" : undefined)));
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card"
|
||||
onClick={() => onOpen(game)}
|
||||
onKeyDown={onKey}
|
||||
aria-label={game.name}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="card"
|
||||
onClick={() => onOpen(game)}
|
||||
onKeyDown={onKey}
|
||||
aria-label={game.name}
|
||||
>
|
||||
<div className="card-cover-wrap" data-aspect={aspect}>
|
||||
<GameCover game={game} aspect={aspect} thumbnailUrl={thumbnailUrl} />
|
||||
<StateChip game={game} />
|
||||
{game.peer_count > 0 && (
|
||||
<div
|
||||
className="card-mp"
|
||||
title={`${game.peer_count} peer${
|
||||
game.peer_count === 1 ? "" : "s"
|
||||
} have this`}
|
||||
>
|
||||
<Icon.users />
|
||||
<span>{game.peer_count}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="card-title" title={game.name}>{game.name}</div>
|
||||
<div className="card-meta">
|
||||
{metaSeparator(formatBytes(game.size), game.genre || null)}
|
||||
</div>
|
||||
<div
|
||||
className={`card-status${statusLevel ? ` is-${statusLevel}` : ""}`}
|
||||
>
|
||||
<div className="card-cover-wrap" data-aspect={aspect}>
|
||||
<GameCover game={game} aspect={aspect} thumbnailUrl={thumbnailUrl} />
|
||||
<StateChip game={game} />
|
||||
{game.peer_count > 0 && (
|
||||
<div className="card-mp" title={`${game.peer_count} peer${game.peer_count === 1 ? '' : 's'} have this`}>
|
||||
<Icon.users />
|
||||
<span>{game.peer_count}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="card-title" title={game.name}>{game.name}</div>
|
||||
<div className="card-meta">
|
||||
{metaSeparator(formatBytes(game.size), game.genre || null)}
|
||||
</div>
|
||||
<div className={`card-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
||||
{statusMessage}
|
||||
</div>
|
||||
<ActionButton
|
||||
game={game}
|
||||
full
|
||||
onClick={() => onPrimary(game)}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
{statusMessage}
|
||||
</div>
|
||||
<ActionButton
|
||||
game={game}
|
||||
full
|
||||
onClick={() => onPrimary(game)}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,187 +1,213 @@
|
||||
import { Modal } from '../Modal';
|
||||
import { Icon } from '../Icon';
|
||||
import { GameCover } from '../grid/GameCover';
|
||||
import { StateChip } from '../StateChip';
|
||||
import { ActionButton } from '../ActionButton';
|
||||
import { Modal } from "../Modal";
|
||||
import { Icon } from "../Icon";
|
||||
import { GameCover } from "../grid/GameCover";
|
||||
import { StateChip } from "../StateChip";
|
||||
import { ActionButton } from "../ActionButton";
|
||||
|
||||
import { Game, InstallStatus } from '../../lib/types';
|
||||
import { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
|
||||
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
|
||||
import { Game, InstallStatus } from "../../lib/types";
|
||||
import {
|
||||
canStreamInstall,
|
||||
gameStatusLabel,
|
||||
gameTransferStatusPresentation,
|
||||
hasNewerLocalVersion,
|
||||
isInProgress,
|
||||
} from "../../lib/gameState";
|
||||
import { formatBytes, formatEtiVersion, formatPlayers } from "../../lib/format";
|
||||
|
||||
interface Props {
|
||||
game: Game;
|
||||
thumbnailUrl: string | null;
|
||||
onClose: () => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
onStreamInstall: (game: Game) => void;
|
||||
onUninstall: (game: Game) => void;
|
||||
onRemoveDownload: (game: Game) => void;
|
||||
onCancelDownload: (game: Game) => void;
|
||||
onStartServer: (game: Game) => void;
|
||||
onViewFiles: (game: Game) => void;
|
||||
game: Game;
|
||||
thumbnailUrl: string | null;
|
||||
supportsStreamedInstall: boolean;
|
||||
onClose: () => void;
|
||||
onPrimary: (game: Game) => void;
|
||||
onStreamInstall: (game: Game) => void;
|
||||
onUninstall: (game: Game) => void;
|
||||
onRemoveDownload: (game: Game) => void;
|
||||
onCancelDownload: (game: Game) => void;
|
||||
onStartServer: (game: Game) => void;
|
||||
onViewFiles: (game: Game) => void;
|
||||
}
|
||||
|
||||
const tagsFromGame = (game: Game): string[] => {
|
||||
const tags: string[] = [];
|
||||
if (game.genre) tags.push(game.genre);
|
||||
if (game.publisher) tags.push(game.publisher);
|
||||
if (game.release_year) tags.push(game.release_year);
|
||||
return tags;
|
||||
const tags: string[] = [];
|
||||
if (game.genre) tags.push(game.genre);
|
||||
if (game.publisher) tags.push(game.publisher);
|
||||
if (game.release_year) tags.push(game.release_year);
|
||||
return tags;
|
||||
};
|
||||
|
||||
export const GameDetailModal = ({
|
||||
game,
|
||||
thumbnailUrl,
|
||||
onClose,
|
||||
onPrimary,
|
||||
onStreamInstall,
|
||||
onUninstall,
|
||||
onRemoveDownload,
|
||||
onCancelDownload,
|
||||
onStartServer,
|
||||
onViewFiles,
|
||||
game,
|
||||
thumbnailUrl,
|
||||
supportsStreamedInstall,
|
||||
onClose,
|
||||
onPrimary,
|
||||
onStreamInstall,
|
||||
onUninstall,
|
||||
onRemoveDownload,
|
||||
onCancelDownload,
|
||||
onStartServer,
|
||||
onViewFiles,
|
||||
}: Props) => {
|
||||
const tags = tagsFromGame(game);
|
||||
// Some game metadata contains a literal <br>; keep sanitization exact.
|
||||
const description = game.description.split('<br>').join('');
|
||||
const canRemoveDownload = game.downloaded
|
||||
&& !game.installed
|
||||
&& !isInProgress(game.install_status);
|
||||
const showStreamInstall = canStreamInstall(game);
|
||||
const canViewFiles = game.downloaded
|
||||
|| game.installed
|
||||
|| game.install_status === InstallStatus.Downloading
|
||||
|| game.install_status === InstallStatus.Installing;
|
||||
const newerThanExpected = hasNewerLocalVersion(game);
|
||||
const newerStatus = newerThanExpected
|
||||
? `Local version ${formatEtiVersion(game.local_version)} is newer than expected ${formatEtiVersion(game.eti_game_version)}.`
|
||||
: undefined;
|
||||
const hasOutbound = game.active_outbound_transfers !== undefined && game.active_outbound_transfers > 0;
|
||||
const outboundStatus = hasOutbound
|
||||
? `Sharing to ${game.active_outbound_transfers} peer${game.active_outbound_transfers === 1 ? '' : 's'}.`
|
||||
: undefined;
|
||||
const statusMessage = outboundStatus ?? game.status_message ?? newerStatus;
|
||||
const statusLevel = hasOutbound
|
||||
? 'info'
|
||||
: (game.status_level ?? (newerStatus ? 'warning' : undefined));
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<button className="modal-close" type="button" onClick={onClose} aria-label="Close">
|
||||
<Icon.close />
|
||||
const tags = tagsFromGame(game);
|
||||
// Some game metadata contains a literal <br>; keep sanitization exact.
|
||||
const description = game.description.split("<br>").join("");
|
||||
const canRemoveDownload = game.downloaded &&
|
||||
!game.installed &&
|
||||
!isInProgress(game.install_status);
|
||||
const showStreamInstall = canStreamInstall(game, supportsStreamedInstall);
|
||||
const canViewFiles = game.downloaded ||
|
||||
game.installed ||
|
||||
game.install_status === InstallStatus.Downloading ||
|
||||
game.install_status === InstallStatus.Installing;
|
||||
const newerThanExpected = hasNewerLocalVersion(game);
|
||||
const newerStatus = newerThanExpected
|
||||
? `Local version ${
|
||||
formatEtiVersion(game.local_version)
|
||||
} is newer than expected ${formatEtiVersion(game.eti_game_version)}.`
|
||||
: undefined;
|
||||
const hasOutbound = game.active_outbound_transfers !== undefined &&
|
||||
game.active_outbound_transfers > 0;
|
||||
const outboundStatus = hasOutbound
|
||||
? `Sharing to ${game.active_outbound_transfers} peer${
|
||||
game.active_outbound_transfers === 1 ? "" : "s"
|
||||
}.`
|
||||
: undefined;
|
||||
const transferStatus = gameTransferStatusPresentation(game.transfer_status);
|
||||
const statusMessage = transferStatus?.message ?? outboundStatus ??
|
||||
game.status_message ?? newerStatus;
|
||||
const statusLevel = transferStatus?.level ??
|
||||
(hasOutbound
|
||||
? "info"
|
||||
: (game.status_level ?? (newerStatus ? "warning" : undefined)));
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<button
|
||||
className="modal-close"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon.close />
|
||||
</button>
|
||||
<div className="modal-hero">
|
||||
<GameCover
|
||||
game={game}
|
||||
aspect="banner"
|
||||
thumbnailUrl={thumbnailUrl}
|
||||
hideTitle
|
||||
/>
|
||||
<div className="modal-hero-fade" />
|
||||
<div className="modal-hero-text">
|
||||
{tags.length > 0 && (
|
||||
<div className="modal-tags">
|
||||
{tags.map((t) => <span key={t} className="modal-tag">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="modal-title">{game.name}</h2>
|
||||
</div>
|
||||
<div className="modal-state">
|
||||
<StateChip game={game} showNone />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="modal-meta">
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Size</div>
|
||||
<div className="meta-value">{formatBytes(game.size)}</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Players</div>
|
||||
<div className="meta-value">
|
||||
<Icon.users /> {formatPlayers(game.max_players)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Version</div>
|
||||
<div className="meta-value meta-mono">
|
||||
{formatEtiVersion(game.eti_game_version ?? game.local_version)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Status</div>
|
||||
<div className="meta-value">{gameStatusLabel(game)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{description && <p className="modal-desc">{description}</p>}
|
||||
|
||||
{statusMessage && (
|
||||
<p
|
||||
className={`modal-status${statusLevel ? ` is-${statusLevel}` : ""}`}
|
||||
>
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<ActionButton
|
||||
game={game}
|
||||
size="lg"
|
||||
onClick={() => onPrimary(game)}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
{showStreamInstall && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
title="Install without keeping archive files"
|
||||
onClick={() => onStreamInstall(game)}
|
||||
>
|
||||
<Icon.install />
|
||||
<span>Low disk install</span>
|
||||
</button>
|
||||
<div className="modal-hero">
|
||||
<GameCover game={game} aspect="banner" thumbnailUrl={thumbnailUrl} hideTitle />
|
||||
<div className="modal-hero-fade" />
|
||||
<div className="modal-hero-text">
|
||||
{tags.length > 0 && (
|
||||
<div className="modal-tags">
|
||||
{tags.map(t => <span key={t} className="modal-tag">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="modal-title">{game.name}</h2>
|
||||
</div>
|
||||
<div className="modal-state">
|
||||
<StateChip game={game} showNone />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="modal-meta">
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Size</div>
|
||||
<div className="meta-value">{formatBytes(game.size)}</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Players</div>
|
||||
<div className="meta-value">
|
||||
<Icon.users /> {formatPlayers(game.max_players)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Version</div>
|
||||
<div className="meta-value meta-mono">
|
||||
{formatEtiVersion(game.eti_game_version ?? game.local_version)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta-cell">
|
||||
<div className="meta-label">Status</div>
|
||||
<div className="meta-value">{gameStatusLabel(game)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className="modal-desc">{description}</p>
|
||||
)}
|
||||
|
||||
{statusMessage && (
|
||||
<p className={`modal-status${statusLevel ? ` is-${statusLevel}` : ''}`}>
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<ActionButton
|
||||
game={game}
|
||||
size="lg"
|
||||
onClick={() => onPrimary(game)}
|
||||
onCancelDownload={onCancelDownload}
|
||||
/>
|
||||
{showStreamInstall && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
title="Install without keeping archive files"
|
||||
onClick={() => onStreamInstall(game)}
|
||||
>
|
||||
<Icon.install />
|
||||
<span>Low disk install</span>
|
||||
</button>
|
||||
)}
|
||||
{game.installed && game.can_host_server === true && (
|
||||
<button
|
||||
type="button"
|
||||
className="act-btn act-lg act-server"
|
||||
onClick={() => onStartServer(game)}
|
||||
>
|
||||
<Icon.server />
|
||||
<span className="act-label">Start Server</span>
|
||||
</button>
|
||||
)}
|
||||
{game.installed && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn ghost-danger"
|
||||
onClick={() => onUninstall(game)}
|
||||
>
|
||||
<Icon.trash />
|
||||
<span>Uninstall</span>
|
||||
</button>
|
||||
)}
|
||||
{canRemoveDownload && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn ghost-danger"
|
||||
onClick={() => onRemoveDownload(game)}
|
||||
>
|
||||
<Icon.trash />
|
||||
<span>Remove files</span>
|
||||
</button>
|
||||
)}
|
||||
{canViewFiles && (
|
||||
<>
|
||||
<div className="modal-actions-spacer" />
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => onViewFiles(game)}
|
||||
>
|
||||
<Icon.folder />
|
||||
<span>View Files</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
)}
|
||||
{game.installed && game.can_host_server === true && (
|
||||
<button
|
||||
type="button"
|
||||
className="act-btn act-lg act-server"
|
||||
onClick={() => onStartServer(game)}
|
||||
>
|
||||
<Icon.server />
|
||||
<span className="act-label">Start Server</span>
|
||||
</button>
|
||||
)}
|
||||
{game.installed && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn ghost-danger"
|
||||
onClick={() => onUninstall(game)}
|
||||
>
|
||||
<Icon.trash />
|
||||
<span>Uninstall</span>
|
||||
</button>
|
||||
)}
|
||||
{canRemoveDownload && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn ghost-danger"
|
||||
onClick={() => onRemoveDownload(game)}
|
||||
>
|
||||
<Icon.trash />
|
||||
<span>Remove files</span>
|
||||
</button>
|
||||
)}
|
||||
{canViewFiles && (
|
||||
<>
|
||||
<div className="modal-actions-spacer" />
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => onViewFiles(game)}
|
||||
>
|
||||
<Icon.folder />
|
||||
<span>View Files</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
LANGUAGE_OPTIONS,
|
||||
type UISettings,
|
||||
} from '../../hooks/useSettings';
|
||||
import {
|
||||
type LocalNetworkSharingSnapshot,
|
||||
displayedSharingTarget,
|
||||
} from '../../lib/localNetworkSharing';
|
||||
|
||||
interface Props {
|
||||
settings: UISettings;
|
||||
@@ -18,6 +22,11 @@ interface Props {
|
||||
hasGameDirectory: boolean;
|
||||
onPickDirectory: () => void;
|
||||
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||
localNetworkSharing: LocalNetworkSharingSnapshot;
|
||||
localNetworkSharingReady: boolean;
|
||||
localNetworkSharingBusy: boolean;
|
||||
localNetworkSharingError: string | null;
|
||||
onLocalNetworkSharingChange: (enabled: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -59,6 +68,28 @@ const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInpu
|
||||
</div>
|
||||
);
|
||||
|
||||
interface SettingsSwitchProps {
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const SettingsSwitch = ({ checked, disabled, onChange }: SettingsSwitchProps) => (
|
||||
<label className={`settings-switch ${checked ? 'is-checked' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label="Local network sharing"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={event => onChange(event.target.checked)}
|
||||
/>
|
||||
<span className="settings-switch-track" aria-hidden="true">
|
||||
<span className="settings-switch-thumb" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
interface GameFolderFieldProps {
|
||||
path: string;
|
||||
isValid: boolean;
|
||||
@@ -88,6 +119,11 @@ export const SettingsDialog = ({
|
||||
hasGameDirectory,
|
||||
onPickDirectory,
|
||||
onChange,
|
||||
localNetworkSharing,
|
||||
localNetworkSharingReady,
|
||||
localNetworkSharingBusy,
|
||||
localNetworkSharingError,
|
||||
onLocalNetworkSharingChange,
|
||||
onClose,
|
||||
}: Props) => (
|
||||
<Modal onClose={onClose} className="settings-modal">
|
||||
@@ -123,6 +159,25 @@ export const SettingsDialog = ({
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">Network</div>
|
||||
<Row
|
||||
label="Local network sharing"
|
||||
hint="Allow nearby Lanspread devices to browse and request games from this library."
|
||||
>
|
||||
<SettingsSwitch
|
||||
checked={displayedSharingTarget(localNetworkSharing)}
|
||||
disabled={!localNetworkSharingReady || localNetworkSharingBusy}
|
||||
onChange={onLocalNetworkSharingChange}
|
||||
/>
|
||||
</Row>
|
||||
{localNetworkSharingError && (
|
||||
<div className="settings-inline-error" role="status">
|
||||
{localNetworkSharingError}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-title">Appearance</div>
|
||||
<Row label="Accent color" hint="Used for primary actions and highlights">
|
||||
|
||||
Reference in new issue
Block a user