diff --git a/src/__tests__/unit/components/library/LibraryMediaDetailsModal.test.tsx b/src/__tests__/unit/components/library/LibraryMediaDetailsModal.test.tsx
index 26ab8ac7..38f21e91 100644
--- a/src/__tests__/unit/components/library/LibraryMediaDetailsModal.test.tsx
+++ b/src/__tests__/unit/components/library/LibraryMediaDetailsModal.test.tsx
@@ -4,7 +4,7 @@ import { act, render, screen, waitFor, within } from "@/test-utils";
import { CoreAPI } from "@/lib/coreApi";
import type { MediaBrowseEntry, MediaMetaResponse } from "@/lib/models";
import { usePreferencesStore } from "@/lib/preferencesStore";
-import { useStatusStore } from "@/lib/store";
+import { ConnectionState, useStatusStore } from "@/lib/store";
import { LibraryMediaDetailsModal } from "@/components/library/LibraryMediaDetailsModal";
const mockRequestLibraryImage = vi.fn();
@@ -102,6 +102,7 @@ describe("LibraryMediaDetailsModal", () => {
vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(false);
useStatusStore.setState({
connected: true,
+ connectionState: ConnectionState.CONNECTED,
coreVersion: "2.15.0",
coreVersionPending: false,
});
@@ -314,6 +315,21 @@ describe("LibraryMediaDetailsModal", () => {
).toBeInTheDocument();
});
+ it("should not launch while Core is reconnecting", async () => {
+ const user = userEvent.setup();
+ const runSpy = vi.spyOn(CoreAPI, "run").mockResolvedValue();
+ useStatusStore.getState().setConnectionState(ConnectionState.RECONNECTING);
+ renderModal();
+
+ const launch = await screen.findByRole("button", {
+ name: "library.launch",
+ });
+ expect(launch).toBeDisabled();
+ await user.click(launch);
+
+ expect(runSpy).not.toHaveBeenCalled();
+ });
+
it("should offer ZapScript and relative path before NFC writing", async () => {
usePreferencesStore.setState({ nfcAvailable: true });
const user = userEvent.setup();
@@ -644,7 +660,7 @@ describe("LibraryMediaDetailsModal", () => {
});
it("should disable launch while disconnected", async () => {
- useStatusStore.setState({ connected: false });
+ useStatusStore.getState().setConnectionState(ConnectionState.DISCONNECTED);
renderModal();
expect(
diff --git a/src/__tests__/unit/coreApi.internals.test.ts b/src/__tests__/unit/coreApi.internals.test.ts
index 28af49ea..e0c31c74 100644
--- a/src/__tests__/unit/coreApi.internals.test.ts
+++ b/src/__tests__/unit/coreApi.internals.test.ts
@@ -134,6 +134,53 @@ describe("CoreAPI Internals", () => {
expect(callSpy).toHaveBeenCalledWith(Method.Systems, { all: true });
});
+ it("should keep virtual launchables when the caller opts in", async () => {
+ const steam = {
+ id: "virtual:steam",
+ name: "Steam",
+ mediaCount: 0,
+ zapScript: "**launch.system:steam",
+ };
+ const callSpy = vi.spyOn(CoreAPI, "call").mockResolvedValue({
+ systems: [
+ { id: "snes", name: "Super Nintendo", mediaCount: 12 },
+ steam,
+ ],
+ });
+
+ await expect(
+ CoreAPI.systems(undefined, { includeLaunchables: true }),
+ ).resolves.toEqual({
+ systems: [
+ { id: "snes", name: "Super Nintendo", mediaCount: 12 },
+ steam,
+ ],
+ });
+ expect(callSpy).toHaveBeenCalledWith(Method.Systems, undefined);
+ });
+
+ it.each([
+ { name: "by default", options: undefined },
+ {
+ name: "when the caller opts in",
+ options: { includeLaunchables: true },
+ },
+ ])(
+ "should drop virtual systems with a blank ZapScript $name",
+ async ({ options }) => {
+ vi.spyOn(CoreAPI, "call").mockResolvedValue({
+ systems: [
+ { id: "snes", name: "Super Nintendo", mediaCount: 12 },
+ { id: "virtual:blank", name: "Blank", zapScript: " " },
+ ],
+ });
+
+ await expect(CoreAPI.systems(undefined, options)).resolves.toEqual({
+ systems: [{ id: "snes", name: "Super Nintendo", mediaCount: 12 }],
+ });
+ },
+ );
+
it.each([
{
name: "systems",
diff --git a/src/__tests__/unit/lib/systemFilters.test.ts b/src/__tests__/unit/lib/systemFilters.test.ts
index 0ed3ee4c..ab014652 100644
--- a/src/__tests__/unit/lib/systemFilters.test.ts
+++ b/src/__tests__/unit/lib/systemFilters.test.ts
@@ -3,6 +3,7 @@ import type { System } from "@/lib/models";
import {
filterSystemCatalog,
systemHasIndexedMedia,
+ systemIsLaunchable,
systemManufacturers,
systemReleaseYear,
systemSubtitle,
@@ -62,6 +63,40 @@ describe("system filters", () => {
expect(systemHasIndexedMedia({})).toBe(true);
});
+ it("should keep virtual systems only when launchables are included", () => {
+ const countedSystems: System[] = [
+ { id: "SNES", name: "Super Nintendo", mediaCount: 25 },
+ { id: "3DO", name: "3DO", mediaCount: 0 },
+ {
+ id: "winamp",
+ name: "Winamp",
+ mediaCount: 0,
+ zapScript: "zaparoo://winamp/Winamp",
+ },
+ ];
+
+ const indexed = filterSystemCatalog(countedSystems, {
+ category: "all",
+ query: "",
+ });
+ const withLaunchables = filterSystemCatalog(countedSystems, {
+ category: "all",
+ query: "",
+ includeLaunchables: true,
+ });
+
+ expect(indexed.systems.map((system) => system.id)).toEqual(["SNES"]);
+ expect(withLaunchables.systems.map((system) => system.id)).toEqual([
+ "SNES",
+ "winamp",
+ ]);
+ expect(systemIsLaunchable({ zapScript: "zaparoo://winamp/Winamp" })).toBe(
+ true,
+ );
+ expect(systemIsLaunchable({ zapScript: " " })).toBe(false);
+ expect(systemIsLaunchable({})).toBe(false);
+ });
+
it("should combine manufacturer and release-period filters", () => {
const result = filterSystemCatalog(systems, {
category: "all",
diff --git a/src/__tests__/unit/routes/library.index.test.tsx b/src/__tests__/unit/routes/library.index.test.tsx
index b4d8d469..cc07bbf6 100644
--- a/src/__tests__/unit/routes/library.index.test.tsx
+++ b/src/__tests__/unit/routes/library.index.test.tsx
@@ -1,10 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
-import { render, screen } from "@/test-utils";
+import { act, render, screen, waitFor, within } from "@/test-utils";
import { seedActiveDevice } from "@/test-utils/deviceRegistry";
import { deviceRegistry } from "@/lib/devices/deviceRegistry";
import { CoreAPI } from "@/lib/coreApi";
-import { useStatusStore } from "@/lib/store";
+import { ConnectionState, useStatusStore } from "@/lib/store";
import {
libraryBrowseScrollKey,
useLibrarySessionStore,
@@ -13,7 +13,8 @@ import { useTabSessionStore } from "@/lib/tabSessionStore";
import { usePreferencesStore } from "@/lib/preferencesStore";
import { Library } from "@/routes/library.index";
-const { mockNavigate } = vi.hoisted(() => ({
+const { mockErrorToast, mockNavigate } = vi.hoisted(() => ({
+ mockErrorToast: vi.fn(),
mockNavigate: vi.fn(),
}));
@@ -56,17 +57,35 @@ vi.mock("@/hooks/useHaptics", () => ({
useHaptics: () => ({ impact: vi.fn() }),
}));
+vi.mock("@/lib/toastUtils", () => ({
+ showRateLimitedErrorToast: mockErrorToast,
+}));
+
+const WINAMP = {
+ id: "ab3cdefghijklmnopqrstuvwxy",
+ name: "Winamp",
+ category: "Other",
+ mediaCount: 0,
+ zapScript: "zaparoo://ab3cdefghijklmnopqrstuvwxy/Winamp",
+};
+
describe("Library index route", () => {
beforeEach(async () => {
vi.restoreAllMocks();
CoreAPI.reset();
mockNavigate.mockClear();
- usePreferencesStore.setState({ systemNameRegion: "auto" });
+ mockErrorToast.mockClear();
+ usePreferencesStore.setState({
+ systemNameRegion: "auto",
+ nfcAvailable: false,
+ });
+ useStatusStore.getState().setWriteQueue("");
useLibrarySessionStore.getState().reset();
useTabSessionStore.getState().reset();
await seedActiveDevice({ recordId: "device-a" });
useStatusStore.setState({
connected: true,
+ connectionState: ConnectionState.CONNECTED,
coreVersion: "2.15.0",
coreVersionPending: false,
gamesIndex: {
@@ -174,6 +193,162 @@ describe("Library index route", () => {
).not.toBeInTheDocument();
});
+ it("should list virtual systems as actions without listing empty systems", async () => {
+ const systemsSpy = vi.spyOn(CoreAPI, "systems").mockResolvedValue({
+ systems: [
+ { id: "SNES", name: "Super Nintendo", mediaCount: 150 },
+ { id: "3DO", name: "3DO", mediaCount: 0 },
+ WINAMP,
+ ],
+ });
+
+ render();
+
+ expect(
+ await screen.findByRole("button", { name: "Winamp" }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "SNES" })).toBeInTheDocument();
+ expect(
+ screen.queryByRole("link", { name: "Winamp" }),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText("3DO")).not.toBeInTheDocument();
+ expect(systemsSpy).toHaveBeenCalledWith(undefined, {
+ includeLaunchables: true,
+ });
+ });
+
+ it("should not show the empty state when only virtual systems exist", async () => {
+ vi.spyOn(CoreAPI, "systems").mockResolvedValue({
+ systems: [{ id: "3DO", name: "3DO", mediaCount: 0 }, WINAMP],
+ });
+
+ render();
+
+ expect(
+ await screen.findByRole("button", { name: "Winamp" }),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("library.noSystems")).not.toBeInTheDocument();
+ });
+
+ it("should launch a virtual system from its actions", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] });
+ vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(false);
+ const runSpy = vi.spyOn(CoreAPI, "run").mockResolvedValue();
+
+ render();
+ await user.click(await screen.findByRole("button", { name: "Winamp" }));
+ const dialog = await screen.findByRole("dialog", { name: "Winamp" });
+ await user.click(
+ within(dialog).getByRole("button", { name: "library.launch" }),
+ );
+
+ expect(runSpy).toHaveBeenCalledWith({ text: WINAMP.zapScript });
+ expect(mockErrorToast).not.toHaveBeenCalled();
+ });
+
+ it("should not launch a virtual system while Core is reconnecting", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] });
+ vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(false);
+ const runSpy = vi.spyOn(CoreAPI, "run").mockResolvedValue();
+
+ render();
+ await user.click(await screen.findByRole("button", { name: "Winamp" }));
+ const dialog = await screen.findByRole("dialog", { name: "Winamp" });
+ act(() => {
+ useStatusStore
+ .getState()
+ .setConnectionState(ConnectionState.RECONNECTING);
+ });
+ const launch = within(dialog).getByRole("button", {
+ name: "library.launch",
+ });
+ expect(launch).toBeDisabled();
+ await user.click(launch);
+
+ expect(runSpy).not.toHaveBeenCalled();
+ expect(screen.getByRole("button", { name: "Winamp" })).toBeInTheDocument();
+ });
+
+ it("should report a failed virtual system launch", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] });
+ vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(false);
+ vi.spyOn(CoreAPI, "run").mockRejectedValue(new Error("launch failed"));
+
+ render();
+ await user.click(await screen.findByRole("button", { name: "Winamp" }));
+ const dialog = await screen.findByRole("dialog", { name: "Winamp" });
+ await user.click(
+ within(dialog).getByRole("button", { name: "library.launch" }),
+ );
+
+ await waitFor(() =>
+ expect(mockErrorToast).toHaveBeenCalledWith("library.launchSystemError"),
+ );
+ expect(
+ within(dialog).getByRole("button", { name: "library.launch" }),
+ ).toBeEnabled();
+ });
+
+ it("should write a virtual system's ZapScript to a token", async () => {
+ const user = userEvent.setup();
+ usePreferencesStore.setState({ nfcAvailable: true });
+ vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] });
+
+ render();
+ await user.click(await screen.findByRole("button", { name: "Winamp" }));
+ const dialog = await screen.findByRole("dialog", { name: "Winamp" });
+ await user.click(
+ within(dialog).getByRole("button", { name: "library.write" }),
+ );
+
+ expect(useStatusStore.getState().writeQueue).toBe(WINAMP.zapScript);
+ });
+
+ it("should disable writing a virtual system without a writer", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] });
+ const writerSpy = vi
+ .spyOn(CoreAPI, "hasWriteCapableReader")
+ .mockResolvedValue(false);
+
+ render();
+ await user.click(await screen.findByRole("button", { name: "Winamp" }));
+ const dialog = await screen.findByRole("dialog", { name: "Winamp" });
+
+ await waitFor(() => expect(writerSpy).toHaveBeenCalled());
+ expect(
+ within(dialog).getByRole("button", { name: "library.write" }),
+ ).toBeDisabled();
+ expect(useStatusStore.getState().writeQueue).toBe("");
+ });
+
+ it("should disable writing a virtual system to a Core reader while reconnecting", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] });
+ vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(true);
+
+ render();
+ await user.click(await screen.findByRole("button", { name: "Winamp" }));
+ const dialog = await screen.findByRole("dialog", { name: "Winamp" });
+ const write = within(dialog).getByRole("button", {
+ name: "library.write",
+ });
+ await waitFor(() => expect(write).toBeEnabled());
+
+ act(() => {
+ useStatusStore
+ .getState()
+ .setConnectionState(ConnectionState.RECONNECTING);
+ });
+ expect(write).toBeDisabled();
+ await user.click(write);
+
+ expect(useStatusStore.getState().writeQueue).toBe("");
+ });
+
it("should use the preferred regional system names", async () => {
usePreferencesStore.setState({ systemNameRegion: "eu" });
vi.spyOn(CoreAPI, "systems").mockResolvedValue({
diff --git a/src/components/library/LibraryLaunchableModal.tsx b/src/components/library/LibraryLaunchableModal.tsx
new file mode 100644
index 00000000..cab3bfc2
--- /dev/null
+++ b/src/components/library/LibraryLaunchableModal.tsx
@@ -0,0 +1,128 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { ChevronRightIcon, PlayIcon } from "lucide-react";
+import { CoreAPI, logRunFailure } from "@/lib/coreApi";
+import type { System } from "@/lib/models";
+import { ConnectionState, useStatusStore } from "@/lib/store";
+import { showRateLimitedErrorToast } from "@/lib/toastUtils";
+import { useNfcWriteAvailable } from "@/hooks/useNfcWriteAvailable";
+import { SlideModal } from "@/components/SlideModal";
+import { Button } from "@/components/wui/Button";
+import { ModalActionRail } from "@/components/wui/ModalActionRail";
+import { LoadingSpinner } from "@/components/ui/loading-spinner";
+import { CreateIcon } from "@/lib/images";
+import { DetailRow } from "@/components/library/LibraryMediaDetailsModal";
+
+/**
+ * Actions for a Core virtual system. It has no media to browse, so the Library
+ * offers its ZapScript directly for launching or writing to a token.
+ */
+export function LibraryLaunchableModal(props: {
+ isOpen: boolean;
+ close: () => void;
+ system: System | null;
+ deviceKey: string;
+}) {
+ const { t } = useTranslation();
+ // `connected` stays true while reconnecting so cached data remains usable.
+ // Launching needs a live socket, or it would sit out the request timeout.
+ const liveConnected = useStatusStore(
+ (state) => state.connectionState === ConnectionState.CONNECTED,
+ );
+ const setWriteQueue = useStatusStore((state) => state.setWriteQueue);
+ const writeAvailable = useNfcWriteAvailable(props.deviceKey, props.isOpen);
+ const [launching, setLaunching] = useState(false);
+ const zapScript = props.system?.zapScript?.trim() ?? "";
+
+ const launch = async () => {
+ if (!zapScript || launching || !liveConnected) return;
+ setLaunching(true);
+ try {
+ await CoreAPI.run({ text: zapScript });
+ } catch (error) {
+ logRunFailure("Failed to launch virtual system from Library", error, {
+ action: "launchLibraryLaunchable",
+ });
+ showRateLimitedErrorToast(t("library.launchSystemError"));
+ } finally {
+ setLaunching(false);
+ }
+ };
+
+ const write = () => {
+ if (!zapScript || launching || !writeAvailable) return;
+ props.close();
+ setWriteQueue(zapScript);
+ };
+
+ const footer = props.system ? (
+ }
+ layout="responsive"
+ variant="text"
+ className="whitespace-nowrap"
+ disabled={!zapScript || !writeAvailable || launching}
+ onClick={write}
+ />
+ }
+ primaryAction={
+
+ ) : (
+
+ )
+ }
+ intent="primary"
+ disabled={!zapScript || !liveConnected || launching}
+ onClick={() => void launch()}
+ />
+ }
+ />
+ ) : undefined;
+
+ return (
+
+ {props.system && (
+
+ {props.system.category && (
+
+ {props.system.category}
+
+ )}
+
+
+
+ {t("library.technicalDetails")}
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/library/LibraryMediaDetailsModal.tsx b/src/components/library/LibraryMediaDetailsModal.tsx
index 429f2ae1..8fb6ed01 100644
--- a/src/components/library/LibraryMediaDetailsModal.tsx
+++ b/src/components/library/LibraryMediaDetailsModal.tsx
@@ -20,8 +20,9 @@ import {
} from "@/lib/libraryMedia";
import type { MediaBrowseEntry, TagInfo } from "@/lib/models";
import { usePreferencesStore } from "@/lib/preferencesStore";
-import { useStatusStore } from "@/lib/store";
+import { ConnectionState, useStatusStore } from "@/lib/store";
import { useSystemNameResolver } from "@/hooks/useSystemName";
+import { useNfcWriteAvailable } from "@/hooks/useNfcWriteAvailable";
import { showRateLimitedErrorToast } from "@/lib/toastUtils";
import { SlideModal } from "@/components/SlideModal";
import { TagBadge } from "@/components/TagBadge";
@@ -39,7 +40,11 @@ import {
shouldSelectMediaWriteTarget,
} from "@/lib/mediaWriteTarget";
-function DetailRow(props: { label: string; value: string; mono?: boolean }) {
+export function DetailRow(props: {
+ label: string;
+ value: string;
+ mono?: boolean;
+}) {
if (!props.value) return null;
return (
@@ -106,9 +111,12 @@ export function LibraryMediaDetailsModal(props: {
}) {
const { t } = useTranslation();
const showFilenames = usePreferencesStore((state) => state.showFilenames);
- const nfcAvailable = usePreferencesStore((state) => state.nfcAvailable);
const resolveSystemName = useSystemNameResolver();
- const connected = useStatusStore((state) => state.connected);
+ // `connected` stays true while reconnecting so cached data remains usable.
+ // Launching needs a live socket, or it would sit out the request timeout.
+ const liveConnected = useStatusStore(
+ (state) => state.connectionState === ConnectionState.CONNECTED,
+ );
const setWriteQueue = useStatusStore((state) => state.setWriteQueue);
const [imageIndex, setImageIndex] = useState(0);
const [resolvedDefaultType, setResolvedDefaultType] = useState(
@@ -157,14 +165,7 @@ export function LibraryMediaDetailsModal(props: {
gcTime: 10 * 60 * 1000,
retry: false,
});
- const writeCapabilityQuery = useQuery({
- queryKey: ["nfcWriteCapability", props.deviceKey],
- queryFn: () => CoreAPI.hasWriteCapableReader(),
- enabled: props.isOpen && connected && !nfcAvailable,
- staleTime: 60 * 1000,
- retry: false,
- });
- const writeAvailable = nfcAvailable || writeCapabilityQuery.data === true;
+ const writeAvailable = useNfcWriteAvailable(props.deviceKey, props.isOpen);
const metadata = metadataQuery.data?.media;
const metadataWritePath =
entry?.type !== "media" && metadata?.path !== entry?.path
@@ -265,7 +266,7 @@ export function LibraryMediaDetailsModal(props: {
};
const launch = async () => {
- if (!entry || launching || preparingWrite || !connected) return;
+ if (!entry || launching || preparingWrite || !liveConnected) return;
const controller = new AbortController();
launchControllerRef.current?.abort();
launchControllerRef.current = controller;
@@ -441,7 +442,7 @@ export function LibraryMediaDetailsModal(props: {
)
}
intent="primary"
- disabled={!connected || launching || preparingWrite}
+ disabled={!liveConnected || launching || preparingWrite}
onClick={() => void launch()}
/>
}
diff --git a/src/hooks/useNfcWriteAvailable.ts b/src/hooks/useNfcWriteAvailable.ts
new file mode 100644
index 00000000..c40a3562
--- /dev/null
+++ b/src/hooks/useNfcWriteAvailable.ts
@@ -0,0 +1,28 @@
+import { useQuery } from "@tanstack/react-query";
+import { CoreAPI } from "@/lib/coreApi";
+import { usePreferencesStore } from "@/lib/preferencesStore";
+import { ConnectionState, useStatusStore } from "@/lib/store";
+
+/**
+ * Whether a token can be written from here: on-device NFC, or a write-capable
+ * reader on the live-connected Core. Core is only asked while `enabled`.
+ */
+export function useNfcWriteAvailable(
+ deviceKey: string,
+ enabled: boolean,
+): boolean {
+ const nfcAvailable = usePreferencesStore((state) => state.nfcAvailable);
+ // A cached remote result must not keep writing enabled while Core is
+ // reconnecting or gone; the write queue cannot reach its reader then.
+ const liveConnected = useStatusStore(
+ (state) => state.connectionState === ConnectionState.CONNECTED,
+ );
+ const writeCapabilityQuery = useQuery({
+ queryKey: ["nfcWriteCapability", deviceKey],
+ queryFn: () => CoreAPI.hasWriteCapableReader(),
+ enabled: enabled && liveConnected && !nfcAvailable,
+ staleTime: 60 * 1000,
+ retry: false,
+ });
+ return nfcAvailable || (liveConnected && writeCapabilityQuery.data === true);
+}
diff --git a/src/lib/coreApi.ts b/src/lib/coreApi.ts
index 8ce9c47e..9dbac58f 100644
--- a/src/lib/coreApi.ts
+++ b/src/lib/coreApi.ts
@@ -71,6 +71,7 @@ import {
VersionResponse,
WriteRequest,
} from "./models";
+import { systemIsLaunchable } from "./systemFilters";
export { isIndexResponse };
@@ -1907,7 +1908,10 @@ class CoreApi {
});
}
- systems(params?: SystemsParams): Promise {
+ systems(
+ params?: SystemsParams,
+ options?: { includeLaunchables?: boolean },
+ ): Promise {
return new Promise((resolve, reject) => {
this.call(Method.Systems, params)
.then((result) => {
@@ -1923,11 +1927,15 @@ class CoreApi {
isSystem,
);
// Virtual launchables execute ZapScript directly and never own media
- // rows, so they are not valid choices in app game-system filters.
- const gameSystems = response.systems.filter(
- (system) => !system.zapScript,
+ // rows, so only callers that can launch or write them opt in. A
+ // virtual system with a blank ZapScript is never listed.
+ const systems = response.systems.filter(
+ (system) =>
+ !system.zapScript ||
+ (options?.includeLaunchables === true &&
+ systemIsLaunchable(system)),
);
- const filteredResponse = { ...response, systems: gameSystems };
+ const filteredResponse = { ...response, systems };
logger.debug(filteredResponse);
resolve(filteredResponse);
} catch (e) {
diff --git a/src/lib/systemFilters.ts b/src/lib/systemFilters.ts
index f4db4e69..1071452b 100644
--- a/src/lib/systemFilters.ts
+++ b/src/lib/systemFilters.ts
@@ -26,6 +26,10 @@ export function systemHasIndexedMedia(
return system.mediaCount === undefined || system.mediaCount > 0;
}
+export function systemIsLaunchable(system: Pick): boolean {
+ return Boolean(system.zapScript?.trim());
+}
+
export function systemReleaseYear(system: Pick) {
const year = system.releaseDate?.match(/\b\d{4}\b/)?.[0];
return year ? Number.parseInt(year, 10) : null;
@@ -95,6 +99,7 @@ export function filterSystemCatalog(
options: {
allowedSystemIds?: string[];
includeEmptySystems?: boolean;
+ includeLaunchables?: boolean;
category: string;
manufacturer?: string;
query: string;
@@ -105,12 +110,16 @@ export function filterSystemCatalog(
const allowedSystemIds = options.allowedSystemIds
? new Set(options.allowedSystemIds)
: null;
- const systemsWithMedia = options.includeEmptySystems
+ const listedSystems = options.includeEmptySystems
? systems
- : systems.filter(systemHasIndexedMedia);
+ : systems.filter(
+ (system) =>
+ systemHasIndexedMedia(system) ||
+ (options.includeLaunchables === true && systemIsLaunchable(system)),
+ );
const availableSystems = allowedSystemIds
- ? systemsWithMedia.filter((system) => allowedSystemIds.has(system.id))
- : systemsWithMedia;
+ ? listedSystems.filter((system) => allowedSystemIds.has(system.id))
+ : listedSystems;
const categories = Array.from(
new Set(
availableSystems.map((system) => system.category || FALLBACK_CATEGORY),
diff --git a/src/routes/library.$system.tsx b/src/routes/library.$system.tsx
index c788f7d8..ac1d2e0b 100644
--- a/src/routes/library.$system.tsx
+++ b/src/routes/library.$system.tsx
@@ -164,8 +164,8 @@ export function LibrarySystem() {
[browseScrollKey, updateBrowseWindows],
);
const systemsQuery = useQuery({
- queryKey: ["systems", deviceKey, { all: false }],
- queryFn: () => CoreAPI.systems(),
+ queryKey: ["systems", deviceKey, { all: false, launchables: true }],
+ queryFn: () => CoreAPI.systems(undefined, { includeLaunchables: true }),
enabled: connected,
staleTime: 60 * 1000,
});
diff --git a/src/routes/library.index.tsx b/src/routes/library.index.tsx
index 53fce966..bb12dbd4 100644
--- a/src/routes/library.index.tsx
+++ b/src/routes/library.index.tsx
@@ -11,9 +11,11 @@ import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useActiveDeviceKey } from "@/hooks/useActiveDeviceKey";
import { CoreAPI } from "@/lib/coreApi";
+import type { System } from "@/lib/models";
import {
filterSystemCatalog,
systemHasIndexedMedia,
+ systemIsLaunchable,
systemManufacturers,
systemSubtitle,
type SystemReleasePeriod,
@@ -34,6 +36,7 @@ import { SystemFilterControls } from "@/components/SystemFilterControls";
import { LibraryHeaderActions } from "@/components/library/LibraryHeader";
import { LibrarySystemFiltersModal } from "@/components/library/LibrarySystemFiltersModal";
import { LibrarySystemRefinementBar } from "@/components/library/LibrarySystemRefinementBar";
+import { LibraryLaunchableModal } from "@/components/library/LibraryLaunchableModal";
import { getTabBarPanelId, getTabBarTabId } from "@/components/wui/tabBarIds";
import { EmptyState } from "@/components/wui/EmptyState";
import { Button } from "@/components/wui/Button";
@@ -81,6 +84,8 @@ export function Library() {
const [draftReleasePeriod, setDraftReleasePeriod] =
useState("any");
const [draftSort, setDraftSort] = useState("name-asc");
+ const [launchableSystem, setLaunchableSystem] = useState(null);
+ const [launchableOpen, setLaunchableOpen] = useState(false);
const connected = useStatusStore((state) => state.connected);
const gamesIndex = useStatusStore((state) => state.gamesIndex);
const deviceKey = useActiveDeviceKey();
@@ -90,8 +95,8 @@ export function Library() {
const libraryFeature = useCoreFeature("mediaLibrary");
const favoritesFeature = useCoreFeature("mediaFavorites");
const systemsQuery = useQuery({
- queryKey: ["systems", deviceKey, { all: false }],
- queryFn: () => CoreAPI.systems(),
+ queryKey: ["systems", deviceKey, { all: false, launchables: true }],
+ queryFn: () => CoreAPI.systems(undefined, { includeLaunchables: true }),
enabled: connected && gamesIndex.exists && libraryFeature.available,
staleTime: 60 * 1000,
});
@@ -104,6 +109,7 @@ export function Library() {
() =>
filterSystemCatalog(allSystems, {
category: selectedCategory,
+ includeLaunchables: true,
manufacturer: manufacturerFilter,
query: "",
releasePeriod,
@@ -119,13 +125,16 @@ export function Library() {
() =>
filterSystemCatalog(allSystems, {
category: selectedCategory,
+ includeLaunchables: true,
manufacturer: draftManufacturer,
query: "",
releasePeriod: draftReleasePeriod,
}).systems.length,
[allSystems, selectedCategory, draftManufacturer, draftReleasePeriod],
);
- const availableSystemCount = allSystems.filter(systemHasIndexedMedia).length;
+ const availableSystemCount = allSystems.filter(
+ (system) => systemHasIndexedMedia(system) || systemIsLaunchable(system),
+ ).length;
const systemTabIdPrefix = "library-system-category-tab";
const selectedCategoryTabId = getTabBarTabId(
selectedCategory,
@@ -299,21 +308,16 @@ export function Library() {