-
}
- />
+ {((Capacitor.isNativePlatform() && cameraAvailable) ||
+ onRemoteKeyboard) && (
+
+ {Capacitor.isNativePlatform() && cameraAvailable && (
+ }
+ />
+ )}
+ {onRemoteKeyboard && (
+
+ }
+ disabled={!connected || !remoteInput.available}
+ aria-label={t("scan.remoteKeyboard")}
+ />
+
+ )}
)}
>
diff --git a/src/components/wui/Segmented.tsx b/src/components/wui/Segmented.tsx
index 45338065..56d27ecb 100644
--- a/src/components/wui/Segmented.tsx
+++ b/src/components/wui/Segmented.tsx
@@ -3,6 +3,7 @@ import { useRef, type KeyboardEvent } from "react";
interface SegmentedProps
{
label: string;
+ labelHidden?: boolean;
help?: string;
options: { value: T; label: string }[];
value: T;
@@ -11,6 +12,7 @@ interface SegmentedProps {
export function Segmented({
label,
+ labelHidden = false,
help,
options,
value,
@@ -53,7 +55,9 @@ export function Segmented({
return (
-
+
logger.warn("WebSocket send is not initialized");
}
+ private callConnected(
+ method: Method,
+ params?: unknown,
+ signal?: AbortSignal,
+ ): Promise
{
+ if (signal?.aborted) {
+ return Promise.resolve({ cancelled: true });
+ }
+
+ if (!this.transport?.isConnected) {
+ return Promise.reject(new Error("Request requires active connection"));
+ }
+
+ return this.call(method, params, signal);
+ }
+
call(
method: Method,
params?: unknown,
@@ -634,6 +653,71 @@ class CoreApi {
});
}
+ inputKeyboard(params: InputKeyboardRequest): Promise {
+ return new Promise((resolve, reject) => {
+ this.callConnected(Method.InputKeyboard, params)
+ .then(() => {
+ resolve();
+ })
+ .catch((error) => {
+ logger.error("Input keyboard API call failed:", error, {
+ category: "api",
+ action: "inputKeyboard",
+ severity: "error",
+ });
+ reject(error);
+ });
+ });
+ }
+
+ inputGamepad(params: InputGamepadRequest): Promise {
+ return new Promise((resolve, reject) => {
+ this.callConnected(Method.InputGamepad, params)
+ .then(() => {
+ resolve();
+ })
+ .catch((error) => {
+ logger.error("Input gamepad API call failed:", error, {
+ category: "api",
+ action: "inputGamepad",
+ severity: "error",
+ });
+ reject(error);
+ });
+ });
+ }
+
+ screenshot(): Promise {
+ return new Promise((resolve, reject) => {
+ this.callConnected(Method.Screenshot)
+ .then((response) => {
+ if (
+ response &&
+ typeof response === "object" &&
+ "path" in response &&
+ "data" in response &&
+ "size" in response &&
+ typeof response.path === "string" &&
+ typeof response.data === "string" &&
+ typeof response.size === "number"
+ ) {
+ resolve(response as ScreenshotResponse);
+ return;
+ }
+
+ reject(new Error("Invalid screenshot response"));
+ })
+ .catch((error) => {
+ logger.error("Screenshot API call failed:", error, {
+ category: "api",
+ action: "screenshot",
+ severity: "error",
+ });
+ reject(error);
+ });
+ });
+ }
+
write(
params: WriteRequest,
signal?: AbortSignal,
diff --git a/src/lib/featureGates.ts b/src/lib/featureGates.ts
index 0eefdc54..065e5c0b 100644
--- a/src/lib/featureGates.ts
+++ b/src/lib/featureGates.ts
@@ -21,6 +21,11 @@ export const FEATURE_GATES: Record = {
marquee: false,
labelKey: "features.mediaScrapers",
},
+ remoteInput: {
+ since: "2.10.0",
+ marquee: true,
+ labelKey: "features.remoteInput",
+ },
};
export type FeatureId = keyof typeof FEATURE_GATES;
diff --git a/src/lib/models.ts b/src/lib/models.ts
index 951fa26c..f5ebee53 100644
--- a/src/lib/models.ts
+++ b/src/lib/models.ts
@@ -37,6 +37,9 @@ export enum Method {
MediaScrapeStatus = "media.scrape.status",
MediaScrapeCancel = "media.scrape.cancel",
MediaScrapeResume = "media.scrape.resume",
+ InputKeyboard = "input.keyboard",
+ InputGamepad = "input.gamepad",
+ Screenshot = "screenshot",
}
export enum Notification {
@@ -71,6 +74,20 @@ export interface WriteRequest {
text: string;
}
+export interface InputKeyboardRequest {
+ keys: string;
+}
+
+export interface InputGamepadRequest {
+ buttons: string;
+}
+
+export interface ScreenshotResponse {
+ path: string;
+ data: string;
+ size: number;
+}
+
export interface SearchParams {
query: string;
systems: string[];
diff --git a/src/routes/-pages/Index.tsx b/src/routes/-pages/Index.tsx
index fa2fd3b3..ca30e7fa 100644
--- a/src/routes/-pages/Index.tsx
+++ b/src/routes/-pages/Index.tsx
@@ -20,6 +20,7 @@ import { LastScannedInfo } from "@/components/home/LastScannedInfo";
import { NowPlayingInfo } from "@/components/home/NowPlayingInfo";
import { HistoryModal } from "@/components/home/HistoryModal";
import { StopConfirmModal } from "@/components/home/StopConfirmModal";
+import { RemoteKeyboardModal } from "@/components/RemoteKeyboardModal";
import { useScanOperations } from "@/hooks/useScanOperations";
import { usePreferencesStore } from "@/lib/preferencesStore";
import { usePageHeadingFocus } from "@/hooks/usePageHeadingFocus";
@@ -72,6 +73,7 @@ export function Index() {
const [historyOpen, setHistoryOpen] = useState(false);
const [stopConfirmOpen, setStopConfirmOpen] = useState(false);
+ const [remoteKeyboardOpen, setRemoteKeyboardOpen] = useState(false);
// Holds the deferred history-modal toggle that fires after the pro-purchase
// modal closes. Tracked so we can cancel a pending toggle on unmount or
// when another toggle arrives before the timer fires.
@@ -201,6 +203,8 @@ export function Index() {
scanStatus={scanStatus}
onScanButton={handleScanButton}
onCameraScan={handleCameraScan}
+ connected={connected}
+ onRemoteKeyboard={() => setRemoteKeyboardOpen(true)}
/>
@@ -224,6 +228,10 @@ export function Index() {
historyData={history.data}
/>
+
setRemoteKeyboardOpen(false)}
+ />