koin.js 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import React2, { memo, useState, useEffect, useRef, createContext, useMemo, useCallback, useContext } from 'react';
2
- import { Gauge, Play, Pause, RotateCcw, Rewind, Save, Download, Camera, Video, Circle, Monitor, ChevronDown, RefreshCw, HelpCircle, Maximize, Gamepad2, Joystick, Code, Settings, Power, Square, Keyboard, X, ChevronUp, VolumeX, Volume1, Volume2, Loader2, Trophy, AlertTriangle, Minimize2, List, PauseCircle, Check, Clock, Move, User, Copy, Lock, Zap, HardDrive, Trash2, Cpu, AlertCircle, FileCode, Globe, ExternalLink, EyeOff, Eye, Shield, CheckCircle, LogOut, Info, XCircle } from 'lucide-react';
2
+ import { Gauge, Play, Pause, RotateCcw, Rewind, Save, Download, Camera, Video, Circle, Monitor, ChevronDown, RefreshCw, HelpCircle, Maximize, Gamepad2, Joystick, Code, Settings, Power, Square, Keyboard, ChevronUp, VolumeX, Volume1, Volume2, Loader2, Trophy, X, AlertTriangle, Minimize2, List, PauseCircle, Check, Clock, Lock, Unlock, Move, User, Copy, Zap, HardDrive, Trash2, Cpu, AlertCircle, FileCode, Globe, ExternalLink, EyeOff, Eye, Shield, CheckCircle, LogOut, Info, XCircle } from 'lucide-react';
3
3
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
4
4
  import { createPortal } from 'react-dom';
5
5
  import { Nostalgist } from 'nostalgist';
@@ -1441,6 +1441,40 @@ var PlayerControls = memo(function PlayerControls2({
1441
1441
  ] });
1442
1442
  });
1443
1443
  var PlayerControls_default = PlayerControls;
1444
+ function useAnimatedVisibility({
1445
+ exitDuration = 200,
1446
+ onExit,
1447
+ autoDismissMs
1448
+ } = {}) {
1449
+ const [isVisible, setIsVisible] = useState(false);
1450
+ const [isExiting, setIsExiting] = useState(false);
1451
+ useEffect(() => {
1452
+ requestAnimationFrame(() => {
1453
+ setIsVisible(true);
1454
+ });
1455
+ }, []);
1456
+ useEffect(() => {
1457
+ if (!autoDismissMs) return;
1458
+ const timer = setTimeout(() => {
1459
+ triggerExit();
1460
+ }, autoDismissMs);
1461
+ return () => clearTimeout(timer);
1462
+ }, [autoDismissMs]);
1463
+ const triggerExit = useCallback(() => {
1464
+ if (isExiting) return;
1465
+ setIsExiting(true);
1466
+ setTimeout(() => {
1467
+ onExit?.();
1468
+ }, exitDuration);
1469
+ }, [isExiting, exitDuration, onExit]);
1470
+ const slideInRightClasses = isVisible && !isExiting ? "translate-x-0 opacity-100" : "translate-x-full opacity-0";
1471
+ return {
1472
+ isVisible,
1473
+ isExiting,
1474
+ triggerExit,
1475
+ slideInRightClasses
1476
+ };
1477
+ }
1444
1478
  var TOAST_CONFIGS = {
1445
1479
  success: {
1446
1480
  icon: CheckCircle,
@@ -1485,32 +1519,20 @@ var TOAST_CONFIGS = {
1485
1519
  }
1486
1520
  };
1487
1521
  function ToastItem({ toast, onDismiss }) {
1488
- const [isVisible, setIsVisible] = useState(false);
1489
- const [isExiting, setIsExiting] = useState(false);
1522
+ const { slideInRightClasses, triggerExit } = useAnimatedVisibility({
1523
+ exitDuration: 200,
1524
+ onExit: () => onDismiss?.(toast.id)
1525
+ });
1490
1526
  const config = TOAST_CONFIGS[toast.type];
1491
1527
  const IconComponent = config.icon;
1492
- useEffect(() => {
1493
- requestAnimationFrame(() => {
1494
- setIsVisible(true);
1495
- });
1496
- }, []);
1497
- const handleDismiss = () => {
1498
- setIsExiting(true);
1499
- setTimeout(() => {
1500
- onDismiss?.(toast.id);
1501
- }, 200);
1502
- };
1503
1528
  return /* @__PURE__ */ jsx(
1504
1529
  "div",
1505
1530
  {
1506
- className: `
1507
- relative transition-all duration-300 ease-out
1508
- ${isVisible && !isExiting ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"}
1509
- `,
1531
+ className: `relative transition-all duration-300 ease-out ${slideInRightClasses}`,
1510
1532
  children: /* @__PURE__ */ jsxs(
1511
1533
  "div",
1512
1534
  {
1513
- className: "relative w-[320px]",
1535
+ className: "relative w-[320px] pointer-events-auto",
1514
1536
  style: {
1515
1537
  backgroundColor: config.bgColor,
1516
1538
  border: `2px solid ${config.borderColor}`,
@@ -1545,7 +1567,7 @@ function ToastItem({ toast, onDismiss }) {
1545
1567
  {
1546
1568
  onClick: () => {
1547
1569
  toast.action?.onClick();
1548
- handleDismiss();
1570
+ triggerExit();
1549
1571
  },
1550
1572
  className: "flex-shrink-0 text-[9px] font-black uppercase tracking-wider px-2 py-1 transition-all hover:-translate-y-0.5 active:translate-y-0",
1551
1573
  style: {
@@ -1559,7 +1581,7 @@ function ToastItem({ toast, onDismiss }) {
1559
1581
  /* @__PURE__ */ jsx(
1560
1582
  "button",
1561
1583
  {
1562
- onClick: handleDismiss,
1584
+ onClick: triggerExit,
1563
1585
  className: "flex-shrink-0 p-0.5 text-gray-500 hover:text-white transition-colors",
1564
1586
  children: /* @__PURE__ */ jsx(X, { size: 14 })
1565
1587
  }
@@ -1932,6 +1954,67 @@ var RecordingIndicator = memo(function RecordingIndicator2({
1932
1954
  );
1933
1955
  });
1934
1956
  var RecordingIndicator_default = RecordingIndicator;
1957
+ var MAX_WIDTH_CLASSES = {
1958
+ sm: "max-w-sm",
1959
+ md: "max-w-md",
1960
+ lg: "max-w-lg"
1961
+ };
1962
+ function ModalShell({
1963
+ isOpen,
1964
+ onClose,
1965
+ title,
1966
+ subtitle,
1967
+ icon,
1968
+ children,
1969
+ footer,
1970
+ maxWidth = "lg",
1971
+ systemColor,
1972
+ closeOnBackdrop = true
1973
+ }) {
1974
+ if (!isOpen) return null;
1975
+ const handleBackdropClick = () => {
1976
+ if (closeOnBackdrop) {
1977
+ onClose();
1978
+ }
1979
+ };
1980
+ return /* @__PURE__ */ jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center", children: [
1981
+ /* @__PURE__ */ jsx(
1982
+ "div",
1983
+ {
1984
+ className: "absolute inset-0 bg-black/80 backdrop-blur-sm",
1985
+ onClick: handleBackdropClick
1986
+ }
1987
+ ),
1988
+ /* @__PURE__ */ jsxs(
1989
+ "div",
1990
+ {
1991
+ className: `relative bg-gray-900 border border-retro-primary/30 rounded-xl shadow-2xl w-full ${MAX_WIDTH_CLASSES[maxWidth]} mx-4 overflow-hidden`,
1992
+ style: systemColor ? { borderColor: `${systemColor}30` } : void 0,
1993
+ children: [
1994
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between px-6 py-4 border-b border-white/10 bg-black/50", children: [
1995
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
1996
+ /* @__PURE__ */ jsx("div", { style: systemColor ? { color: systemColor } : void 0, children: icon }),
1997
+ /* @__PURE__ */ jsxs("div", { children: [
1998
+ /* @__PURE__ */ jsx("h2", { className: "text-lg font-bold text-white", children: title }),
1999
+ subtitle && /* @__PURE__ */ jsx("p", { className: "text-xs text-gray-400", children: subtitle })
2000
+ ] })
2001
+ ] }),
2002
+ /* @__PURE__ */ jsx(
2003
+ "button",
2004
+ {
2005
+ onClick: onClose,
2006
+ className: "p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-colors",
2007
+ children: /* @__PURE__ */ jsx(X, { size: 20 })
2008
+ }
2009
+ )
2010
+ ] }),
2011
+ children,
2012
+ footer && /* @__PURE__ */ jsx("div", { className: "flex items-center justify-between px-6 py-4 bg-black/30 border-t border-white/10", children: footer })
2013
+ ]
2014
+ }
2015
+ )
2016
+ ] });
2017
+ }
1935
2018
  var ShortcutsModal = memo(function ShortcutsModal2({
1936
2019
  isOpen,
1937
2020
  onClose,
@@ -1960,90 +2043,55 @@ var ShortcutsModal = memo(function ShortcutsModal2({
1960
2043
  ]
1961
2044
  }
1962
2045
  ], [t]);
1963
- if (!isOpen) return null;
1964
2046
  return /* @__PURE__ */ jsx(
1965
- "div",
2047
+ ModalShell,
1966
2048
  {
1967
- className: "absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm",
1968
- onClick: onClose,
1969
- children: /* @__PURE__ */ jsxs(
1970
- "div",
1971
- {
1972
- className: "max-w-sm w-full mx-4 bg-black/95 border rounded-lg overflow-hidden",
1973
- style: { borderColor: `${systemColor}40` },
1974
- onClick: (e) => e.stopPropagation(),
1975
- children: [
1976
- /* @__PURE__ */ jsxs(
1977
- "div",
1978
- {
1979
- className: "flex items-center justify-between px-4 py-3 border-b",
1980
- style: { borderColor: `${systemColor}20`, backgroundColor: `${systemColor}10` },
1981
- children: [
1982
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
1983
- /* @__PURE__ */ jsx(Keyboard, { size: 18, style: { color: systemColor } }),
1984
- /* @__PURE__ */ jsx("span", { className: "font-bold text-white", children: t.modals.shortcuts.playerShortcuts })
1985
- ] }),
1986
- /* @__PURE__ */ jsx(
1987
- "button",
1988
- {
1989
- onClick: onClose,
1990
- className: "p-1 rounded hover:bg-white/10 transition-colors",
1991
- children: /* @__PURE__ */ jsx(X, { size: 18, className: "text-white/60 hover:text-white" })
1992
- }
1993
- )
1994
- ]
1995
- }
1996
- ),
1997
- /* @__PURE__ */ jsxs("div", { className: "p-4 space-y-3", children: [
1998
- shortcuts.map(({ section, items }) => /* @__PURE__ */ jsxs("div", { children: [
2049
+ isOpen,
2050
+ onClose,
2051
+ title: t.modals.shortcuts.playerShortcuts,
2052
+ icon: /* @__PURE__ */ jsx(Keyboard, { size: 20, style: { color: systemColor } }),
2053
+ maxWidth: "sm",
2054
+ systemColor,
2055
+ footer: /* @__PURE__ */ jsx("span", { className: "text-xs text-gray-500 w-full text-center", children: t.modals.shortcuts.pressEsc }),
2056
+ children: /* @__PURE__ */ jsxs("div", { className: "p-4 space-y-3", children: [
2057
+ shortcuts.map(({ section, items }) => /* @__PURE__ */ jsxs("div", { children: [
2058
+ /* @__PURE__ */ jsx(
2059
+ "h3",
2060
+ {
2061
+ className: "text-[10px] font-bold uppercase tracking-wider mb-1.5 opacity-60",
2062
+ style: { color: systemColor },
2063
+ children: section
2064
+ }
2065
+ ),
2066
+ /* @__PURE__ */ jsx("div", { className: "space-y-1", children: items.map(({ key, description }) => /* @__PURE__ */ jsxs(
2067
+ "div",
2068
+ {
2069
+ className: "flex items-center justify-between text-sm",
2070
+ children: [
2071
+ /* @__PURE__ */ jsx("span", { className: "text-white/70", children: description }),
1999
2072
  /* @__PURE__ */ jsx(
2000
- "h3",
2073
+ "kbd",
2001
2074
  {
2002
- className: "text-[10px] font-bold uppercase tracking-wider mb-1.5 opacity-60",
2003
- style: { color: systemColor },
2004
- children: section
2075
+ className: "px-2 py-0.5 rounded text-xs font-mono font-bold",
2076
+ style: {
2077
+ backgroundColor: `${systemColor}20`,
2078
+ color: systemColor,
2079
+ border: `1px solid ${systemColor}40`
2080
+ },
2081
+ children: key
2005
2082
  }
2006
- ),
2007
- /* @__PURE__ */ jsx("div", { className: "space-y-1", children: items.map(({ key, description }) => /* @__PURE__ */ jsxs(
2008
- "div",
2009
- {
2010
- className: "flex items-center justify-between text-sm",
2011
- children: [
2012
- /* @__PURE__ */ jsx("span", { className: "text-white/70", children: description }),
2013
- /* @__PURE__ */ jsx(
2014
- "kbd",
2015
- {
2016
- className: "px-2 py-0.5 rounded text-xs font-mono font-bold",
2017
- style: {
2018
- backgroundColor: `${systemColor}20`,
2019
- color: systemColor,
2020
- border: `1px solid ${systemColor}40`
2021
- },
2022
- children: key
2023
- }
2024
- )
2025
- ]
2026
- },
2027
- key
2028
- )) })
2029
- ] }, section)),
2030
- /* @__PURE__ */ jsxs("div", { className: "pt-2 border-t border-white/10 text-xs text-white/40", children: [
2031
- "Game controls can be configured in ",
2032
- /* @__PURE__ */ jsx("strong", { className: "text-white/60", children: t.controls.keys }),
2033
- " settings."
2034
- ] })
2035
- ] }),
2036
- /* @__PURE__ */ jsx(
2037
- "div",
2038
- {
2039
- className: "px-4 py-2 text-center text-xs text-white/40 border-t",
2040
- style: { borderColor: `${systemColor}20` },
2041
- children: t.modals.shortcuts.pressEsc
2042
- }
2043
- )
2044
- ]
2045
- }
2046
- )
2083
+ )
2084
+ ]
2085
+ },
2086
+ key
2087
+ )) })
2088
+ ] }, section)),
2089
+ /* @__PURE__ */ jsxs("div", { className: "pt-2 border-t border-white/10 text-xs text-white/40", children: [
2090
+ "Game controls can be configured in ",
2091
+ /* @__PURE__ */ jsx("strong", { className: "text-white/60", children: t.controls.keys }),
2092
+ " settings."
2093
+ ] })
2094
+ ] })
2047
2095
  }
2048
2096
  );
2049
2097
  });
@@ -2387,26 +2435,49 @@ function getLayoutForSystem(system) {
2387
2435
  if (s.includes("ATARI")) return TWO_BUTTON_LAYOUT;
2388
2436
  return TWO_BUTTON_LAYOUT;
2389
2437
  }
2390
- var DRAG_HOLD_DELAY = 350;
2438
+
2439
+ // src/components/VirtualController/utils/dragConstraints.ts
2440
+ function constrainToViewport({
2441
+ newXPercent,
2442
+ newYPercent,
2443
+ elementSize,
2444
+ containerWidth,
2445
+ containerHeight
2446
+ }) {
2447
+ const xMargin = elementSize / 2 / containerWidth * 100;
2448
+ const yMargin = elementSize / 2 / containerHeight * 100;
2449
+ return {
2450
+ x: Math.max(xMargin, Math.min(100 - xMargin, newXPercent)),
2451
+ y: Math.max(yMargin, Math.min(100 - yMargin, newYPercent))
2452
+ };
2453
+ }
2454
+
2455
+ // src/components/VirtualController/hooks/useDrag.ts
2456
+ var DEFAULT_HOLD_DELAY = 350;
2457
+ var DEFAULT_CENTER_THRESHOLD = 0.4;
2391
2458
  var DRAG_MOVE_THRESHOLD = 10;
2392
- var DRAG_CENTER_THRESHOLD = 0.4;
2393
- function useTouchHandlers({
2394
- buttonType,
2395
- isSystemButton,
2396
- buttonSize,
2459
+ function useDrag({
2460
+ elementSize,
2397
2461
  displayX,
2398
2462
  displayY,
2399
2463
  containerWidth,
2400
2464
  containerHeight,
2401
- onPress,
2402
- onPressDown,
2403
- onRelease,
2404
- onPositionChange
2465
+ onPositionChange,
2466
+ holdDelay = DEFAULT_HOLD_DELAY,
2467
+ centerThreshold = DEFAULT_CENTER_THRESHOLD,
2468
+ onDragStart,
2469
+ onDragEnd
2405
2470
  }) {
2471
+ const [isDragging, setIsDragging] = useState(false);
2406
2472
  const isDraggingRef = useRef(false);
2407
- const dragStartRef = useRef({ x: 0, y: 0 });
2408
2473
  const dragTimerRef = useRef(null);
2409
2474
  const touchStartPosRef = useRef({ x: 0, y: 0 });
2475
+ const dragStartRef = useRef({
2476
+ elementX: 0,
2477
+ elementY: 0,
2478
+ touchX: 0,
2479
+ touchY: 0
2480
+ });
2410
2481
  const clearDragTimer = useCallback(() => {
2411
2482
  if (dragTimerRef.current) {
2412
2483
  clearTimeout(dragTimerRef.current);
@@ -2416,23 +2487,129 @@ function useTouchHandlers({
2416
2487
  const startDragging = useCallback(
2417
2488
  (touchX, touchY) => {
2418
2489
  isDraggingRef.current = true;
2490
+ setIsDragging(true);
2419
2491
  dragStartRef.current = {
2420
- x: touchX - displayX / 100 * containerWidth,
2421
- y: touchY - displayY / 100 * containerHeight
2492
+ elementX: displayX,
2493
+ elementY: displayY,
2494
+ touchX,
2495
+ touchY
2422
2496
  };
2423
2497
  if (navigator.vibrate) {
2424
2498
  navigator.vibrate([10, 30, 10]);
2425
2499
  }
2500
+ onDragStart?.();
2501
+ },
2502
+ [displayX, displayY, onDragStart]
2503
+ );
2504
+ const checkDragStart = useCallback(
2505
+ (touchX, touchY, elementRect) => {
2506
+ if (!onPositionChange) return false;
2507
+ touchStartPosRef.current = { x: touchX, y: touchY };
2508
+ const centerX = elementRect.left + elementRect.width / 2;
2509
+ const centerY = elementRect.top + elementRect.height / 2;
2510
+ const distFromCenter = Math.sqrt(
2511
+ Math.pow(touchX - centerX, 2) + Math.pow(touchY - centerY, 2)
2512
+ );
2513
+ const centerRadius = elementSize * centerThreshold;
2514
+ if (distFromCenter < centerRadius) {
2515
+ dragTimerRef.current = setTimeout(() => {
2516
+ if (!isDraggingRef.current) {
2517
+ startDragging(touchX, touchY);
2518
+ }
2519
+ }, holdDelay);
2520
+ return true;
2521
+ }
2522
+ return false;
2523
+ },
2524
+ [onPositionChange, elementSize, centerThreshold, holdDelay, startDragging]
2525
+ );
2526
+ const checkMoveThreshold = useCallback(
2527
+ (touchX, touchY) => {
2528
+ if (!onPositionChange || isDraggingRef.current) return false;
2529
+ const moveDistance = Math.sqrt(
2530
+ Math.pow(touchX - touchStartPosRef.current.x, 2) + Math.pow(touchY - touchStartPosRef.current.y, 2)
2531
+ );
2532
+ if (moveDistance > DRAG_MOVE_THRESHOLD) {
2533
+ clearDragTimer();
2534
+ startDragging(touchX, touchY);
2535
+ return true;
2536
+ }
2537
+ return false;
2538
+ },
2539
+ [onPositionChange, clearDragTimer, startDragging]
2540
+ );
2541
+ const handleDragMove = useCallback(
2542
+ (touchX, touchY) => {
2543
+ if (!isDraggingRef.current || !onPositionChange) return;
2544
+ const deltaX = touchX - dragStartRef.current.touchX;
2545
+ const deltaY = touchY - dragStartRef.current.touchY;
2546
+ const newXPercent = dragStartRef.current.elementX + deltaX / containerWidth * 100;
2547
+ const newYPercent = dragStartRef.current.elementY + deltaY / containerHeight * 100;
2548
+ const constrained = constrainToViewport({
2549
+ newXPercent,
2550
+ newYPercent,
2551
+ elementSize,
2552
+ containerWidth,
2553
+ containerHeight
2554
+ });
2555
+ onPositionChange(constrained.x, constrained.y);
2556
+ },
2557
+ [onPositionChange, containerWidth, containerHeight, elementSize]
2558
+ );
2559
+ const handleDragEnd = useCallback(() => {
2560
+ clearDragTimer();
2561
+ if (isDraggingRef.current) {
2562
+ isDraggingRef.current = false;
2563
+ setIsDragging(false);
2564
+ onDragEnd?.();
2565
+ }
2566
+ }, [clearDragTimer, onDragEnd]);
2567
+ return {
2568
+ isDragging,
2569
+ checkDragStart,
2570
+ handleDragMove,
2571
+ handleDragEnd,
2572
+ clearDragTimer,
2573
+ checkMoveThreshold
2574
+ };
2575
+ }
2576
+
2577
+ // src/components/VirtualController/hooks/useTouchHandlers.ts
2578
+ function useTouchHandlers({
2579
+ buttonType,
2580
+ isSystemButton,
2581
+ buttonSize,
2582
+ displayX,
2583
+ displayY,
2584
+ containerWidth,
2585
+ containerHeight,
2586
+ onPress,
2587
+ onPressDown,
2588
+ onRelease,
2589
+ onPositionChange
2590
+ }) {
2591
+ const isDraggingRef = useRef(false);
2592
+ const drag = useDrag({
2593
+ elementSize: buttonSize,
2594
+ displayX,
2595
+ displayY,
2596
+ containerWidth,
2597
+ containerHeight,
2598
+ onPositionChange,
2599
+ centerThreshold: 0.4,
2600
+ onDragStart: () => {
2601
+ isDraggingRef.current = true;
2426
2602
  if (!isSystemButton) {
2427
2603
  onRelease(buttonType);
2428
2604
  }
2429
2605
  },
2430
- [displayX, displayY, containerWidth, containerHeight, isSystemButton, buttonType, onRelease]
2431
- );
2606
+ onDragEnd: () => {
2607
+ isDraggingRef.current = false;
2608
+ }
2609
+ });
2432
2610
  const handleTouchStart = useCallback(
2433
2611
  (e) => {
2434
2612
  const touch = e.touches[0];
2435
- touchStartPosRef.current = { x: touch.clientX, y: touch.clientY };
2436
2613
  e.preventDefault();
2437
2614
  e.stopPropagation();
2438
2615
  if (navigator.vibrate) {
@@ -2447,57 +2624,32 @@ function useTouchHandlers({
2447
2624
  const target = e.currentTarget;
2448
2625
  if (!target) return;
2449
2626
  const rect = target.getBoundingClientRect();
2450
- const centerX = rect.left + rect.width / 2;
2451
- const centerY = rect.top + rect.height / 2;
2452
- const distance = Math.sqrt(
2453
- Math.pow(touch.clientX - centerX, 2) + Math.pow(touch.clientY - centerY, 2)
2454
- );
2455
- const dragThreshold = buttonSize * DRAG_CENTER_THRESHOLD;
2456
- if (distance < dragThreshold) {
2457
- dragTimerRef.current = setTimeout(() => {
2458
- if (!isDraggingRef.current) {
2459
- startDragging(touch.clientX, touch.clientY);
2460
- }
2461
- }, DRAG_HOLD_DELAY);
2462
- }
2627
+ drag.checkDragStart(touch.clientX, touch.clientY, rect);
2463
2628
  }
2464
2629
  },
2465
- [isSystemButton, buttonType, onPress, onPressDown, onPositionChange, buttonSize, startDragging]
2630
+ [isSystemButton, buttonType, onPress, onPressDown, onPositionChange, drag]
2466
2631
  );
2467
2632
  const handleTouchMove = useCallback(
2468
2633
  (e) => {
2469
2634
  const touch = e.touches[0];
2470
2635
  if (onPositionChange && !isDraggingRef.current) {
2471
- const moveDistance = Math.sqrt(
2472
- Math.pow(touch.clientX - touchStartPosRef.current.x, 2) + Math.pow(touch.clientY - touchStartPosRef.current.y, 2)
2473
- );
2474
- if (moveDistance > DRAG_MOVE_THRESHOLD) {
2475
- clearDragTimer();
2476
- startDragging(touch.clientX, touch.clientY);
2477
- }
2636
+ drag.checkMoveThreshold(touch.clientX, touch.clientY);
2478
2637
  }
2479
- if (isDraggingRef.current && onPositionChange) {
2638
+ if (isDraggingRef.current) {
2480
2639
  e.preventDefault();
2481
2640
  e.stopPropagation();
2482
- const newX = touch.clientX - dragStartRef.current.x;
2483
- const newY = touch.clientY - dragStartRef.current.y;
2484
- const newXPercent = newX / containerWidth * 100;
2485
- const newYPercent = newY / containerHeight * 100;
2486
- const margin = buttonSize / 2 / Math.min(containerWidth, containerHeight) * 100;
2487
- const constrainedX = Math.max(margin, Math.min(100 - margin, newXPercent));
2488
- const constrainedY = Math.max(margin, Math.min(100 - margin, newYPercent));
2489
- onPositionChange(constrainedX, constrainedY);
2641
+ drag.handleDragMove(touch.clientX, touch.clientY);
2490
2642
  }
2491
2643
  },
2492
- [onPositionChange, clearDragTimer, startDragging, containerWidth, containerHeight, buttonSize]
2644
+ [onPositionChange, drag]
2493
2645
  );
2494
2646
  const handleTouchEnd = useCallback(
2495
2647
  (e) => {
2496
- clearDragTimer();
2648
+ drag.clearDragTimer();
2497
2649
  if (isDraggingRef.current) {
2498
2650
  e.preventDefault();
2499
2651
  e.stopPropagation();
2500
- isDraggingRef.current = false;
2652
+ drag.handleDragEnd();
2501
2653
  return;
2502
2654
  }
2503
2655
  e.preventDefault();
@@ -2506,15 +2658,15 @@ function useTouchHandlers({
2506
2658
  onRelease(buttonType);
2507
2659
  }
2508
2660
  },
2509
- [clearDragTimer, isSystemButton, buttonType, onRelease]
2661
+ [drag, isSystemButton, buttonType, onRelease]
2510
2662
  );
2511
2663
  const handleTouchCancel = useCallback(
2512
2664
  (e) => {
2513
- clearDragTimer();
2665
+ drag.clearDragTimer();
2514
2666
  if (isDraggingRef.current) {
2515
2667
  e.preventDefault();
2516
2668
  e.stopPropagation();
2517
- isDraggingRef.current = false;
2669
+ drag.handleDragEnd();
2518
2670
  return;
2519
2671
  }
2520
2672
  e.preventDefault();
@@ -2523,11 +2675,11 @@ function useTouchHandlers({
2523
2675
  onRelease(buttonType);
2524
2676
  }
2525
2677
  },
2526
- [clearDragTimer, isSystemButton, buttonType, onRelease]
2678
+ [drag, isSystemButton, buttonType, onRelease]
2527
2679
  );
2528
2680
  const cleanup = useCallback(() => {
2529
- clearDragTimer();
2530
- }, [clearDragTimer]);
2681
+ drag.clearDragTimer();
2682
+ }, [drag]);
2531
2683
  return {
2532
2684
  handleTouchStart,
2533
2685
  handleTouchMove,
@@ -2536,6 +2688,39 @@ function useTouchHandlers({
2536
2688
  cleanup
2537
2689
  };
2538
2690
  }
2691
+ function useTouchEvents(ref, handlers, options = {}) {
2692
+ const { cleanup, passive = false } = options;
2693
+ const handlersRef = useRef(handlers);
2694
+ handlersRef.current = handlers;
2695
+ const handleTouchStart = useCallback((e) => {
2696
+ handlersRef.current.onTouchStart?.(e);
2697
+ }, []);
2698
+ const handleTouchMove = useCallback((e) => {
2699
+ handlersRef.current.onTouchMove?.(e);
2700
+ }, []);
2701
+ const handleTouchEnd = useCallback((e) => {
2702
+ handlersRef.current.onTouchEnd?.(e);
2703
+ }, []);
2704
+ const handleTouchCancel = useCallback((e) => {
2705
+ handlersRef.current.onTouchCancel?.(e);
2706
+ }, []);
2707
+ useEffect(() => {
2708
+ const element = ref.current;
2709
+ if (!element) return;
2710
+ const listenerOptions = { passive };
2711
+ element.addEventListener("touchstart", handleTouchStart, listenerOptions);
2712
+ element.addEventListener("touchmove", handleTouchMove, listenerOptions);
2713
+ element.addEventListener("touchend", handleTouchEnd, listenerOptions);
2714
+ element.addEventListener("touchcancel", handleTouchCancel, listenerOptions);
2715
+ return () => {
2716
+ element.removeEventListener("touchstart", handleTouchStart);
2717
+ element.removeEventListener("touchmove", handleTouchMove);
2718
+ element.removeEventListener("touchend", handleTouchEnd);
2719
+ element.removeEventListener("touchcancel", handleTouchCancel);
2720
+ cleanup?.();
2721
+ };
2722
+ }, [ref, passive, cleanup, handleTouchStart, handleTouchMove, handleTouchEnd, handleTouchCancel]);
2723
+ }
2539
2724
 
2540
2725
  // src/components/VirtualController/utils/buttonStyles.ts
2541
2726
  var DEFAULT_FACE = {
@@ -2693,21 +2878,12 @@ var VirtualButton = React2.memo(function VirtualButton2({
2693
2878
  onRelease,
2694
2879
  onPositionChange
2695
2880
  });
2696
- useEffect(() => {
2697
- const button = buttonRef.current;
2698
- if (!button) return;
2699
- button.addEventListener("touchstart", handleTouchStart, { passive: false });
2700
- button.addEventListener("touchmove", handleTouchMove, { passive: false });
2701
- button.addEventListener("touchend", handleTouchEnd, { passive: false });
2702
- button.addEventListener("touchcancel", handleTouchCancel, { passive: false });
2703
- return () => {
2704
- button.removeEventListener("touchstart", handleTouchStart);
2705
- button.removeEventListener("touchmove", handleTouchMove);
2706
- button.removeEventListener("touchend", handleTouchEnd);
2707
- button.removeEventListener("touchcancel", handleTouchCancel);
2708
- cleanup();
2709
- };
2710
- }, [handleTouchStart, handleTouchMove, handleTouchEnd, handleTouchCancel, cleanup]);
2881
+ useTouchEvents(buttonRef, {
2882
+ onTouchStart: handleTouchStart,
2883
+ onTouchMove: handleTouchMove,
2884
+ onTouchEnd: handleTouchEnd,
2885
+ onTouchCancel: handleTouchCancel
2886
+ }, { cleanup });
2711
2887
  const leftPercent = displayX / 100 * containerWidth - config.size / 2;
2712
2888
  const topPercent = displayY / 100 * containerHeight - config.size / 2;
2713
2889
  const transform = `translate3d(${leftPercent.toFixed(1)}px, ${topPercent.toFixed(1)}px, 0)`;
@@ -3383,8 +3559,7 @@ function dispatchKeyboardEvent(type, code) {
3383
3559
  canvas.dispatchEvent(event);
3384
3560
  return true;
3385
3561
  }
3386
- var DRAG_HOLD_DELAY2 = 350;
3387
- var CENTER_TOUCH_RADIUS = 0.25;
3562
+ var CENTER_TOUCH_RADIUS = 0.35;
3388
3563
  var Dpad = React2.memo(function Dpad2({
3389
3564
  size = 180,
3390
3565
  x,
@@ -3400,10 +3575,6 @@ var Dpad = React2.memo(function Dpad2({
3400
3575
  const dpadRef = useRef(null);
3401
3576
  const activeTouchRef = useRef(null);
3402
3577
  const activeDirectionsRef = useRef(/* @__PURE__ */ new Set());
3403
- const [isDragging, setIsDragging] = useState(false);
3404
- const dragTimerRef = useRef(null);
3405
- const dragStartRef = useRef({ x: 0, y: 0, touchX: 0, touchY: 0 });
3406
- const touchStartPosRef = useRef({ x: 0, y: 0, time: 0 });
3407
3578
  const upPathRef = useRef(null);
3408
3579
  const downPathRef = useRef(null);
3409
3580
  const leftPathRef = useRef(null);
@@ -3411,6 +3582,13 @@ var Dpad = React2.memo(function Dpad2({
3411
3582
  const centerCircleRef = useRef(null);
3412
3583
  const displayX = customPosition ? customPosition.x : x;
3413
3584
  const displayY = customPosition ? customPosition.y : y;
3585
+ const releaseAllDirections = useCallback((getKeyCode2) => {
3586
+ activeDirectionsRef.current.forEach((dir) => {
3587
+ const keyCode = getKeyCode2(dir);
3588
+ if (keyCode) dispatchKeyboardEvent("keyup", keyCode);
3589
+ });
3590
+ activeDirectionsRef.current = /* @__PURE__ */ new Set();
3591
+ }, []);
3414
3592
  const getKeyCode = useCallback((direction) => {
3415
3593
  if (!controls) {
3416
3594
  const defaults = {
@@ -3423,6 +3601,19 @@ var Dpad = React2.memo(function Dpad2({
3423
3601
  }
3424
3602
  return controls[direction] || "";
3425
3603
  }, [controls]);
3604
+ const drag = useDrag({
3605
+ elementSize: size,
3606
+ displayX,
3607
+ displayY,
3608
+ containerWidth,
3609
+ containerHeight,
3610
+ onPositionChange,
3611
+ centerThreshold: CENTER_TOUCH_RADIUS,
3612
+ onDragStart: () => {
3613
+ releaseAllDirections(getKeyCode);
3614
+ updateVisuals(/* @__PURE__ */ new Set());
3615
+ }
3616
+ });
3426
3617
  const getDirectionsFromTouch = useCallback((touchX, touchY, rect) => {
3427
3618
  const centerX = rect.left + rect.width / 2;
3428
3619
  const centerY = rect.top + rect.height / 2;
@@ -3484,51 +3675,20 @@ var Dpad = React2.memo(function Dpad2({
3484
3675
  activeDirectionsRef.current = newDirections;
3485
3676
  updateVisuals(newDirections);
3486
3677
  }, [getKeyCode, updateVisuals]);
3487
- const clearDragTimer = useCallback(() => {
3488
- if (dragTimerRef.current) {
3489
- clearTimeout(dragTimerRef.current);
3490
- dragTimerRef.current = null;
3491
- }
3492
- }, []);
3493
- const startDragging = useCallback((touchX, touchY) => {
3494
- setIsDragging(true);
3495
- dragStartRef.current = {
3496
- x: displayX,
3497
- y: displayY,
3498
- touchX,
3499
- touchY
3500
- };
3501
- activeDirectionsRef.current.forEach((dir) => {
3502
- const keyCode = getKeyCode(dir);
3503
- if (keyCode) dispatchKeyboardEvent("keyup", keyCode);
3504
- });
3505
- activeDirectionsRef.current = /* @__PURE__ */ new Set();
3506
- updateVisuals(/* @__PURE__ */ new Set());
3507
- if (navigator.vibrate) navigator.vibrate([10, 30, 10]);
3508
- }, [displayX, displayY, getKeyCode, updateVisuals]);
3509
3678
  const handleTouchStart = useCallback((e) => {
3510
3679
  e.preventDefault();
3511
3680
  if (activeTouchRef.current !== null) return;
3512
3681
  const touch = e.changedTouches[0];
3513
3682
  activeTouchRef.current = touch.identifier;
3514
- touchStartPosRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() };
3515
3683
  const rect = dpadRef.current?.getBoundingClientRect();
3516
3684
  if (!rect) return;
3517
- const centerX = rect.left + rect.width / 2;
3518
- const centerY = rect.top + rect.height / 2;
3519
- const distFromCenter = Math.sqrt(
3520
- Math.pow(touch.clientX - centerX, 2) + Math.pow(touch.clientY - centerY, 2)
3521
- );
3522
- const centerRadius = size * CENTER_TOUCH_RADIUS;
3523
- if (distFromCenter < centerRadius && onPositionChange) {
3524
- dragTimerRef.current = setTimeout(() => {
3525
- startDragging(touch.clientX, touch.clientY);
3526
- }, DRAG_HOLD_DELAY2);
3685
+ if (onPositionChange) {
3686
+ drag.checkDragStart(touch.clientX, touch.clientY, rect);
3527
3687
  }
3528
- if (!isDragging) {
3688
+ if (!drag.isDragging) {
3529
3689
  updateDirections(getDirectionsFromTouch(touch.clientX, touch.clientY, rect));
3530
3690
  }
3531
- }, [getDirectionsFromTouch, updateDirections, isDragging, size, onPositionChange, startDragging]);
3691
+ }, [getDirectionsFromTouch, updateDirections, onPositionChange, drag]);
3532
3692
  const handleTouchMove = useCallback((e) => {
3533
3693
  e.preventDefault();
3534
3694
  let touch = null;
@@ -3539,31 +3699,19 @@ var Dpad = React2.memo(function Dpad2({
3539
3699
  }
3540
3700
  }
3541
3701
  if (!touch) return;
3542
- if (isDragging && onPositionChange) {
3543
- const deltaX = touch.clientX - dragStartRef.current.touchX;
3544
- const deltaY = touch.clientY - dragStartRef.current.touchY;
3545
- const newXPercent = dragStartRef.current.x + deltaX / containerWidth * 100;
3546
- const newYPercent = dragStartRef.current.y + deltaY / containerHeight * 100;
3547
- const margin = size / 2 / Math.min(containerWidth, containerHeight) * 100;
3548
- const constrainedX = Math.max(margin, Math.min(100 - margin, newXPercent));
3549
- const constrainedY = Math.max(margin, Math.min(100 - margin, newYPercent));
3550
- onPositionChange(constrainedX, constrainedY);
3702
+ if (drag.isDragging) {
3703
+ drag.handleDragMove(touch.clientX, touch.clientY);
3551
3704
  } else {
3552
- const moveDistance = Math.sqrt(
3553
- Math.pow(touch.clientX - touchStartPosRef.current.x, 2) + Math.pow(touch.clientY - touchStartPosRef.current.y, 2)
3554
- );
3555
- if (moveDistance > 15) {
3556
- clearDragTimer();
3557
- }
3558
3705
  const rect = dpadRef.current?.getBoundingClientRect();
3559
3706
  if (rect) {
3707
+ drag.clearDragTimer();
3560
3708
  updateDirections(getDirectionsFromTouch(touch.clientX, touch.clientY, rect));
3561
3709
  }
3562
3710
  }
3563
- }, [isDragging, onPositionChange, containerWidth, containerHeight, size, getDirectionsFromTouch, updateDirections, clearDragTimer]);
3711
+ }, [drag, getDirectionsFromTouch, updateDirections]);
3564
3712
  const handleTouchEnd = useCallback((e) => {
3565
3713
  e.preventDefault();
3566
- clearDragTimer();
3714
+ drag.clearDragTimer();
3567
3715
  let touchEnded = false;
3568
3716
  for (let i = 0; i < e.changedTouches.length; i++) {
3569
3717
  if (e.changedTouches[i].identifier === activeTouchRef.current) {
@@ -3573,8 +3721,8 @@ var Dpad = React2.memo(function Dpad2({
3573
3721
  }
3574
3722
  if (touchEnded) {
3575
3723
  activeTouchRef.current = null;
3576
- if (isDragging) {
3577
- setIsDragging(false);
3724
+ if (drag.isDragging) {
3725
+ drag.handleDragEnd();
3578
3726
  } else {
3579
3727
  activeDirectionsRef.current.forEach((dir) => {
3580
3728
  const keyCode = getKeyCode(dir);
@@ -3584,22 +3732,13 @@ var Dpad = React2.memo(function Dpad2({
3584
3732
  updateVisuals(/* @__PURE__ */ new Set());
3585
3733
  }
3586
3734
  }
3587
- }, [getKeyCode, updateVisuals, isDragging, clearDragTimer]);
3588
- useEffect(() => {
3589
- const dpad = dpadRef.current;
3590
- if (!dpad) return;
3591
- dpad.addEventListener("touchstart", handleTouchStart, { passive: false });
3592
- dpad.addEventListener("touchmove", handleTouchMove, { passive: false });
3593
- dpad.addEventListener("touchend", handleTouchEnd, { passive: false });
3594
- dpad.addEventListener("touchcancel", handleTouchEnd, { passive: false });
3595
- return () => {
3596
- dpad.removeEventListener("touchstart", handleTouchStart);
3597
- dpad.removeEventListener("touchmove", handleTouchMove);
3598
- dpad.removeEventListener("touchend", handleTouchEnd);
3599
- dpad.removeEventListener("touchcancel", handleTouchEnd);
3600
- clearDragTimer();
3601
- };
3602
- }, [handleTouchStart, handleTouchMove, handleTouchEnd, clearDragTimer]);
3735
+ }, [getKeyCode, updateVisuals, drag]);
3736
+ useTouchEvents(dpadRef, {
3737
+ onTouchStart: handleTouchStart,
3738
+ onTouchMove: handleTouchMove,
3739
+ onTouchEnd: handleTouchEnd,
3740
+ onTouchCancel: handleTouchEnd
3741
+ }, { cleanup: drag.clearDragTimer });
3603
3742
  const leftPx = displayX / 100 * containerWidth - size / 2;
3604
3743
  const topPx = displayY / 100 * containerHeight - size / 2;
3605
3744
  const dUp = "M 35,5 L 65,5 L 65,35 L 50,50 L 35,35 Z";
@@ -3610,21 +3749,21 @@ var Dpad = React2.memo(function Dpad2({
3610
3749
  "div",
3611
3750
  {
3612
3751
  ref: dpadRef,
3613
- className: `absolute pointer-events-auto touch-manipulation select-none ${isDragging ? "opacity-60" : ""}`,
3752
+ className: `absolute pointer-events-auto touch-manipulation select-none ${drag.isDragging ? "opacity-60" : ""}`,
3614
3753
  style: {
3615
3754
  top: 0,
3616
3755
  left: 0,
3617
- transform: `translate3d(${leftPx}px, ${topPx}px, 0)${isDragging ? " scale(1.05)" : ""}`,
3756
+ transform: `translate3d(${leftPx}px, ${topPx}px, 0)${drag.isDragging ? " scale(1.05)" : ""}`,
3618
3757
  width: size,
3619
3758
  height: size,
3620
3759
  opacity: isLandscape ? 0.75 : 0.9,
3621
3760
  WebkitTouchCallout: "none",
3622
3761
  WebkitUserSelect: "none",
3623
3762
  touchAction: "none",
3624
- transition: isDragging ? "none" : "transform 0.1s ease-out"
3763
+ transition: drag.isDragging ? "none" : "transform 0.1s ease-out"
3625
3764
  },
3626
3765
  children: [
3627
- /* @__PURE__ */ jsx("div", { className: `absolute inset-0 rounded-full bg-black/40 backdrop-blur-md border shadow-lg ${isDragging ? "border-white/50 ring-2 ring-white/30" : "border-white/10"}` }),
3766
+ /* @__PURE__ */ jsx("div", { className: `absolute inset-0 rounded-full bg-black/40 backdrop-blur-md border shadow-lg ${drag.isDragging ? "border-white/50 ring-2 ring-white/30" : "border-white/10"}` }),
3628
3767
  /* @__PURE__ */ jsxs("svg", { width: "100%", height: "100%", viewBox: "0 0 100 100", className: "drop-shadow-xl relative z-10", children: [
3629
3768
  /* @__PURE__ */ jsx("path", { ref: upPathRef, d: dUp, fill: "rgba(255,255,255,0.05)", stroke: "rgba(255,255,255,0.2)", strokeWidth: "1", className: "transition-all duration-75" }),
3630
3769
  /* @__PURE__ */ jsx("path", { ref: rightPathRef, d: dRight, fill: "rgba(255,255,255,0.05)", stroke: "rgba(255,255,255,0.2)", strokeWidth: "1", className: "transition-all duration-75" }),
@@ -3637,9 +3776,9 @@ var Dpad = React2.memo(function Dpad2({
3637
3776
  cx: "50",
3638
3777
  cy: "50",
3639
3778
  r: "12",
3640
- fill: isDragging ? systemColor : "rgba(0,0,0,0.5)",
3641
- stroke: isDragging ? "#fff" : "rgba(255,255,255,0.3)",
3642
- strokeWidth: isDragging ? 2 : 1
3779
+ fill: drag.isDragging ? systemColor : "rgba(0,0,0,0.5)",
3780
+ stroke: drag.isDragging ? "#fff" : "rgba(255,255,255,0.3)",
3781
+ strokeWidth: drag.isDragging ? 2 : 1
3643
3782
  }
3644
3783
  ),
3645
3784
  /* @__PURE__ */ jsx("path", { d: "M 50,15 L 50,25 M 45,20 L 50,15 L 55,20", stroke: "white", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", fill: "none", opacity: "0.8", pointerEvents: "none" }),
@@ -3799,6 +3938,33 @@ function ControlsHint({ isVisible }) {
3799
3938
  }
3800
3939
  );
3801
3940
  }
3941
+ function LockButton({
3942
+ isLocked,
3943
+ onToggle,
3944
+ systemColor = "#00FF41"
3945
+ }) {
3946
+ const Icon = isLocked ? Lock : Unlock;
3947
+ return /* @__PURE__ */ jsx(
3948
+ "button",
3949
+ {
3950
+ onClick: onToggle,
3951
+ className: "fixed top-4 left-1/2 -translate-x-1/2 z-40 pointer-events-auto p-2 rounded-full backdrop-blur-sm transition-all active:scale-95",
3952
+ style: {
3953
+ backgroundColor: isLocked ? "rgba(0,0,0,0.6)" : `${systemColor}20`,
3954
+ border: `1px solid ${isLocked ? "rgba(255,255,255,0.2)" : systemColor}`
3955
+ },
3956
+ "aria-label": isLocked ? "Unlock controls for repositioning" : "Lock controls",
3957
+ children: /* @__PURE__ */ jsx(
3958
+ Icon,
3959
+ {
3960
+ size: 18,
3961
+ style: { color: isLocked ? "rgba(255,255,255,0.6)" : systemColor }
3962
+ }
3963
+ )
3964
+ }
3965
+ );
3966
+ }
3967
+ var LOCK_KEY = "koin-controls-locked";
3802
3968
  function VirtualController({
3803
3969
  system,
3804
3970
  isRunning,
@@ -3810,8 +3976,22 @@ function VirtualController({
3810
3976
  const [pressedButtons, setPressedButtons] = useState(/* @__PURE__ */ new Set());
3811
3977
  const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
3812
3978
  const [isFullscreenState, setIsFullscreenState] = useState(false);
3979
+ const [isLocked, setIsLocked] = useState(true);
3813
3980
  const { getPosition, savePosition } = useButtonPositions();
3814
- const layout = getLayoutForSystem(system);
3981
+ useEffect(() => {
3982
+ const stored = localStorage.getItem(LOCK_KEY);
3983
+ if (stored !== null) {
3984
+ setIsLocked(stored === "true");
3985
+ }
3986
+ }, []);
3987
+ const toggleLock = useCallback(() => {
3988
+ setIsLocked((prev) => {
3989
+ const newValue = !prev;
3990
+ localStorage.setItem(LOCK_KEY, String(newValue));
3991
+ return newValue;
3992
+ });
3993
+ }, []);
3994
+ const layout = getLayoutForSystem(system);
3815
3995
  const visibleButtons = layout.buttons.filter((btn) => {
3816
3996
  if (isPortrait) {
3817
3997
  return btn.showInPortrait;
@@ -3965,6 +4145,14 @@ function VirtualController({
3965
4145
  className: "fixed inset-0 z-30 pointer-events-none",
3966
4146
  style: { touchAction: "none" },
3967
4147
  children: [
4148
+ /* @__PURE__ */ jsx(
4149
+ LockButton,
4150
+ {
4151
+ isLocked,
4152
+ onToggle: toggleLock,
4153
+ systemColor
4154
+ }
4155
+ ),
3968
4156
  /* @__PURE__ */ jsx(
3969
4157
  Dpad_default,
3970
4158
  {
@@ -3977,7 +4165,7 @@ function VirtualController({
3977
4165
  systemColor,
3978
4166
  isLandscape,
3979
4167
  customPosition: getPosition("up", isLandscape),
3980
- onPositionChange: (x, y) => savePosition("up", x, y, isLandscape)
4168
+ onPositionChange: isLocked ? void 0 : (x, y) => savePosition("up", x, y, isLandscape)
3981
4169
  }
3982
4170
  ),
3983
4171
  memoizedButtonElements.filter(({ buttonConfig }) => !DPAD_TYPES.includes(buttonConfig.type)).map(({ buttonConfig, adjustedConfig, customPosition, width, height }) => /* @__PURE__ */ jsx(
@@ -3991,7 +4179,7 @@ function VirtualController({
3991
4179
  containerWidth: width,
3992
4180
  containerHeight: height,
3993
4181
  customPosition,
3994
- onPositionChange: (x, y) => savePosition(buttonConfig.type, x, y, isLandscape),
4182
+ onPositionChange: isLocked ? void 0 : (x, y) => savePosition(buttonConfig.type, x, y, isLandscape),
3995
4183
  isLandscape,
3996
4184
  console: layout.console
3997
4185
  },
@@ -4067,6 +4255,45 @@ function FloatingFullscreenButton({ onClick, disabled = false }) {
4067
4255
  }
4068
4256
  );
4069
4257
  }
4258
+ function FloatingPauseButton({
4259
+ isPaused,
4260
+ onClick,
4261
+ disabled = false,
4262
+ systemColor = "#00FF41"
4263
+ }) {
4264
+ return /* @__PURE__ */ jsx(
4265
+ "button",
4266
+ {
4267
+ onClick,
4268
+ disabled,
4269
+ className: `
4270
+ fixed top-3 left-3 z-50
4271
+ px-3 py-2 rounded-xl
4272
+ bg-black/80 backdrop-blur-md
4273
+ border-2
4274
+ shadow-xl
4275
+ flex items-center gap-2
4276
+ transition-all duration-300
4277
+ hover:scale-105
4278
+ active:scale-95
4279
+ disabled:opacity-40 disabled:cursor-not-allowed
4280
+ touch-manipulation
4281
+ `,
4282
+ style: {
4283
+ paddingTop: "max(env(safe-area-inset-top, 0px), 8px)",
4284
+ borderColor: isPaused ? systemColor : "rgba(255,255,255,0.3)"
4285
+ },
4286
+ "aria-label": isPaused ? "Resume game" : "Pause game",
4287
+ children: isPaused ? /* @__PURE__ */ jsxs(Fragment, { children: [
4288
+ /* @__PURE__ */ jsx(Play, { size: 16, style: { color: systemColor }, fill: systemColor }),
4289
+ /* @__PURE__ */ jsx("span", { className: "text-white text-xs font-bold uppercase tracking-wider", children: "Play" })
4290
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
4291
+ /* @__PURE__ */ jsx(Pause, { size: 16, className: "text-white/80" }),
4292
+ /* @__PURE__ */ jsx("span", { className: "text-white text-xs font-bold uppercase tracking-wider", children: "Pause" })
4293
+ ] })
4294
+ }
4295
+ );
4296
+ }
4070
4297
  function LoadingSpinner({ color, size = "lg" }) {
4071
4298
  const sizeClass = size === "lg" ? "w-12 h-12" : "w-8 h-8";
4072
4299
  return /* @__PURE__ */ jsx(Loader2, { className: `${sizeClass} animate-spin`, style: { color } });
@@ -4272,6 +4499,45 @@ var GameCanvas = memo(function GameCanvas2({
4272
4499
  ] });
4273
4500
  });
4274
4501
  var GameCanvas_default = GameCanvas;
4502
+ function useInputCapture({
4503
+ isOpen,
4504
+ onClose
4505
+ }) {
4506
+ const [listeningFor, setListeningFor] = useState(null);
4507
+ const startListening = useCallback((target) => {
4508
+ setListeningFor(target);
4509
+ }, []);
4510
+ const stopListening = useCallback(() => {
4511
+ setListeningFor(null);
4512
+ }, []);
4513
+ useEffect(() => {
4514
+ if (!isOpen) {
4515
+ setListeningFor(null);
4516
+ }
4517
+ }, [isOpen]);
4518
+ useEffect(() => {
4519
+ if (!isOpen) return;
4520
+ const handleKeyDown = (e) => {
4521
+ if (e.code === "Escape") {
4522
+ if (listeningFor !== null) {
4523
+ e.preventDefault();
4524
+ e.stopPropagation();
4525
+ setListeningFor(null);
4526
+ } else {
4527
+ onClose();
4528
+ }
4529
+ }
4530
+ };
4531
+ window.addEventListener("keydown", handleKeyDown);
4532
+ return () => window.removeEventListener("keydown", handleKeyDown);
4533
+ }, [isOpen, listeningFor, onClose]);
4534
+ return {
4535
+ listeningFor,
4536
+ startListening,
4537
+ stopListening,
4538
+ isListening: listeningFor !== null
4539
+ };
4540
+ }
4275
4541
  function getFilteredGroups(activeButtons) {
4276
4542
  return BUTTON_GROUPS.map((group) => ({
4277
4543
  ...group,
@@ -4287,7 +4553,10 @@ function ControlMapper({
4287
4553
  }) {
4288
4554
  const t = useKoinTranslation();
4289
4555
  const [localControls, setLocalControls] = useState(controls);
4290
- const [listeningFor, setListeningFor] = useState(null);
4556
+ const { listeningFor, startListening, stopListening, isListening } = useInputCapture({
4557
+ isOpen,
4558
+ onClose
4559
+ });
4291
4560
  const activeButtons = useMemo(() => {
4292
4561
  return getConsoleButtons(system || "SNES");
4293
4562
  }, [system]);
@@ -4303,27 +4572,20 @@ function ControlMapper({
4303
4572
  }
4304
4573
  }, [isOpen, controls]);
4305
4574
  useEffect(() => {
4306
- if (!isOpen) {
4307
- setListeningFor(null);
4308
- return;
4309
- }
4575
+ if (!isOpen || !listeningFor) return;
4310
4576
  const handleKeyDown = (e) => {
4311
- if (!listeningFor) return;
4577
+ if (e.code === "Escape") return;
4312
4578
  e.preventDefault();
4313
4579
  e.stopPropagation();
4314
- if (e.code === "Escape") {
4315
- setListeningFor(null);
4316
- return;
4317
- }
4318
4580
  setLocalControls((prev) => ({
4319
4581
  ...prev,
4320
4582
  [listeningFor]: e.code
4321
4583
  }));
4322
- setListeningFor(null);
4584
+ stopListening();
4323
4585
  };
4324
4586
  window.addEventListener("keydown", handleKeyDown);
4325
4587
  return () => window.removeEventListener("keydown", handleKeyDown);
4326
- }, [isOpen, listeningFor]);
4588
+ }, [isOpen, listeningFor, stopListening]);
4327
4589
  const handleReset = () => {
4328
4590
  setLocalControls(defaultControls);
4329
4591
  };
@@ -4331,52 +4593,58 @@ function ControlMapper({
4331
4593
  onSave(localControls);
4332
4594
  onClose();
4333
4595
  };
4334
- if (!isOpen) return null;
4335
- return /* @__PURE__ */ jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center", children: [
4336
- /* @__PURE__ */ jsx(
4337
- "div",
4338
- {
4339
- className: "absolute inset-0 bg-black/80 backdrop-blur-sm",
4340
- onClick: onClose
4341
- }
4342
- ),
4343
- /* @__PURE__ */ jsxs("div", { className: "relative bg-gray-900 border border-retro-primary/30 rounded-xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden", children: [
4344
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between px-6 py-4 border-b border-white/10 bg-black/50", children: [
4345
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
4346
- /* @__PURE__ */ jsx(Gamepad2, { className: "text-retro-primary", size: 24 }),
4347
- /* @__PURE__ */ jsxs("div", { children: [
4348
- /* @__PURE__ */ jsx("h2", { className: "text-lg font-bold text-white", children: t.modals.controls.title }),
4349
- /* @__PURE__ */ jsx("p", { className: "text-xs text-gray-400", children: t.modals.controls.description })
4350
- ] })
4351
- ] }),
4352
- /* @__PURE__ */ jsx(
4596
+ return /* @__PURE__ */ jsx(
4597
+ ModalShell,
4598
+ {
4599
+ isOpen,
4600
+ onClose,
4601
+ title: t.modals.controls.title,
4602
+ subtitle: t.modals.controls.description,
4603
+ icon: /* @__PURE__ */ jsx(Gamepad2, { className: "text-retro-primary", size: 24 }),
4604
+ closeOnBackdrop: !isListening,
4605
+ footer: /* @__PURE__ */ jsxs(Fragment, { children: [
4606
+ /* @__PURE__ */ jsxs(
4607
+ "button",
4608
+ {
4609
+ onClick: handleReset,
4610
+ className: "flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white hover:bg-white/10 transition-colors",
4611
+ children: [
4612
+ /* @__PURE__ */ jsx(RotateCcw, { size: 16 }),
4613
+ t.modals.controls.reset
4614
+ ]
4615
+ }
4616
+ ),
4617
+ /* @__PURE__ */ jsxs(
4353
4618
  "button",
4354
4619
  {
4355
- onClick: onClose,
4356
- className: "p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-colors",
4357
- children: /* @__PURE__ */ jsx(X, { size: 20 })
4620
+ onClick: handleSave,
4621
+ className: "flex items-center gap-2 px-6 py-2 rounded-lg bg-retro-primary text-black font-bold text-sm hover:bg-retro-primary/90 transition-colors",
4622
+ children: [
4623
+ /* @__PURE__ */ jsx(Check, { size: 16 }),
4624
+ t.modals.controls.save
4625
+ ]
4358
4626
  }
4359
4627
  )
4360
4628
  ] }),
4361
- /* @__PURE__ */ jsx("div", { className: "p-4 space-y-6 max-h-[400px] overflow-y-auto", children: controlGroups.map((group) => /* @__PURE__ */ jsxs("div", { children: [
4629
+ children: /* @__PURE__ */ jsx("div", { className: "p-4 space-y-6 max-h-[400px] overflow-y-auto", children: controlGroups.map((group) => /* @__PURE__ */ jsxs("div", { children: [
4362
4630
  /* @__PURE__ */ jsx("h3", { className: "text-xs font-bold text-gray-500 uppercase tracking-wider mb-2", children: group.label }),
4363
4631
  /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-2", children: group.buttons.map((btn) => /* @__PURE__ */ jsxs(
4364
4632
  "button",
4365
4633
  {
4366
- onClick: () => setListeningFor(btn),
4634
+ onClick: () => startListening(btn),
4367
4635
  className: `
4368
- flex items-center justify-between px-4 py-3 rounded-lg border transition-all
4369
- ${listeningFor === btn ? "border-retro-primary bg-retro-primary/20 ring-2 ring-retro-primary/50" : "border-white/10 bg-white/5 hover:border-white/20 hover:bg-white/10"}
4370
- `,
4636
+ flex items-center justify-between px-4 py-3 rounded-lg border transition-all
4637
+ ${listeningFor === btn ? "border-retro-primary bg-retro-primary/20 ring-2 ring-retro-primary/50" : "border-white/10 bg-white/5 hover:border-white/20 hover:bg-white/10"}
4638
+ `,
4371
4639
  children: [
4372
4640
  /* @__PURE__ */ jsx("span", { className: "text-sm text-gray-300", children: BUTTON_LABELS[btn] }),
4373
4641
  /* @__PURE__ */ jsx(
4374
4642
  "span",
4375
4643
  {
4376
4644
  className: `
4377
- px-2 py-1 rounded text-xs font-mono
4378
- ${listeningFor === btn ? "bg-retro-primary/30 text-retro-primary animate-pulse" : "bg-black/50 text-white"}
4379
- `,
4645
+ px-2 py-1 rounded text-xs font-mono
4646
+ ${listeningFor === btn ? "bg-retro-primary/30 text-retro-primary animate-pulse" : "bg-black/50 text-white"}
4647
+ `,
4380
4648
  children: listeningFor === btn ? t.modals.controls.pressKey : formatKeyCode(localControls[btn] || "")
4381
4649
  }
4382
4650
  )
@@ -4384,199 +4652,10 @@ function ControlMapper({
4384
4652
  },
4385
4653
  btn
4386
4654
  )) })
4387
- ] }, group.label)) }),
4388
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between px-6 py-4 bg-black/30 border-t border-white/10", children: [
4389
- /* @__PURE__ */ jsxs(
4390
- "button",
4391
- {
4392
- onClick: handleReset,
4393
- className: "flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white hover:bg-white/10 transition-colors",
4394
- children: [
4395
- /* @__PURE__ */ jsx(RotateCcw, { size: 16 }),
4396
- t.modals.controls.reset
4397
- ]
4398
- }
4399
- ),
4400
- /* @__PURE__ */ jsxs(
4401
- "button",
4402
- {
4403
- onClick: handleSave,
4404
- className: "flex items-center gap-2 px-6 py-2 rounded-lg bg-retro-primary text-black font-bold text-sm hover:bg-retro-primary/90 transition-colors",
4405
- children: [
4406
- /* @__PURE__ */ jsx(Check, { size: 16 }),
4407
- t.modals.controls.save
4408
- ]
4409
- }
4410
- )
4411
- ] })
4412
- ] })
4413
- ] });
4414
- }
4415
- function getDisplayName(id) {
4416
- let name = id;
4417
- name = name.replace(/^[0-9a-f]{4}-[0-9a-f]{4}-/i, "");
4418
- name = name.replace(/\s*\(.*\)\s*$/i, "");
4419
- name = name.replace(/\s*STANDARD GAMEPAD\s*/i, "");
4420
- if (/xbox/i.test(name)) {
4421
- if (/series/i.test(name)) return "Xbox Series Controller";
4422
- if (/one/i.test(name)) return "Xbox One Controller";
4423
- if (/360/i.test(name)) return "Xbox 360 Controller";
4424
- return "Xbox Controller";
4425
- }
4426
- if (/dualsense/i.test(name)) return "DualSense";
4427
- if (/dualshock\s*4/i.test(name)) return "DualShock 4";
4428
- if (/dualshock/i.test(name)) return "DualShock";
4429
- if (/playstation/i.test(name) || /sony/i.test(name)) return "PlayStation Controller";
4430
- if (/pro\s*controller/i.test(name)) return "Switch Pro Controller";
4431
- if (/joy-?con/i.test(name)) return "Joy-Con";
4432
- if (/nintendo/i.test(name)) return "Nintendo Controller";
4433
- return name.trim() || "Gamepad";
4434
- }
4435
- function detectControllerBrand(id) {
4436
- const lowerId = id.toLowerCase();
4437
- if (/xbox|xinput|microsoft/i.test(lowerId)) return "xbox";
4438
- if (/playstation|sony|dualshock|dualsense/i.test(lowerId)) return "playstation";
4439
- if (/nintendo|switch|joy-?con|pro controller/i.test(lowerId)) return "nintendo";
4440
- return "generic";
4441
- }
4442
- function toGamepadInfo(gamepad) {
4443
- return {
4444
- index: gamepad.index,
4445
- id: gamepad.id,
4446
- name: getDisplayName(gamepad.id),
4447
- connected: gamepad.connected,
4448
- buttons: gamepad.buttons.length,
4449
- axes: gamepad.axes.length,
4450
- mapping: gamepad.mapping
4451
- };
4452
- }
4453
- function useGamepad(options) {
4454
- const { onConnect, onDisconnect } = options || {};
4455
- const [gamepads, setGamepads] = useState([]);
4456
- const rafRef = useRef(null);
4457
- const lastStateRef = useRef("");
4458
- const prevCountRef = useRef(0);
4459
- const onConnectRef = useRef(onConnect);
4460
- const onDisconnectRef = useRef(onDisconnect);
4461
- useEffect(() => {
4462
- onConnectRef.current = onConnect;
4463
- onDisconnectRef.current = onDisconnect;
4464
- }, [onConnect, onDisconnect]);
4465
- const getGamepads = useCallback(() => {
4466
- if (typeof navigator === "undefined" || typeof navigator.getGamepads !== "function") {
4467
- return [];
4468
- }
4469
- const rawGamepads = navigator.getGamepads() ?? [];
4470
- const connected = [];
4471
- for (let i = 0; i < rawGamepads.length; i++) {
4472
- const gp = rawGamepads[i];
4473
- if (gp && gp.connected) {
4474
- connected.push(toGamepadInfo(gp));
4475
- }
4476
- }
4477
- return connected;
4478
- }, []);
4479
- const getRawGamepad = useCallback((index) => {
4480
- const rawGamepads = navigator.getGamepads?.() ?? [];
4481
- return rawGamepads[index] ?? null;
4482
- }, []);
4483
- const refresh = useCallback(() => {
4484
- setGamepads(getGamepads());
4485
- }, [getGamepads]);
4486
- useEffect(() => {
4487
- if (typeof window === "undefined" || typeof navigator === "undefined") {
4488
- return;
4489
- }
4490
- if (typeof navigator.getGamepads !== "function") {
4491
- console.warn("[useGamepad] Gamepad API not supported in this browser");
4492
- return;
4655
+ ] }, group.label)) })
4493
4656
  }
4494
- let isActive = true;
4495
- const poll = () => {
4496
- if (!isActive) return;
4497
- const current = getGamepads();
4498
- let hasChanged = current.length !== prevCountRef.current;
4499
- if (!hasChanged) {
4500
- for (let i = 0; i < current.length; i++) {
4501
- const saved = gamepads[i];
4502
- if (!saved || saved.id !== current[i].id || saved.connected !== current[i].connected) {
4503
- hasChanged = true;
4504
- break;
4505
- }
4506
- }
4507
- }
4508
- if (hasChanged) {
4509
- const prevCount = prevCountRef.current;
4510
- const currentCount = current.length;
4511
- if (currentCount > prevCount && prevCount >= 0 && onConnectRef.current) {
4512
- const newGamepad = current[current.length - 1];
4513
- onConnectRef.current(newGamepad);
4514
- } else if (currentCount < prevCount && prevCount > 0 && onDisconnectRef.current) {
4515
- onDisconnectRef.current();
4516
- }
4517
- prevCountRef.current = currentCount;
4518
- setGamepads(current);
4519
- }
4520
- rafRef.current = requestAnimationFrame(poll);
4521
- };
4522
- const handleConnect = (e) => {
4523
- console.log("[useGamepad] \u{1F3AE} Gamepad connected:", e.gamepad.id);
4524
- const current = getGamepads();
4525
- const prevCount = prevCountRef.current;
4526
- prevCountRef.current = current.length;
4527
- lastStateRef.current = current.map((g) => `${g.index}:${g.id}`).join("|");
4528
- setGamepads(current);
4529
- if (onConnectRef.current && current.length > prevCount) {
4530
- const newGamepad = current[current.length - 1];
4531
- onConnectRef.current(newGamepad);
4532
- }
4533
- };
4534
- const handleDisconnect = (e) => {
4535
- console.log("[useGamepad] \u{1F3AE} Gamepad disconnected:", e.gamepad.id);
4536
- const current = getGamepads();
4537
- const prevCount = prevCountRef.current;
4538
- prevCountRef.current = current.length;
4539
- lastStateRef.current = current.map((g) => `${g.index}:${g.id}`).join("|");
4540
- setGamepads(current);
4541
- if (onDisconnectRef.current && current.length < prevCount) {
4542
- onDisconnectRef.current();
4543
- }
4544
- };
4545
- window.addEventListener("gamepadconnected", handleConnect);
4546
- window.addEventListener("gamepaddisconnected", handleDisconnect);
4547
- rafRef.current = requestAnimationFrame(poll);
4548
- const initial = getGamepads();
4549
- if (initial.length > 0) {
4550
- console.log("[useGamepad] Initial gamepads found:", initial.map((g) => g.name).join(", "));
4551
- prevCountRef.current = initial.length;
4552
- setGamepads(initial);
4553
- lastStateRef.current = initial.map((g) => `${g.index}:${g.id}`).join("|");
4554
- } else {
4555
- prevCountRef.current = 0;
4556
- }
4557
- return () => {
4558
- isActive = false;
4559
- if (rafRef.current) {
4560
- cancelAnimationFrame(rafRef.current);
4561
- }
4562
- window.removeEventListener("gamepadconnected", handleConnect);
4563
- window.removeEventListener("gamepaddisconnected", handleDisconnect);
4564
- };
4565
- }, [getGamepads]);
4566
- return {
4567
- gamepads,
4568
- isAnyConnected: gamepads.length > 0,
4569
- connectedCount: gamepads.length,
4570
- getRawGamepad,
4571
- refresh
4572
- };
4657
+ );
4573
4658
  }
4574
- var STANDARD_AXIS_MAP = {
4575
- leftStickX: 0,
4576
- leftStickY: 1,
4577
- rightStickX: 2,
4578
- rightStickY: 3
4579
- };
4580
4659
  function GamepadMapper({
4581
4660
  isOpen,
4582
4661
  gamepads,
@@ -4587,7 +4666,10 @@ function GamepadMapper({
4587
4666
  const t = useKoinTranslation();
4588
4667
  const [selectedPlayer, setSelectedPlayer] = useState(1);
4589
4668
  const [bindings, setBindings] = useState({});
4590
- const [listeningFor, setListeningFor] = useState(null);
4669
+ const { listeningFor, startListening, stopListening, isListening } = useInputCapture({
4670
+ isOpen,
4671
+ onClose
4672
+ });
4591
4673
  const rafRef = useRef(null);
4592
4674
  useEffect(() => {
4593
4675
  if (isOpen) {
@@ -4622,7 +4704,7 @@ function GamepadMapper({
4622
4704
  [listeningFor]: i
4623
4705
  }
4624
4706
  }));
4625
- setListeningFor(null);
4707
+ stopListening();
4626
4708
  return;
4627
4709
  }
4628
4710
  }
@@ -4635,21 +4717,7 @@ function GamepadMapper({
4635
4717
  cancelAnimationFrame(rafRef.current);
4636
4718
  }
4637
4719
  };
4638
- }, [isOpen, listeningFor, selectedPlayer]);
4639
- useEffect(() => {
4640
- if (!isOpen) return;
4641
- const handleKeyDown = (e) => {
4642
- if (e.code === "Escape") {
4643
- if (listeningFor) {
4644
- setListeningFor(null);
4645
- } else {
4646
- onClose();
4647
- }
4648
- }
4649
- };
4650
- window.addEventListener("keydown", handleKeyDown);
4651
- return () => window.removeEventListener("keydown", handleKeyDown);
4652
- }, [isOpen, listeningFor, onClose]);
4720
+ }, [isOpen, listeningFor, selectedPlayer, stopListening]);
4653
4721
  const handleReset = () => {
4654
4722
  setBindings((prev) => ({
4655
4723
  ...prev,
@@ -4664,127 +4732,19 @@ function GamepadMapper({
4664
4732
  onSave?.(bindings[selectedPlayer], selectedPlayer);
4665
4733
  onClose();
4666
4734
  };
4667
- if (!isOpen) return null;
4668
4735
  const currentBindings = bindings[selectedPlayer] ?? DEFAULT_GAMEPAD;
4669
4736
  const currentGamepad = gamepads.find((g) => g.index === selectedPlayer - 1);
4670
- currentGamepad ? detectControllerBrand(currentGamepad.id) : "generic";
4671
- return /* @__PURE__ */ jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center", children: [
4672
- /* @__PURE__ */ jsx(
4673
- "div",
4674
- {
4675
- className: "absolute inset-0 bg-black/80 backdrop-blur-sm",
4676
- onClick: () => !listeningFor && onClose()
4677
- }
4678
- ),
4679
- /* @__PURE__ */ jsxs("div", { className: "relative bg-gray-900 border border-retro-primary/30 rounded-xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden", children: [
4680
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between px-6 py-4 border-b border-white/10 bg-black/50", children: [
4681
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
4682
- /* @__PURE__ */ jsx(Joystick, { className: "text-retro-primary", size: 24, style: { color: systemColor } }),
4683
- /* @__PURE__ */ jsxs("div", { children: [
4684
- /* @__PURE__ */ jsx("h2", { className: "text-lg font-bold text-white", children: t.modals.gamepad.title }),
4685
- /* @__PURE__ */ jsx("p", { className: "text-xs text-gray-400", children: gamepads.length > 0 ? t.modals.gamepad.connected.replace("{{count}}", gamepads.length.toString()) : t.modals.gamepad.none })
4686
- ] })
4687
- ] }),
4688
- /* @__PURE__ */ jsx(
4689
- "button",
4690
- {
4691
- onClick: onClose,
4692
- className: "p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-colors",
4693
- children: /* @__PURE__ */ jsx(X, { size: 20 })
4694
- }
4695
- )
4696
- ] }),
4697
- gamepads.length > 1 && /* @__PURE__ */ jsxs("div", { className: "px-6 py-3 border-b border-white/10 bg-black/30", children: [
4698
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
4699
- /* @__PURE__ */ jsx("span", { className: "text-xs text-gray-400 font-medium", children: t.modals.gamepad.player }),
4700
- /* @__PURE__ */ jsx("div", { className: "flex gap-1", children: gamepads.map((gp) => /* @__PURE__ */ jsxs(
4701
- "button",
4702
- {
4703
- onClick: () => setSelectedPlayer(gp.index + 1),
4704
- className: `
4705
- flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all
4706
- ${selectedPlayer === gp.index + 1 ? "bg-retro-primary/20 text-retro-primary" : "bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white"}
4707
- `,
4708
- style: selectedPlayer === gp.index + 1 ? {
4709
- backgroundColor: `${systemColor}20`,
4710
- color: systemColor
4711
- } : {},
4712
- children: [
4713
- /* @__PURE__ */ jsx(User, { size: 14 }),
4714
- "P",
4715
- gp.index + 1
4716
- ]
4717
- },
4718
- gp.index
4719
- )) })
4720
- ] }),
4721
- currentGamepad && /* @__PURE__ */ jsx("p", { className: "text-xs text-gray-500 mt-1", children: currentGamepad.name })
4722
- ] }),
4723
- gamepads.length === 1 && currentGamepad && /* @__PURE__ */ jsx("div", { className: "px-6 py-2 border-b border-white/10 bg-black/30", children: /* @__PURE__ */ jsxs("p", { className: "text-xs text-gray-400", children: [
4724
- currentGamepad.name,
4725
- " \u2022 Player 1"
4726
- ] }) }),
4727
- gamepads.length === 0 && /* @__PURE__ */ jsxs("div", { className: "px-6 py-10 text-center", children: [
4728
- /* @__PURE__ */ jsxs("div", { className: "relative inline-block mb-4", children: [
4729
- /* @__PURE__ */ jsx(Joystick, { size: 56, className: "text-gray-600 animate-pulse" }),
4730
- /* @__PURE__ */ jsx("div", { className: "absolute inset-0 -m-2 rounded-full border-2 border-dashed border-gray-600 animate-spin", style: { animationDuration: "8s" } })
4731
- ] }),
4732
- /* @__PURE__ */ jsx("p", { className: "text-gray-300 font-medium mb-2", children: t.modals.gamepad.noController }),
4733
- /* @__PURE__ */ jsx("p", { className: "text-sm text-gray-500 mb-4", children: t.modals.gamepad.pressAny }),
4734
- /* @__PURE__ */ jsxs("div", { className: "inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-white/5 border border-white/10", children: [
4735
- /* @__PURE__ */ jsx("span", { className: "text-xs text-gray-400", children: t.modals.gamepad.waiting }),
4736
- /* @__PURE__ */ jsxs("div", { className: "flex gap-1", children: [
4737
- /* @__PURE__ */ jsx("div", { className: "w-1.5 h-1.5 rounded-full bg-gray-500 animate-bounce", style: { animationDelay: "0ms" } }),
4738
- /* @__PURE__ */ jsx("div", { className: "w-1.5 h-1.5 rounded-full bg-gray-500 animate-bounce", style: { animationDelay: "150ms" } }),
4739
- /* @__PURE__ */ jsx("div", { className: "w-1.5 h-1.5 rounded-full bg-gray-500 animate-bounce", style: { animationDelay: "300ms" } })
4740
- ] })
4741
- ] })
4742
- ] }),
4743
- gamepads.length > 0 && /* @__PURE__ */ jsxs("div", { className: "p-4 space-y-6 max-h-[400px] overflow-y-auto", children: [
4744
- listeningFor && /* @__PURE__ */ jsxs("div", { className: "p-4 rounded-lg bg-black/50 border border-retro-primary/50 text-center animate-pulse", style: { borderColor: `${systemColor}50` }, children: [
4745
- /* @__PURE__ */ jsx("p", { className: "text-sm text-white mb-1", children: t.modals.gamepad.pressButton.replace("{{button}}", BUTTON_LABELS[listeningFor]) }),
4746
- /* @__PURE__ */ jsx("p", { className: "text-xs text-gray-400", children: t.modals.gamepad.pressEsc })
4747
- ] }),
4748
- BUTTON_GROUPS.map((group) => /* @__PURE__ */ jsxs("div", { children: [
4749
- /* @__PURE__ */ jsx("h3", { className: "text-xs font-bold text-gray-500 uppercase tracking-wider mb-2", children: group.label }),
4750
- /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-2", children: group.buttons.map((btn) => /* @__PURE__ */ jsxs(
4751
- "button",
4752
- {
4753
- onClick: () => setListeningFor(btn),
4754
- disabled: !!listeningFor && listeningFor !== btn,
4755
- className: `
4756
- flex items-center justify-between px-4 py-3 rounded-lg border transition-all
4757
- disabled:opacity-50
4758
- ${listeningFor === btn ? "border-retro-primary bg-retro-primary/20 ring-2 ring-retro-primary/50" : "border-white/10 bg-white/5 hover:border-white/20 hover:bg-white/10"}
4759
- `,
4760
- style: listeningFor === btn ? {
4761
- borderColor: systemColor,
4762
- backgroundColor: `${systemColor}20`,
4763
- boxShadow: `0 0 0 2px ${systemColor}50`
4764
- } : {},
4765
- children: [
4766
- /* @__PURE__ */ jsx("span", { className: "text-sm text-gray-300", children: BUTTON_LABELS[btn] }),
4767
- /* @__PURE__ */ jsx(
4768
- "span",
4769
- {
4770
- className: `
4771
- px-2 py-1 rounded text-xs font-mono
4772
- ${listeningFor === btn ? "bg-retro-primary/30 text-retro-primary animate-pulse" : "bg-black/50 text-white"}
4773
- `,
4774
- style: listeningFor === btn ? {
4775
- backgroundColor: `${systemColor}30`,
4776
- color: systemColor
4777
- } : {},
4778
- children: listeningFor === btn ? t.controls.press : formatGamepadButton(currentBindings[btn])
4779
- }
4780
- )
4781
- ]
4782
- },
4783
- btn
4784
- )) })
4785
- ] }, group.label))
4786
- ] }),
4787
- gamepads.length > 0 && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between px-6 py-4 bg-black/30 border-t border-white/10", children: [
4737
+ return /* @__PURE__ */ jsxs(
4738
+ ModalShell,
4739
+ {
4740
+ isOpen,
4741
+ onClose,
4742
+ title: t.modals.gamepad.title,
4743
+ subtitle: gamepads.length > 0 ? t.modals.gamepad.connected.replace("{{count}}", gamepads.length.toString()) : t.modals.gamepad.none,
4744
+ icon: /* @__PURE__ */ jsx(Joystick, { size: 24, style: { color: systemColor } }),
4745
+ systemColor,
4746
+ closeOnBackdrop: !isListening,
4747
+ footer: gamepads.length > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
4788
4748
  /* @__PURE__ */ jsxs(
4789
4749
  "button",
4790
4750
  {
@@ -4810,9 +4770,101 @@ function GamepadMapper({
4810
4770
  ]
4811
4771
  }
4812
4772
  )
4813
- ] })
4814
- ] })
4815
- ] });
4773
+ ] }) : void 0,
4774
+ children: [
4775
+ gamepads.length > 1 && /* @__PURE__ */ jsxs("div", { className: "px-6 py-3 border-b border-white/10 bg-black/30", children: [
4776
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
4777
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-gray-400 font-medium", children: t.modals.gamepad.player }),
4778
+ /* @__PURE__ */ jsx("div", { className: "flex gap-1", children: gamepads.map((gp) => /* @__PURE__ */ jsxs(
4779
+ "button",
4780
+ {
4781
+ onClick: () => setSelectedPlayer(gp.index + 1),
4782
+ className: `
4783
+ flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all
4784
+ ${selectedPlayer === gp.index + 1 ? "bg-retro-primary/20 text-retro-primary" : "bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white"}
4785
+ `,
4786
+ style: selectedPlayer === gp.index + 1 ? {
4787
+ backgroundColor: `${systemColor}20`,
4788
+ color: systemColor
4789
+ } : {},
4790
+ children: [
4791
+ /* @__PURE__ */ jsx(User, { size: 14 }),
4792
+ "P",
4793
+ gp.index + 1
4794
+ ]
4795
+ },
4796
+ gp.index
4797
+ )) })
4798
+ ] }),
4799
+ currentGamepad && /* @__PURE__ */ jsx("p", { className: "text-xs text-gray-500 mt-1", children: currentGamepad.name })
4800
+ ] }),
4801
+ gamepads.length === 1 && currentGamepad && /* @__PURE__ */ jsx("div", { className: "px-6 py-2 border-b border-white/10 bg-black/30", children: /* @__PURE__ */ jsxs("p", { className: "text-xs text-gray-400", children: [
4802
+ currentGamepad.name,
4803
+ " \u2022 Player 1"
4804
+ ] }) }),
4805
+ gamepads.length === 0 && /* @__PURE__ */ jsxs("div", { className: "px-6 py-10 text-center", children: [
4806
+ /* @__PURE__ */ jsxs("div", { className: "relative inline-block mb-4", children: [
4807
+ /* @__PURE__ */ jsx(Joystick, { size: 56, className: "text-gray-600 animate-pulse" }),
4808
+ /* @__PURE__ */ jsx("div", { className: "absolute inset-0 -m-2 rounded-full border-2 border-dashed border-gray-600 animate-spin", style: { animationDuration: "8s" } })
4809
+ ] }),
4810
+ /* @__PURE__ */ jsx("p", { className: "text-gray-300 font-medium mb-2", children: t.modals.gamepad.noController }),
4811
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-gray-500 mb-4", children: t.modals.gamepad.pressAny }),
4812
+ /* @__PURE__ */ jsxs("div", { className: "inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-white/5 border border-white/10", children: [
4813
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-gray-400", children: t.modals.gamepad.waiting }),
4814
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-1", children: [
4815
+ /* @__PURE__ */ jsx("div", { className: "w-1.5 h-1.5 rounded-full bg-gray-500 animate-bounce", style: { animationDelay: "0ms" } }),
4816
+ /* @__PURE__ */ jsx("div", { className: "w-1.5 h-1.5 rounded-full bg-gray-500 animate-bounce", style: { animationDelay: "150ms" } }),
4817
+ /* @__PURE__ */ jsx("div", { className: "w-1.5 h-1.5 rounded-full bg-gray-500 animate-bounce", style: { animationDelay: "300ms" } })
4818
+ ] })
4819
+ ] })
4820
+ ] }),
4821
+ gamepads.length > 0 && /* @__PURE__ */ jsxs("div", { className: "p-4 space-y-6 max-h-[400px] overflow-y-auto", children: [
4822
+ listeningFor && /* @__PURE__ */ jsxs("div", { className: "p-4 rounded-lg bg-black/50 border border-retro-primary/50 text-center animate-pulse", style: { borderColor: `${systemColor}50` }, children: [
4823
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-white mb-1", children: t.modals.gamepad.pressButton.replace("{{button}}", BUTTON_LABELS[listeningFor]) }),
4824
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-gray-400", children: t.modals.gamepad.pressEsc })
4825
+ ] }),
4826
+ BUTTON_GROUPS.map((group) => /* @__PURE__ */ jsxs("div", { children: [
4827
+ /* @__PURE__ */ jsx("h3", { className: "text-xs font-bold text-gray-500 uppercase tracking-wider mb-2", children: group.label }),
4828
+ /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-2", children: group.buttons.map((btn) => /* @__PURE__ */ jsxs(
4829
+ "button",
4830
+ {
4831
+ onClick: () => startListening(btn),
4832
+ disabled: !!listeningFor && listeningFor !== btn,
4833
+ className: `
4834
+ flex items-center justify-between px-4 py-3 rounded-lg border transition-all
4835
+ disabled:opacity-50
4836
+ ${listeningFor === btn ? "border-retro-primary bg-retro-primary/20 ring-2 ring-retro-primary/50" : "border-white/10 bg-white/5 hover:border-white/20 hover:bg-white/10"}
4837
+ `,
4838
+ style: listeningFor === btn ? {
4839
+ borderColor: systemColor,
4840
+ backgroundColor: `${systemColor}20`,
4841
+ boxShadow: `0 0 0 2px ${systemColor}50`
4842
+ } : {},
4843
+ children: [
4844
+ /* @__PURE__ */ jsx("span", { className: "text-sm text-gray-300", children: BUTTON_LABELS[btn] }),
4845
+ /* @__PURE__ */ jsx(
4846
+ "span",
4847
+ {
4848
+ className: `
4849
+ px-2 py-1 rounded text-xs font-mono
4850
+ ${listeningFor === btn ? "bg-retro-primary/30 text-retro-primary animate-pulse" : "bg-black/50 text-white"}
4851
+ `,
4852
+ style: listeningFor === btn ? {
4853
+ backgroundColor: `${systemColor}30`,
4854
+ color: systemColor
4855
+ } : {},
4856
+ children: listeningFor === btn ? t.controls.press : formatGamepadButton(currentBindings[btn])
4857
+ }
4858
+ )
4859
+ ]
4860
+ },
4861
+ btn
4862
+ )) })
4863
+ ] }, group.label))
4864
+ ] })
4865
+ ]
4866
+ }
4867
+ );
4816
4868
  }
4817
4869
  function CheatModal({
4818
4870
  isOpen,
@@ -4823,39 +4875,21 @@ function CheatModal({
4823
4875
  }) {
4824
4876
  const t = useKoinTranslation();
4825
4877
  const [copiedId, setCopiedId] = React2.useState(null);
4826
- if (!isOpen) return null;
4827
4878
  const handleCopy = async (code, id) => {
4828
4879
  await navigator.clipboard.writeText(code);
4829
- setCopiedId(id);
4830
- setTimeout(() => setCopiedId(null), 2e3);
4831
- };
4832
- return /* @__PURE__ */ jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center", children: [
4833
- /* @__PURE__ */ jsx(
4834
- "div",
4835
- {
4836
- className: "absolute inset-0 bg-black/80 backdrop-blur-sm",
4837
- onClick: onClose
4838
- }
4839
- ),
4840
- /* @__PURE__ */ jsxs("div", { className: "relative bg-gray-900 border border-retro-primary/30 rounded-xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden", children: [
4841
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between px-6 py-4 border-b border-white/10 bg-black/50", children: [
4842
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
4843
- /* @__PURE__ */ jsx(Code, { className: "text-purple-400", size: 24 }),
4844
- /* @__PURE__ */ jsxs("div", { children: [
4845
- /* @__PURE__ */ jsx("h2", { className: "text-lg font-bold text-white", children: t.modals.cheats.title }),
4846
- /* @__PURE__ */ jsx("p", { className: "text-xs text-gray-400", children: t.modals.cheats.available.replace("{{count}}", cheats.length.toString()) })
4847
- ] })
4848
- ] }),
4849
- /* @__PURE__ */ jsx(
4850
- "button",
4851
- {
4852
- onClick: onClose,
4853
- className: "p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-colors",
4854
- children: /* @__PURE__ */ jsx(X, { size: 20 })
4855
- }
4856
- )
4857
- ] }),
4858
- /* @__PURE__ */ jsx("div", { className: "p-4 space-y-2 max-h-[400px] overflow-y-auto", children: cheats.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "text-center py-12 text-gray-500", children: [
4880
+ setCopiedId(id);
4881
+ setTimeout(() => setCopiedId(null), 2e3);
4882
+ };
4883
+ return /* @__PURE__ */ jsx(
4884
+ ModalShell,
4885
+ {
4886
+ isOpen,
4887
+ onClose,
4888
+ title: t.modals.cheats.title,
4889
+ subtitle: t.modals.cheats.available.replace("{{count}}", cheats.length.toString()),
4890
+ icon: /* @__PURE__ */ jsx(Code, { size: 24, className: "text-purple-400" }),
4891
+ footer: /* @__PURE__ */ jsx("p", { className: "text-xs text-gray-500 text-center w-full", children: activeCheats.size > 0 ? t.modals.cheats.active.replace("{{count}}", activeCheats.size.toString()) : t.modals.cheats.toggleHint }),
4892
+ children: /* @__PURE__ */ jsx("div", { className: "p-4 space-y-2 max-h-[400px] overflow-y-auto", children: cheats.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "text-center py-12 text-gray-500", children: [
4859
4893
  /* @__PURE__ */ jsx(Code, { size: 48, className: "mx-auto mb-3 opacity-50" }),
4860
4894
  /* @__PURE__ */ jsx("p", { className: "font-medium", children: t.modals.cheats.emptyTitle }),
4861
4895
  /* @__PURE__ */ jsx("p", { className: "text-sm mt-1", children: t.modals.cheats.emptyDesc })
@@ -4865,18 +4899,18 @@ function CheatModal({
4865
4899
  "div",
4866
4900
  {
4867
4901
  className: `
4868
- group flex items-start gap-4 p-4 rounded-lg border transition-all cursor-pointer
4869
- ${isActive ? "border-purple-500/50 bg-purple-500/10" : "border-white/10 bg-white/5 hover:border-white/20 hover:bg-white/10"}
4870
- `,
4902
+ group flex items-start gap-4 p-4 rounded-lg border transition-all cursor-pointer
4903
+ ${isActive ? "border-purple-500/50 bg-purple-500/10" : "border-white/10 bg-white/5 hover:border-white/20 hover:bg-white/10"}
4904
+ `,
4871
4905
  onClick: () => onToggle(cheat.id),
4872
4906
  children: [
4873
4907
  /* @__PURE__ */ jsx(
4874
4908
  "div",
4875
4909
  {
4876
4910
  className: `
4877
- flex-shrink-0 w-6 h-6 rounded border-2 flex items-center justify-center transition-all
4878
- ${isActive ? "border-purple-500 bg-purple-500" : "border-gray-600 group-hover:border-gray-400"}
4879
- `,
4911
+ flex-shrink-0 w-6 h-6 rounded border-2 flex items-center justify-center transition-all
4912
+ ${isActive ? "border-purple-500 bg-purple-500" : "border-gray-600 group-hover:border-gray-400"}
4913
+ `,
4880
4914
  children: isActive && /* @__PURE__ */ jsx(Check, { size: 14, className: "text-white" })
4881
4915
  }
4882
4916
  ),
@@ -4902,10 +4936,9 @@ function CheatModal({
4902
4936
  },
4903
4937
  cheat.id
4904
4938
  );
4905
- }) }),
4906
- /* @__PURE__ */ jsx("div", { className: "px-6 py-3 bg-black/30 border-t border-white/10", children: /* @__PURE__ */ jsx("p", { className: "text-xs text-gray-500 text-center", children: activeCheats.size > 0 ? t.modals.cheats.active.replace("{{count}}", activeCheats.size.toString()) : t.modals.cheats.toggleHint }) })
4907
- ] })
4908
- ] });
4939
+ }) })
4940
+ }
4941
+ );
4909
4942
  }
4910
4943
  var AUTO_SAVE_SLOT = 5;
4911
4944
  function formatBytes(bytes) {
@@ -4946,7 +4979,6 @@ function SaveSlotModal({
4946
4979
  onUpgrade
4947
4980
  }) {
4948
4981
  const t = useKoinTranslation();
4949
- if (!isOpen) return null;
4950
4982
  const isSaveMode = mode === "save";
4951
4983
  const allSlots = [1, 2, 3, 4, 5];
4952
4984
  const isUnlimited = maxSlots === -1 || maxSlots >= 5;
@@ -4961,33 +4993,17 @@ function SaveSlotModal({
4961
4993
  const getSlotData = (slotNum) => {
4962
4994
  return slots.find((s) => s.slot === slotNum);
4963
4995
  };
4964
- return /* @__PURE__ */ jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center", children: [
4965
- /* @__PURE__ */ jsx(
4966
- "div",
4967
- {
4968
- className: "absolute inset-0 bg-black/80 backdrop-blur-sm",
4969
- onClick: onClose
4970
- }
4971
- ),
4972
- /* @__PURE__ */ jsxs("div", { className: "relative bg-gray-900 border border-retro-primary/30 rounded-xl shadow-2xl w-full max-w-md mx-4 overflow-hidden", children: [
4973
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between px-6 py-4 border-b border-white/10 bg-black/50", children: [
4974
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
4975
- isSaveMode ? /* @__PURE__ */ jsx(Save, { className: "text-retro-primary", size: 24 }) : /* @__PURE__ */ jsx(Download, { className: "text-retro-primary", size: 24 }),
4976
- /* @__PURE__ */ jsxs("div", { children: [
4977
- /* @__PURE__ */ jsx("h2", { className: "text-lg font-bold text-white", children: isSaveMode ? t.modals.saveSlots.saveTitle : t.modals.saveSlots.loadTitle }),
4978
- /* @__PURE__ */ jsx("p", { className: "text-xs text-gray-400", children: isSaveMode ? t.modals.saveSlots.subtitleSave : t.modals.saveSlots.subtitleLoad })
4979
- ] })
4980
- ] }),
4981
- /* @__PURE__ */ jsx(
4982
- "button",
4983
- {
4984
- onClick: onClose,
4985
- className: "p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-colors",
4986
- children: /* @__PURE__ */ jsx(X, { size: 20 })
4987
- }
4988
- )
4989
- ] }),
4990
- /* @__PURE__ */ jsx("div", { className: "p-4 space-y-2 max-h-[400px] overflow-y-auto", children: isLoading ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center py-12 text-gray-400", children: [
4996
+ return /* @__PURE__ */ jsx(
4997
+ ModalShell,
4998
+ {
4999
+ isOpen,
5000
+ onClose,
5001
+ title: isSaveMode ? t.modals.saveSlots.saveTitle : t.modals.saveSlots.loadTitle,
5002
+ subtitle: isSaveMode ? t.modals.saveSlots.subtitleSave : t.modals.saveSlots.subtitleLoad,
5003
+ icon: isSaveMode ? /* @__PURE__ */ jsx(Save, { className: "text-retro-primary", size: 24 }) : /* @__PURE__ */ jsx(Download, { className: "text-retro-primary", size: 24 }),
5004
+ maxWidth: "md",
5005
+ footer: /* @__PURE__ */ jsx("p", { className: "text-xs text-gray-500 text-center w-full", children: isSaveMode ? t.modals.saveSlots.footerSave : t.modals.saveSlots.footerLoad }),
5006
+ children: /* @__PURE__ */ jsx("div", { className: "p-4 space-y-2 max-h-[400px] overflow-y-auto", children: isLoading ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center py-12 text-gray-400", children: [
4991
5007
  /* @__PURE__ */ jsx(Loader2, { className: "w-8 h-8 animate-spin mb-3" }),
4992
5008
  /* @__PURE__ */ jsx("span", { className: "text-sm", children: t.modals.saveSlots.loading })
4993
5009
  ] }) : displaySlots.map((slotNum) => {
@@ -5106,10 +5122,9 @@ function SaveSlotModal({
5106
5122
  },
5107
5123
  slotNum
5108
5124
  );
5109
- }) }),
5110
- /* @__PURE__ */ jsx("div", { className: "px-6 py-3 bg-black/30 border-t border-white/10", children: /* @__PURE__ */ jsx("p", { className: "text-xs text-gray-500 text-center", children: isSaveMode ? t.modals.saveSlots.footerSave : t.modals.saveSlots.footerLoad }) })
5111
- ] })
5112
- ] });
5125
+ }) })
5126
+ }
5127
+ );
5113
5128
  }
5114
5129
  function BiosSelectionModal({
5115
5130
  isOpen,
@@ -5256,36 +5271,28 @@ function SettingsModal({
5256
5271
  systemColor = "#00FF41"
5257
5272
  }) {
5258
5273
  const t = useKoinTranslation();
5259
- if (!isOpen) return null;
5260
5274
  const languages = [
5261
5275
  { code: "en", name: "English" },
5262
5276
  { code: "es", name: "Espa\xF1ol" },
5263
5277
  { code: "fr", name: "Fran\xE7ais" }
5264
5278
  ];
5265
- return /* @__PURE__ */ jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center", children: [
5266
- /* @__PURE__ */ jsx(
5267
- "div",
5268
- {
5269
- className: "absolute inset-0 bg-black/80 backdrop-blur-sm",
5270
- onClick: onClose
5271
- }
5272
- ),
5273
- /* @__PURE__ */ jsxs("div", { className: "relative bg-gray-900 border border-white/10 rounded-xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden animate-in fade-in zoom-in-95 duration-200", children: [
5274
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between px-6 py-4 border-b border-white/10 bg-black/50", children: [
5275
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
5276
- /* @__PURE__ */ jsx(Settings, { className: "text-white", size: 20 }),
5277
- /* @__PURE__ */ jsx("h2", { className: "text-lg font-bold text-white", children: t.settings.title })
5278
- ] }),
5279
- /* @__PURE__ */ jsx(
5280
- "button",
5281
- {
5282
- onClick: onClose,
5283
- className: "p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-colors",
5284
- children: /* @__PURE__ */ jsx(X, { size: 20 })
5285
- }
5286
- )
5287
- ] }),
5288
- /* @__PURE__ */ jsx("div", { className: "p-6 space-y-6", children: /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
5279
+ return /* @__PURE__ */ jsx(
5280
+ ModalShell,
5281
+ {
5282
+ isOpen,
5283
+ onClose,
5284
+ title: t.settings.title,
5285
+ icon: /* @__PURE__ */ jsx(Settings, { size: 20, className: "text-white" }),
5286
+ maxWidth: "sm",
5287
+ footer: /* @__PURE__ */ jsx(
5288
+ "button",
5289
+ {
5290
+ onClick: onClose,
5291
+ className: "text-sm text-gray-500 hover:text-white transition-colors w-full text-center",
5292
+ children: t.modals.shortcuts.pressEsc
5293
+ }
5294
+ ),
5295
+ children: /* @__PURE__ */ jsx("div", { className: "p-6 space-y-6", children: /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
5289
5296
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-sm font-medium text-gray-400", children: [
5290
5297
  /* @__PURE__ */ jsx(Globe, { size: 16 }),
5291
5298
  /* @__PURE__ */ jsx("span", { children: t.settings.language })
@@ -5297,9 +5304,9 @@ function SettingsModal({
5297
5304
  {
5298
5305
  onClick: () => onLanguageChange(lang.code),
5299
5306
  className: `
5300
- flex items-center justify-between px-4 py-3 rounded-lg border transition-all
5301
- ${isActive ? "bg-white/10 border-white/20 text-white" : "bg-black/20 border-transparent text-gray-400 hover:bg-white/5 hover:text-white"}
5302
- `,
5307
+ flex items-center justify-between px-4 py-3 rounded-lg border transition-all
5308
+ ${isActive ? "bg-white/10 border-white/20 text-white" : "bg-black/20 border-transparent text-gray-400 hover:bg-white/5 hover:text-white"}
5309
+ `,
5303
5310
  children: [
5304
5311
  /* @__PURE__ */ jsx("span", { children: lang.name }),
5305
5312
  isActive && /* @__PURE__ */ jsx(Check, { size: 16, style: { color: systemColor } })
@@ -5308,17 +5315,9 @@ function SettingsModal({
5308
5315
  lang.code
5309
5316
  );
5310
5317
  }) })
5311
- ] }) }),
5312
- /* @__PURE__ */ jsx("div", { className: "px-6 py-4 bg-black/30 border-t border-white/10 text-center", children: /* @__PURE__ */ jsx(
5313
- "button",
5314
- {
5315
- onClick: onClose,
5316
- className: "text-sm text-gray-500 hover:text-white transition-colors",
5317
- children: t.modals.shortcuts.pressEsc
5318
- }
5319
- ) })
5320
- ] })
5321
- ] });
5318
+ ] }) })
5319
+ }
5320
+ );
5322
5321
  }
5323
5322
  function GameModals({
5324
5323
  controlsModalOpen,
@@ -7529,6 +7528,171 @@ var useNostalgist = ({
7529
7528
  ]);
7530
7529
  return hookReturn;
7531
7530
  };
7531
+ function getDisplayName(id) {
7532
+ let name = id;
7533
+ name = name.replace(/^[0-9a-f]{4}-[0-9a-f]{4}-/i, "");
7534
+ name = name.replace(/\s*\(.*\)\s*$/i, "");
7535
+ name = name.replace(/\s*STANDARD GAMEPAD\s*/i, "");
7536
+ if (/xbox/i.test(name)) {
7537
+ if (/series/i.test(name)) return "Xbox Series Controller";
7538
+ if (/one/i.test(name)) return "Xbox One Controller";
7539
+ if (/360/i.test(name)) return "Xbox 360 Controller";
7540
+ return "Xbox Controller";
7541
+ }
7542
+ if (/dualsense/i.test(name)) return "DualSense";
7543
+ if (/dualshock\s*4/i.test(name)) return "DualShock 4";
7544
+ if (/dualshock/i.test(name)) return "DualShock";
7545
+ if (/playstation/i.test(name) || /sony/i.test(name)) return "PlayStation Controller";
7546
+ if (/pro\s*controller/i.test(name)) return "Switch Pro Controller";
7547
+ if (/joy-?con/i.test(name)) return "Joy-Con";
7548
+ if (/nintendo/i.test(name)) return "Nintendo Controller";
7549
+ return name.trim() || "Gamepad";
7550
+ }
7551
+ function detectControllerBrand(id) {
7552
+ const lowerId = id.toLowerCase();
7553
+ if (/xbox|xinput|microsoft/i.test(lowerId)) return "xbox";
7554
+ if (/playstation|sony|dualshock|dualsense/i.test(lowerId)) return "playstation";
7555
+ if (/nintendo|switch|joy-?con|pro controller/i.test(lowerId)) return "nintendo";
7556
+ return "generic";
7557
+ }
7558
+ function toGamepadInfo(gamepad) {
7559
+ return {
7560
+ index: gamepad.index,
7561
+ id: gamepad.id,
7562
+ name: getDisplayName(gamepad.id),
7563
+ connected: gamepad.connected,
7564
+ buttons: gamepad.buttons.length,
7565
+ axes: gamepad.axes.length,
7566
+ mapping: gamepad.mapping
7567
+ };
7568
+ }
7569
+ function useGamepad(options) {
7570
+ const { onConnect, onDisconnect } = options || {};
7571
+ const [gamepads, setGamepads] = useState([]);
7572
+ const rafRef = useRef(null);
7573
+ const lastStateRef = useRef("");
7574
+ const prevCountRef = useRef(0);
7575
+ const onConnectRef = useRef(onConnect);
7576
+ const onDisconnectRef = useRef(onDisconnect);
7577
+ useEffect(() => {
7578
+ onConnectRef.current = onConnect;
7579
+ onDisconnectRef.current = onDisconnect;
7580
+ }, [onConnect, onDisconnect]);
7581
+ const getGamepads = useCallback(() => {
7582
+ if (typeof navigator === "undefined" || typeof navigator.getGamepads !== "function") {
7583
+ return [];
7584
+ }
7585
+ const rawGamepads = navigator.getGamepads() ?? [];
7586
+ const connected = [];
7587
+ for (let i = 0; i < rawGamepads.length; i++) {
7588
+ const gp = rawGamepads[i];
7589
+ if (gp && gp.connected) {
7590
+ connected.push(toGamepadInfo(gp));
7591
+ }
7592
+ }
7593
+ return connected;
7594
+ }, []);
7595
+ const getRawGamepad = useCallback((index) => {
7596
+ const rawGamepads = navigator.getGamepads?.() ?? [];
7597
+ return rawGamepads[index] ?? null;
7598
+ }, []);
7599
+ const refresh = useCallback(() => {
7600
+ setGamepads(getGamepads());
7601
+ }, [getGamepads]);
7602
+ useEffect(() => {
7603
+ if (typeof window === "undefined" || typeof navigator === "undefined") {
7604
+ return;
7605
+ }
7606
+ if (typeof navigator.getGamepads !== "function") {
7607
+ console.warn("[useGamepad] Gamepad API not supported in this browser");
7608
+ return;
7609
+ }
7610
+ let isActive = true;
7611
+ const poll = () => {
7612
+ if (!isActive) return;
7613
+ const current = getGamepads();
7614
+ let hasChanged = current.length !== prevCountRef.current;
7615
+ if (!hasChanged) {
7616
+ for (let i = 0; i < current.length; i++) {
7617
+ const saved = gamepads[i];
7618
+ if (!saved || saved.id !== current[i].id || saved.connected !== current[i].connected) {
7619
+ hasChanged = true;
7620
+ break;
7621
+ }
7622
+ }
7623
+ }
7624
+ if (hasChanged) {
7625
+ const prevCount = prevCountRef.current;
7626
+ const currentCount = current.length;
7627
+ if (currentCount > prevCount && prevCount >= 0 && onConnectRef.current) {
7628
+ const newGamepad = current[current.length - 1];
7629
+ onConnectRef.current(newGamepad);
7630
+ } else if (currentCount < prevCount && prevCount > 0 && onDisconnectRef.current) {
7631
+ onDisconnectRef.current();
7632
+ }
7633
+ prevCountRef.current = currentCount;
7634
+ setGamepads(current);
7635
+ }
7636
+ rafRef.current = requestAnimationFrame(poll);
7637
+ };
7638
+ const handleConnect = (e) => {
7639
+ console.log("[useGamepad] \u{1F3AE} Gamepad connected:", e.gamepad.id);
7640
+ const current = getGamepads();
7641
+ const prevCount = prevCountRef.current;
7642
+ prevCountRef.current = current.length;
7643
+ lastStateRef.current = current.map((g) => `${g.index}:${g.id}`).join("|");
7644
+ setGamepads(current);
7645
+ if (onConnectRef.current && current.length > prevCount) {
7646
+ const newGamepad = current[current.length - 1];
7647
+ onConnectRef.current(newGamepad);
7648
+ }
7649
+ };
7650
+ const handleDisconnect = (e) => {
7651
+ console.log("[useGamepad] \u{1F3AE} Gamepad disconnected:", e.gamepad.id);
7652
+ const current = getGamepads();
7653
+ const prevCount = prevCountRef.current;
7654
+ prevCountRef.current = current.length;
7655
+ lastStateRef.current = current.map((g) => `${g.index}:${g.id}`).join("|");
7656
+ setGamepads(current);
7657
+ if (onDisconnectRef.current && current.length < prevCount) {
7658
+ onDisconnectRef.current();
7659
+ }
7660
+ };
7661
+ window.addEventListener("gamepadconnected", handleConnect);
7662
+ window.addEventListener("gamepaddisconnected", handleDisconnect);
7663
+ rafRef.current = requestAnimationFrame(poll);
7664
+ const initial = getGamepads();
7665
+ if (initial.length > 0) {
7666
+ console.log("[useGamepad] Initial gamepads found:", initial.map((g) => g.name).join(", "));
7667
+ prevCountRef.current = initial.length;
7668
+ setGamepads(initial);
7669
+ lastStateRef.current = initial.map((g) => `${g.index}:${g.id}`).join("|");
7670
+ } else {
7671
+ prevCountRef.current = 0;
7672
+ }
7673
+ return () => {
7674
+ isActive = false;
7675
+ if (rafRef.current) {
7676
+ cancelAnimationFrame(rafRef.current);
7677
+ }
7678
+ window.removeEventListener("gamepadconnected", handleConnect);
7679
+ window.removeEventListener("gamepaddisconnected", handleDisconnect);
7680
+ };
7681
+ }, [getGamepads]);
7682
+ return {
7683
+ gamepads,
7684
+ isAnyConnected: gamepads.length > 0,
7685
+ connectedCount: gamepads.length,
7686
+ getRawGamepad,
7687
+ refresh
7688
+ };
7689
+ }
7690
+ var STANDARD_AXIS_MAP = {
7691
+ leftStickX: 0,
7692
+ leftStickY: 1,
7693
+ rightStickX: 2,
7694
+ rightStickY: 3
7695
+ };
7532
7696
  function useVolume({
7533
7697
  setVolume: setVolumeInHook,
7534
7698
  toggleMute: toggleMuteInHook
@@ -9200,6 +9364,14 @@ var GamePlayerInner = memo(function GamePlayerInner2(props) {
9200
9364
  disabled: status === "loading" || status === "error"
9201
9365
  }
9202
9366
  ),
9367
+ isFullscreen2 && isMobile && (status === "running" || status === "paused") && /* @__PURE__ */ jsx(
9368
+ FloatingPauseButton,
9369
+ {
9370
+ isPaused,
9371
+ onClick: handlePauseToggle,
9372
+ systemColor
9373
+ }
9374
+ ),
9203
9375
  /* @__PURE__ */ jsxs("div", { className: "absolute top-2 right-2 z-40 flex flex-col items-end gap-2 pointer-events-auto", children: [
9204
9376
  /* @__PURE__ */ jsx(
9205
9377
  RecordingIndicator_default,
@@ -9408,27 +9580,15 @@ function AchievementPopup({
9408
9580
  onDismiss,
9409
9581
  autoDismissMs = 5e3
9410
9582
  }) {
9411
- const [isVisible, setIsVisible] = useState(false);
9412
- const [isExiting, setIsExiting] = useState(false);
9413
- useEffect(() => {
9414
- requestAnimationFrame(() => {
9415
- setIsVisible(true);
9416
- });
9417
- const timer = setTimeout(() => {
9418
- handleDismiss();
9419
- }, autoDismissMs);
9420
- return () => clearTimeout(timer);
9421
- }, [autoDismissMs]);
9422
- const handleDismiss = () => {
9423
- setIsExiting(true);
9424
- setTimeout(() => {
9425
- onDismiss();
9426
- }, 300);
9427
- };
9583
+ const { slideInRightClasses, triggerExit } = useAnimatedVisibility({
9584
+ exitDuration: 300,
9585
+ onExit: onDismiss,
9586
+ autoDismissMs
9587
+ });
9428
9588
  return /* @__PURE__ */ jsxs(
9429
9589
  "div",
9430
9590
  {
9431
- className: `fixed top-4 right-4 z-[100] transition-all duration-300 ${isVisible && !isExiting ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"}`,
9591
+ className: `fixed top-4 right-4 z-[100] transition-all duration-300 ${slideInRightClasses}`,
9432
9592
  children: [
9433
9593
  /* @__PURE__ */ jsx("div", { className: "absolute inset-0 bg-gradient-to-r from-yellow-500 to-orange-500 blur-lg opacity-50 animate-pulse" }),
9434
9594
  /* @__PURE__ */ jsx("div", { className: "relative bg-gradient-to-r from-yellow-500 to-orange-500 p-[2px] rounded-lg", children: /* @__PURE__ */ jsxs("div", { className: "bg-gray-900 rounded-lg p-4 flex items-center gap-4 min-w-[320px]", children: [
@@ -9465,7 +9625,7 @@ function AchievementPopup({
9465
9625
  /* @__PURE__ */ jsx(
9466
9626
  "button",
9467
9627
  {
9468
- onClick: handleDismiss,
9628
+ onClick: triggerExit,
9469
9629
  className: "flex-shrink-0 text-gray-500 hover:text-white transition-colors",
9470
9630
  children: /* @__PURE__ */ jsx(X, { size: 18 })
9471
9631
  }