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.js CHANGED
@@ -1447,6 +1447,40 @@ var PlayerControls = React2.memo(function PlayerControls2({
1447
1447
  ] });
1448
1448
  });
1449
1449
  var PlayerControls_default = PlayerControls;
1450
+ function useAnimatedVisibility({
1451
+ exitDuration = 200,
1452
+ onExit,
1453
+ autoDismissMs
1454
+ } = {}) {
1455
+ const [isVisible, setIsVisible] = React2.useState(false);
1456
+ const [isExiting, setIsExiting] = React2.useState(false);
1457
+ React2.useEffect(() => {
1458
+ requestAnimationFrame(() => {
1459
+ setIsVisible(true);
1460
+ });
1461
+ }, []);
1462
+ React2.useEffect(() => {
1463
+ if (!autoDismissMs) return;
1464
+ const timer = setTimeout(() => {
1465
+ triggerExit();
1466
+ }, autoDismissMs);
1467
+ return () => clearTimeout(timer);
1468
+ }, [autoDismissMs]);
1469
+ const triggerExit = React2.useCallback(() => {
1470
+ if (isExiting) return;
1471
+ setIsExiting(true);
1472
+ setTimeout(() => {
1473
+ onExit?.();
1474
+ }, exitDuration);
1475
+ }, [isExiting, exitDuration, onExit]);
1476
+ const slideInRightClasses = isVisible && !isExiting ? "translate-x-0 opacity-100" : "translate-x-full opacity-0";
1477
+ return {
1478
+ isVisible,
1479
+ isExiting,
1480
+ triggerExit,
1481
+ slideInRightClasses
1482
+ };
1483
+ }
1450
1484
  var TOAST_CONFIGS = {
1451
1485
  success: {
1452
1486
  icon: lucideReact.CheckCircle,
@@ -1491,32 +1525,20 @@ var TOAST_CONFIGS = {
1491
1525
  }
1492
1526
  };
1493
1527
  function ToastItem({ toast, onDismiss }) {
1494
- const [isVisible, setIsVisible] = React2.useState(false);
1495
- const [isExiting, setIsExiting] = React2.useState(false);
1528
+ const { slideInRightClasses, triggerExit } = useAnimatedVisibility({
1529
+ exitDuration: 200,
1530
+ onExit: () => onDismiss?.(toast.id)
1531
+ });
1496
1532
  const config = TOAST_CONFIGS[toast.type];
1497
1533
  const IconComponent = config.icon;
1498
- React2.useEffect(() => {
1499
- requestAnimationFrame(() => {
1500
- setIsVisible(true);
1501
- });
1502
- }, []);
1503
- const handleDismiss = () => {
1504
- setIsExiting(true);
1505
- setTimeout(() => {
1506
- onDismiss?.(toast.id);
1507
- }, 200);
1508
- };
1509
1534
  return /* @__PURE__ */ jsxRuntime.jsx(
1510
1535
  "div",
1511
1536
  {
1512
- className: `
1513
- relative transition-all duration-300 ease-out
1514
- ${isVisible && !isExiting ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"}
1515
- `,
1537
+ className: `relative transition-all duration-300 ease-out ${slideInRightClasses}`,
1516
1538
  children: /* @__PURE__ */ jsxRuntime.jsxs(
1517
1539
  "div",
1518
1540
  {
1519
- className: "relative w-[320px]",
1541
+ className: "relative w-[320px] pointer-events-auto",
1520
1542
  style: {
1521
1543
  backgroundColor: config.bgColor,
1522
1544
  border: `2px solid ${config.borderColor}`,
@@ -1551,7 +1573,7 @@ function ToastItem({ toast, onDismiss }) {
1551
1573
  {
1552
1574
  onClick: () => {
1553
1575
  toast.action?.onClick();
1554
- handleDismiss();
1576
+ triggerExit();
1555
1577
  },
1556
1578
  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",
1557
1579
  style: {
@@ -1565,7 +1587,7 @@ function ToastItem({ toast, onDismiss }) {
1565
1587
  /* @__PURE__ */ jsxRuntime.jsx(
1566
1588
  "button",
1567
1589
  {
1568
- onClick: handleDismiss,
1590
+ onClick: triggerExit,
1569
1591
  className: "flex-shrink-0 p-0.5 text-gray-500 hover:text-white transition-colors",
1570
1592
  children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { size: 14 })
1571
1593
  }
@@ -1938,6 +1960,67 @@ var RecordingIndicator = React2.memo(function RecordingIndicator2({
1938
1960
  );
1939
1961
  });
1940
1962
  var RecordingIndicator_default = RecordingIndicator;
1963
+ var MAX_WIDTH_CLASSES = {
1964
+ sm: "max-w-sm",
1965
+ md: "max-w-md",
1966
+ lg: "max-w-lg"
1967
+ };
1968
+ function ModalShell({
1969
+ isOpen,
1970
+ onClose,
1971
+ title,
1972
+ subtitle,
1973
+ icon,
1974
+ children,
1975
+ footer,
1976
+ maxWidth = "lg",
1977
+ systemColor,
1978
+ closeOnBackdrop = true
1979
+ }) {
1980
+ if (!isOpen) return null;
1981
+ const handleBackdropClick = () => {
1982
+ if (closeOnBackdrop) {
1983
+ onClose();
1984
+ }
1985
+ };
1986
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center", children: [
1987
+ /* @__PURE__ */ jsxRuntime.jsx(
1988
+ "div",
1989
+ {
1990
+ className: "absolute inset-0 bg-black/80 backdrop-blur-sm",
1991
+ onClick: handleBackdropClick
1992
+ }
1993
+ ),
1994
+ /* @__PURE__ */ jsxRuntime.jsxs(
1995
+ "div",
1996
+ {
1997
+ className: `relative bg-gray-900 border border-retro-primary/30 rounded-xl shadow-2xl w-full ${MAX_WIDTH_CLASSES[maxWidth]} mx-4 overflow-hidden`,
1998
+ style: systemColor ? { borderColor: `${systemColor}30` } : void 0,
1999
+ children: [
2000
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between px-6 py-4 border-b border-white/10 bg-black/50", children: [
2001
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3", children: [
2002
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: systemColor ? { color: systemColor } : void 0, children: icon }),
2003
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
2004
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "text-lg font-bold text-white", children: title }),
2005
+ subtitle && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-gray-400", children: subtitle })
2006
+ ] })
2007
+ ] }),
2008
+ /* @__PURE__ */ jsxRuntime.jsx(
2009
+ "button",
2010
+ {
2011
+ onClick: onClose,
2012
+ className: "p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-colors",
2013
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { size: 20 })
2014
+ }
2015
+ )
2016
+ ] }),
2017
+ children,
2018
+ footer && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center justify-between px-6 py-4 bg-black/30 border-t border-white/10", children: footer })
2019
+ ]
2020
+ }
2021
+ )
2022
+ ] });
2023
+ }
1941
2024
  var ShortcutsModal = React2.memo(function ShortcutsModal2({
1942
2025
  isOpen,
1943
2026
  onClose,
@@ -1966,90 +2049,55 @@ var ShortcutsModal = React2.memo(function ShortcutsModal2({
1966
2049
  ]
1967
2050
  }
1968
2051
  ], [t]);
1969
- if (!isOpen) return null;
1970
2052
  return /* @__PURE__ */ jsxRuntime.jsx(
1971
- "div",
2053
+ ModalShell,
1972
2054
  {
1973
- className: "absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm",
1974
- onClick: onClose,
1975
- children: /* @__PURE__ */ jsxRuntime.jsxs(
1976
- "div",
1977
- {
1978
- className: "max-w-sm w-full mx-4 bg-black/95 border rounded-lg overflow-hidden",
1979
- style: { borderColor: `${systemColor}40` },
1980
- onClick: (e) => e.stopPropagation(),
1981
- children: [
1982
- /* @__PURE__ */ jsxRuntime.jsxs(
1983
- "div",
1984
- {
1985
- className: "flex items-center justify-between px-4 py-3 border-b",
1986
- style: { borderColor: `${systemColor}20`, backgroundColor: `${systemColor}10` },
1987
- children: [
1988
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
1989
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Keyboard, { size: 18, style: { color: systemColor } }),
1990
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-bold text-white", children: t.modals.shortcuts.playerShortcuts })
1991
- ] }),
1992
- /* @__PURE__ */ jsxRuntime.jsx(
1993
- "button",
1994
- {
1995
- onClick: onClose,
1996
- className: "p-1 rounded hover:bg-white/10 transition-colors",
1997
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { size: 18, className: "text-white/60 hover:text-white" })
1998
- }
1999
- )
2000
- ]
2001
- }
2002
- ),
2003
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "p-4 space-y-3", children: [
2004
- shortcuts.map(({ section, items }) => /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
2055
+ isOpen,
2056
+ onClose,
2057
+ title: t.modals.shortcuts.playerShortcuts,
2058
+ icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Keyboard, { size: 20, style: { color: systemColor } }),
2059
+ maxWidth: "sm",
2060
+ systemColor,
2061
+ footer: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-gray-500 w-full text-center", children: t.modals.shortcuts.pressEsc }),
2062
+ children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "p-4 space-y-3", children: [
2063
+ shortcuts.map(({ section, items }) => /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
2064
+ /* @__PURE__ */ jsxRuntime.jsx(
2065
+ "h3",
2066
+ {
2067
+ className: "text-[10px] font-bold uppercase tracking-wider mb-1.5 opacity-60",
2068
+ style: { color: systemColor },
2069
+ children: section
2070
+ }
2071
+ ),
2072
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "space-y-1", children: items.map(({ key, description }) => /* @__PURE__ */ jsxRuntime.jsxs(
2073
+ "div",
2074
+ {
2075
+ className: "flex items-center justify-between text-sm",
2076
+ children: [
2077
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-white/70", children: description }),
2005
2078
  /* @__PURE__ */ jsxRuntime.jsx(
2006
- "h3",
2079
+ "kbd",
2007
2080
  {
2008
- className: "text-[10px] font-bold uppercase tracking-wider mb-1.5 opacity-60",
2009
- style: { color: systemColor },
2010
- children: section
2081
+ className: "px-2 py-0.5 rounded text-xs font-mono font-bold",
2082
+ style: {
2083
+ backgroundColor: `${systemColor}20`,
2084
+ color: systemColor,
2085
+ border: `1px solid ${systemColor}40`
2086
+ },
2087
+ children: key
2011
2088
  }
2012
- ),
2013
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "space-y-1", children: items.map(({ key, description }) => /* @__PURE__ */ jsxRuntime.jsxs(
2014
- "div",
2015
- {
2016
- className: "flex items-center justify-between text-sm",
2017
- children: [
2018
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-white/70", children: description }),
2019
- /* @__PURE__ */ jsxRuntime.jsx(
2020
- "kbd",
2021
- {
2022
- className: "px-2 py-0.5 rounded text-xs font-mono font-bold",
2023
- style: {
2024
- backgroundColor: `${systemColor}20`,
2025
- color: systemColor,
2026
- border: `1px solid ${systemColor}40`
2027
- },
2028
- children: key
2029
- }
2030
- )
2031
- ]
2032
- },
2033
- key
2034
- )) })
2035
- ] }, section)),
2036
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "pt-2 border-t border-white/10 text-xs text-white/40", children: [
2037
- "Game controls can be configured in ",
2038
- /* @__PURE__ */ jsxRuntime.jsx("strong", { className: "text-white/60", children: t.controls.keys }),
2039
- " settings."
2040
- ] })
2041
- ] }),
2042
- /* @__PURE__ */ jsxRuntime.jsx(
2043
- "div",
2044
- {
2045
- className: "px-4 py-2 text-center text-xs text-white/40 border-t",
2046
- style: { borderColor: `${systemColor}20` },
2047
- children: t.modals.shortcuts.pressEsc
2048
- }
2049
- )
2050
- ]
2051
- }
2052
- )
2089
+ )
2090
+ ]
2091
+ },
2092
+ key
2093
+ )) })
2094
+ ] }, section)),
2095
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "pt-2 border-t border-white/10 text-xs text-white/40", children: [
2096
+ "Game controls can be configured in ",
2097
+ /* @__PURE__ */ jsxRuntime.jsx("strong", { className: "text-white/60", children: t.controls.keys }),
2098
+ " settings."
2099
+ ] })
2100
+ ] })
2053
2101
  }
2054
2102
  );
2055
2103
  });
@@ -2393,26 +2441,49 @@ function getLayoutForSystem(system) {
2393
2441
  if (s.includes("ATARI")) return TWO_BUTTON_LAYOUT;
2394
2442
  return TWO_BUTTON_LAYOUT;
2395
2443
  }
2396
- var DRAG_HOLD_DELAY = 350;
2444
+
2445
+ // src/components/VirtualController/utils/dragConstraints.ts
2446
+ function constrainToViewport({
2447
+ newXPercent,
2448
+ newYPercent,
2449
+ elementSize,
2450
+ containerWidth,
2451
+ containerHeight
2452
+ }) {
2453
+ const xMargin = elementSize / 2 / containerWidth * 100;
2454
+ const yMargin = elementSize / 2 / containerHeight * 100;
2455
+ return {
2456
+ x: Math.max(xMargin, Math.min(100 - xMargin, newXPercent)),
2457
+ y: Math.max(yMargin, Math.min(100 - yMargin, newYPercent))
2458
+ };
2459
+ }
2460
+
2461
+ // src/components/VirtualController/hooks/useDrag.ts
2462
+ var DEFAULT_HOLD_DELAY = 350;
2463
+ var DEFAULT_CENTER_THRESHOLD = 0.4;
2397
2464
  var DRAG_MOVE_THRESHOLD = 10;
2398
- var DRAG_CENTER_THRESHOLD = 0.4;
2399
- function useTouchHandlers({
2400
- buttonType,
2401
- isSystemButton,
2402
- buttonSize,
2465
+ function useDrag({
2466
+ elementSize,
2403
2467
  displayX,
2404
2468
  displayY,
2405
2469
  containerWidth,
2406
2470
  containerHeight,
2407
- onPress,
2408
- onPressDown,
2409
- onRelease,
2410
- onPositionChange
2471
+ onPositionChange,
2472
+ holdDelay = DEFAULT_HOLD_DELAY,
2473
+ centerThreshold = DEFAULT_CENTER_THRESHOLD,
2474
+ onDragStart,
2475
+ onDragEnd
2411
2476
  }) {
2477
+ const [isDragging, setIsDragging] = React2.useState(false);
2412
2478
  const isDraggingRef = React2.useRef(false);
2413
- const dragStartRef = React2.useRef({ x: 0, y: 0 });
2414
2479
  const dragTimerRef = React2.useRef(null);
2415
2480
  const touchStartPosRef = React2.useRef({ x: 0, y: 0 });
2481
+ const dragStartRef = React2.useRef({
2482
+ elementX: 0,
2483
+ elementY: 0,
2484
+ touchX: 0,
2485
+ touchY: 0
2486
+ });
2416
2487
  const clearDragTimer = React2.useCallback(() => {
2417
2488
  if (dragTimerRef.current) {
2418
2489
  clearTimeout(dragTimerRef.current);
@@ -2422,23 +2493,129 @@ function useTouchHandlers({
2422
2493
  const startDragging = React2.useCallback(
2423
2494
  (touchX, touchY) => {
2424
2495
  isDraggingRef.current = true;
2496
+ setIsDragging(true);
2425
2497
  dragStartRef.current = {
2426
- x: touchX - displayX / 100 * containerWidth,
2427
- y: touchY - displayY / 100 * containerHeight
2498
+ elementX: displayX,
2499
+ elementY: displayY,
2500
+ touchX,
2501
+ touchY
2428
2502
  };
2429
2503
  if (navigator.vibrate) {
2430
2504
  navigator.vibrate([10, 30, 10]);
2431
2505
  }
2506
+ onDragStart?.();
2507
+ },
2508
+ [displayX, displayY, onDragStart]
2509
+ );
2510
+ const checkDragStart = React2.useCallback(
2511
+ (touchX, touchY, elementRect) => {
2512
+ if (!onPositionChange) return false;
2513
+ touchStartPosRef.current = { x: touchX, y: touchY };
2514
+ const centerX = elementRect.left + elementRect.width / 2;
2515
+ const centerY = elementRect.top + elementRect.height / 2;
2516
+ const distFromCenter = Math.sqrt(
2517
+ Math.pow(touchX - centerX, 2) + Math.pow(touchY - centerY, 2)
2518
+ );
2519
+ const centerRadius = elementSize * centerThreshold;
2520
+ if (distFromCenter < centerRadius) {
2521
+ dragTimerRef.current = setTimeout(() => {
2522
+ if (!isDraggingRef.current) {
2523
+ startDragging(touchX, touchY);
2524
+ }
2525
+ }, holdDelay);
2526
+ return true;
2527
+ }
2528
+ return false;
2529
+ },
2530
+ [onPositionChange, elementSize, centerThreshold, holdDelay, startDragging]
2531
+ );
2532
+ const checkMoveThreshold = React2.useCallback(
2533
+ (touchX, touchY) => {
2534
+ if (!onPositionChange || isDraggingRef.current) return false;
2535
+ const moveDistance = Math.sqrt(
2536
+ Math.pow(touchX - touchStartPosRef.current.x, 2) + Math.pow(touchY - touchStartPosRef.current.y, 2)
2537
+ );
2538
+ if (moveDistance > DRAG_MOVE_THRESHOLD) {
2539
+ clearDragTimer();
2540
+ startDragging(touchX, touchY);
2541
+ return true;
2542
+ }
2543
+ return false;
2544
+ },
2545
+ [onPositionChange, clearDragTimer, startDragging]
2546
+ );
2547
+ const handleDragMove = React2.useCallback(
2548
+ (touchX, touchY) => {
2549
+ if (!isDraggingRef.current || !onPositionChange) return;
2550
+ const deltaX = touchX - dragStartRef.current.touchX;
2551
+ const deltaY = touchY - dragStartRef.current.touchY;
2552
+ const newXPercent = dragStartRef.current.elementX + deltaX / containerWidth * 100;
2553
+ const newYPercent = dragStartRef.current.elementY + deltaY / containerHeight * 100;
2554
+ const constrained = constrainToViewport({
2555
+ newXPercent,
2556
+ newYPercent,
2557
+ elementSize,
2558
+ containerWidth,
2559
+ containerHeight
2560
+ });
2561
+ onPositionChange(constrained.x, constrained.y);
2562
+ },
2563
+ [onPositionChange, containerWidth, containerHeight, elementSize]
2564
+ );
2565
+ const handleDragEnd = React2.useCallback(() => {
2566
+ clearDragTimer();
2567
+ if (isDraggingRef.current) {
2568
+ isDraggingRef.current = false;
2569
+ setIsDragging(false);
2570
+ onDragEnd?.();
2571
+ }
2572
+ }, [clearDragTimer, onDragEnd]);
2573
+ return {
2574
+ isDragging,
2575
+ checkDragStart,
2576
+ handleDragMove,
2577
+ handleDragEnd,
2578
+ clearDragTimer,
2579
+ checkMoveThreshold
2580
+ };
2581
+ }
2582
+
2583
+ // src/components/VirtualController/hooks/useTouchHandlers.ts
2584
+ function useTouchHandlers({
2585
+ buttonType,
2586
+ isSystemButton,
2587
+ buttonSize,
2588
+ displayX,
2589
+ displayY,
2590
+ containerWidth,
2591
+ containerHeight,
2592
+ onPress,
2593
+ onPressDown,
2594
+ onRelease,
2595
+ onPositionChange
2596
+ }) {
2597
+ const isDraggingRef = React2.useRef(false);
2598
+ const drag = useDrag({
2599
+ elementSize: buttonSize,
2600
+ displayX,
2601
+ displayY,
2602
+ containerWidth,
2603
+ containerHeight,
2604
+ onPositionChange,
2605
+ centerThreshold: 0.4,
2606
+ onDragStart: () => {
2607
+ isDraggingRef.current = true;
2432
2608
  if (!isSystemButton) {
2433
2609
  onRelease(buttonType);
2434
2610
  }
2435
2611
  },
2436
- [displayX, displayY, containerWidth, containerHeight, isSystemButton, buttonType, onRelease]
2437
- );
2612
+ onDragEnd: () => {
2613
+ isDraggingRef.current = false;
2614
+ }
2615
+ });
2438
2616
  const handleTouchStart = React2.useCallback(
2439
2617
  (e) => {
2440
2618
  const touch = e.touches[0];
2441
- touchStartPosRef.current = { x: touch.clientX, y: touch.clientY };
2442
2619
  e.preventDefault();
2443
2620
  e.stopPropagation();
2444
2621
  if (navigator.vibrate) {
@@ -2453,57 +2630,32 @@ function useTouchHandlers({
2453
2630
  const target = e.currentTarget;
2454
2631
  if (!target) return;
2455
2632
  const rect = target.getBoundingClientRect();
2456
- const centerX = rect.left + rect.width / 2;
2457
- const centerY = rect.top + rect.height / 2;
2458
- const distance = Math.sqrt(
2459
- Math.pow(touch.clientX - centerX, 2) + Math.pow(touch.clientY - centerY, 2)
2460
- );
2461
- const dragThreshold = buttonSize * DRAG_CENTER_THRESHOLD;
2462
- if (distance < dragThreshold) {
2463
- dragTimerRef.current = setTimeout(() => {
2464
- if (!isDraggingRef.current) {
2465
- startDragging(touch.clientX, touch.clientY);
2466
- }
2467
- }, DRAG_HOLD_DELAY);
2468
- }
2633
+ drag.checkDragStart(touch.clientX, touch.clientY, rect);
2469
2634
  }
2470
2635
  },
2471
- [isSystemButton, buttonType, onPress, onPressDown, onPositionChange, buttonSize, startDragging]
2636
+ [isSystemButton, buttonType, onPress, onPressDown, onPositionChange, drag]
2472
2637
  );
2473
2638
  const handleTouchMove = React2.useCallback(
2474
2639
  (e) => {
2475
2640
  const touch = e.touches[0];
2476
2641
  if (onPositionChange && !isDraggingRef.current) {
2477
- const moveDistance = Math.sqrt(
2478
- Math.pow(touch.clientX - touchStartPosRef.current.x, 2) + Math.pow(touch.clientY - touchStartPosRef.current.y, 2)
2479
- );
2480
- if (moveDistance > DRAG_MOVE_THRESHOLD) {
2481
- clearDragTimer();
2482
- startDragging(touch.clientX, touch.clientY);
2483
- }
2642
+ drag.checkMoveThreshold(touch.clientX, touch.clientY);
2484
2643
  }
2485
- if (isDraggingRef.current && onPositionChange) {
2644
+ if (isDraggingRef.current) {
2486
2645
  e.preventDefault();
2487
2646
  e.stopPropagation();
2488
- const newX = touch.clientX - dragStartRef.current.x;
2489
- const newY = touch.clientY - dragStartRef.current.y;
2490
- const newXPercent = newX / containerWidth * 100;
2491
- const newYPercent = newY / containerHeight * 100;
2492
- const margin = buttonSize / 2 / Math.min(containerWidth, containerHeight) * 100;
2493
- const constrainedX = Math.max(margin, Math.min(100 - margin, newXPercent));
2494
- const constrainedY = Math.max(margin, Math.min(100 - margin, newYPercent));
2495
- onPositionChange(constrainedX, constrainedY);
2647
+ drag.handleDragMove(touch.clientX, touch.clientY);
2496
2648
  }
2497
2649
  },
2498
- [onPositionChange, clearDragTimer, startDragging, containerWidth, containerHeight, buttonSize]
2650
+ [onPositionChange, drag]
2499
2651
  );
2500
2652
  const handleTouchEnd = React2.useCallback(
2501
2653
  (e) => {
2502
- clearDragTimer();
2654
+ drag.clearDragTimer();
2503
2655
  if (isDraggingRef.current) {
2504
2656
  e.preventDefault();
2505
2657
  e.stopPropagation();
2506
- isDraggingRef.current = false;
2658
+ drag.handleDragEnd();
2507
2659
  return;
2508
2660
  }
2509
2661
  e.preventDefault();
@@ -2512,15 +2664,15 @@ function useTouchHandlers({
2512
2664
  onRelease(buttonType);
2513
2665
  }
2514
2666
  },
2515
- [clearDragTimer, isSystemButton, buttonType, onRelease]
2667
+ [drag, isSystemButton, buttonType, onRelease]
2516
2668
  );
2517
2669
  const handleTouchCancel = React2.useCallback(
2518
2670
  (e) => {
2519
- clearDragTimer();
2671
+ drag.clearDragTimer();
2520
2672
  if (isDraggingRef.current) {
2521
2673
  e.preventDefault();
2522
2674
  e.stopPropagation();
2523
- isDraggingRef.current = false;
2675
+ drag.handleDragEnd();
2524
2676
  return;
2525
2677
  }
2526
2678
  e.preventDefault();
@@ -2529,11 +2681,11 @@ function useTouchHandlers({
2529
2681
  onRelease(buttonType);
2530
2682
  }
2531
2683
  },
2532
- [clearDragTimer, isSystemButton, buttonType, onRelease]
2684
+ [drag, isSystemButton, buttonType, onRelease]
2533
2685
  );
2534
2686
  const cleanup = React2.useCallback(() => {
2535
- clearDragTimer();
2536
- }, [clearDragTimer]);
2687
+ drag.clearDragTimer();
2688
+ }, [drag]);
2537
2689
  return {
2538
2690
  handleTouchStart,
2539
2691
  handleTouchMove,
@@ -2542,6 +2694,39 @@ function useTouchHandlers({
2542
2694
  cleanup
2543
2695
  };
2544
2696
  }
2697
+ function useTouchEvents(ref, handlers, options = {}) {
2698
+ const { cleanup, passive = false } = options;
2699
+ const handlersRef = React2.useRef(handlers);
2700
+ handlersRef.current = handlers;
2701
+ const handleTouchStart = React2.useCallback((e) => {
2702
+ handlersRef.current.onTouchStart?.(e);
2703
+ }, []);
2704
+ const handleTouchMove = React2.useCallback((e) => {
2705
+ handlersRef.current.onTouchMove?.(e);
2706
+ }, []);
2707
+ const handleTouchEnd = React2.useCallback((e) => {
2708
+ handlersRef.current.onTouchEnd?.(e);
2709
+ }, []);
2710
+ const handleTouchCancel = React2.useCallback((e) => {
2711
+ handlersRef.current.onTouchCancel?.(e);
2712
+ }, []);
2713
+ React2.useEffect(() => {
2714
+ const element = ref.current;
2715
+ if (!element) return;
2716
+ const listenerOptions = { passive };
2717
+ element.addEventListener("touchstart", handleTouchStart, listenerOptions);
2718
+ element.addEventListener("touchmove", handleTouchMove, listenerOptions);
2719
+ element.addEventListener("touchend", handleTouchEnd, listenerOptions);
2720
+ element.addEventListener("touchcancel", handleTouchCancel, listenerOptions);
2721
+ return () => {
2722
+ element.removeEventListener("touchstart", handleTouchStart);
2723
+ element.removeEventListener("touchmove", handleTouchMove);
2724
+ element.removeEventListener("touchend", handleTouchEnd);
2725
+ element.removeEventListener("touchcancel", handleTouchCancel);
2726
+ cleanup?.();
2727
+ };
2728
+ }, [ref, passive, cleanup, handleTouchStart, handleTouchMove, handleTouchEnd, handleTouchCancel]);
2729
+ }
2545
2730
 
2546
2731
  // src/components/VirtualController/utils/buttonStyles.ts
2547
2732
  var DEFAULT_FACE = {
@@ -2699,21 +2884,12 @@ var VirtualButton = React2__default.default.memo(function VirtualButton2({
2699
2884
  onRelease,
2700
2885
  onPositionChange
2701
2886
  });
2702
- React2.useEffect(() => {
2703
- const button = buttonRef.current;
2704
- if (!button) return;
2705
- button.addEventListener("touchstart", handleTouchStart, { passive: false });
2706
- button.addEventListener("touchmove", handleTouchMove, { passive: false });
2707
- button.addEventListener("touchend", handleTouchEnd, { passive: false });
2708
- button.addEventListener("touchcancel", handleTouchCancel, { passive: false });
2709
- return () => {
2710
- button.removeEventListener("touchstart", handleTouchStart);
2711
- button.removeEventListener("touchmove", handleTouchMove);
2712
- button.removeEventListener("touchend", handleTouchEnd);
2713
- button.removeEventListener("touchcancel", handleTouchCancel);
2714
- cleanup();
2715
- };
2716
- }, [handleTouchStart, handleTouchMove, handleTouchEnd, handleTouchCancel, cleanup]);
2887
+ useTouchEvents(buttonRef, {
2888
+ onTouchStart: handleTouchStart,
2889
+ onTouchMove: handleTouchMove,
2890
+ onTouchEnd: handleTouchEnd,
2891
+ onTouchCancel: handleTouchCancel
2892
+ }, { cleanup });
2717
2893
  const leftPercent = displayX / 100 * containerWidth - config.size / 2;
2718
2894
  const topPercent = displayY / 100 * containerHeight - config.size / 2;
2719
2895
  const transform = `translate3d(${leftPercent.toFixed(1)}px, ${topPercent.toFixed(1)}px, 0)`;
@@ -3389,8 +3565,7 @@ function dispatchKeyboardEvent(type, code) {
3389
3565
  canvas.dispatchEvent(event);
3390
3566
  return true;
3391
3567
  }
3392
- var DRAG_HOLD_DELAY2 = 350;
3393
- var CENTER_TOUCH_RADIUS = 0.25;
3568
+ var CENTER_TOUCH_RADIUS = 0.35;
3394
3569
  var Dpad = React2__default.default.memo(function Dpad2({
3395
3570
  size = 180,
3396
3571
  x,
@@ -3406,10 +3581,6 @@ var Dpad = React2__default.default.memo(function Dpad2({
3406
3581
  const dpadRef = React2.useRef(null);
3407
3582
  const activeTouchRef = React2.useRef(null);
3408
3583
  const activeDirectionsRef = React2.useRef(/* @__PURE__ */ new Set());
3409
- const [isDragging, setIsDragging] = React2.useState(false);
3410
- const dragTimerRef = React2.useRef(null);
3411
- const dragStartRef = React2.useRef({ x: 0, y: 0, touchX: 0, touchY: 0 });
3412
- const touchStartPosRef = React2.useRef({ x: 0, y: 0, time: 0 });
3413
3584
  const upPathRef = React2.useRef(null);
3414
3585
  const downPathRef = React2.useRef(null);
3415
3586
  const leftPathRef = React2.useRef(null);
@@ -3417,6 +3588,13 @@ var Dpad = React2__default.default.memo(function Dpad2({
3417
3588
  const centerCircleRef = React2.useRef(null);
3418
3589
  const displayX = customPosition ? customPosition.x : x;
3419
3590
  const displayY = customPosition ? customPosition.y : y;
3591
+ const releaseAllDirections = React2.useCallback((getKeyCode2) => {
3592
+ activeDirectionsRef.current.forEach((dir) => {
3593
+ const keyCode = getKeyCode2(dir);
3594
+ if (keyCode) dispatchKeyboardEvent("keyup", keyCode);
3595
+ });
3596
+ activeDirectionsRef.current = /* @__PURE__ */ new Set();
3597
+ }, []);
3420
3598
  const getKeyCode = React2.useCallback((direction) => {
3421
3599
  if (!controls) {
3422
3600
  const defaults = {
@@ -3429,6 +3607,19 @@ var Dpad = React2__default.default.memo(function Dpad2({
3429
3607
  }
3430
3608
  return controls[direction] || "";
3431
3609
  }, [controls]);
3610
+ const drag = useDrag({
3611
+ elementSize: size,
3612
+ displayX,
3613
+ displayY,
3614
+ containerWidth,
3615
+ containerHeight,
3616
+ onPositionChange,
3617
+ centerThreshold: CENTER_TOUCH_RADIUS,
3618
+ onDragStart: () => {
3619
+ releaseAllDirections(getKeyCode);
3620
+ updateVisuals(/* @__PURE__ */ new Set());
3621
+ }
3622
+ });
3432
3623
  const getDirectionsFromTouch = React2.useCallback((touchX, touchY, rect) => {
3433
3624
  const centerX = rect.left + rect.width / 2;
3434
3625
  const centerY = rect.top + rect.height / 2;
@@ -3490,51 +3681,20 @@ var Dpad = React2__default.default.memo(function Dpad2({
3490
3681
  activeDirectionsRef.current = newDirections;
3491
3682
  updateVisuals(newDirections);
3492
3683
  }, [getKeyCode, updateVisuals]);
3493
- const clearDragTimer = React2.useCallback(() => {
3494
- if (dragTimerRef.current) {
3495
- clearTimeout(dragTimerRef.current);
3496
- dragTimerRef.current = null;
3497
- }
3498
- }, []);
3499
- const startDragging = React2.useCallback((touchX, touchY) => {
3500
- setIsDragging(true);
3501
- dragStartRef.current = {
3502
- x: displayX,
3503
- y: displayY,
3504
- touchX,
3505
- touchY
3506
- };
3507
- activeDirectionsRef.current.forEach((dir) => {
3508
- const keyCode = getKeyCode(dir);
3509
- if (keyCode) dispatchKeyboardEvent("keyup", keyCode);
3510
- });
3511
- activeDirectionsRef.current = /* @__PURE__ */ new Set();
3512
- updateVisuals(/* @__PURE__ */ new Set());
3513
- if (navigator.vibrate) navigator.vibrate([10, 30, 10]);
3514
- }, [displayX, displayY, getKeyCode, updateVisuals]);
3515
3684
  const handleTouchStart = React2.useCallback((e) => {
3516
3685
  e.preventDefault();
3517
3686
  if (activeTouchRef.current !== null) return;
3518
3687
  const touch = e.changedTouches[0];
3519
3688
  activeTouchRef.current = touch.identifier;
3520
- touchStartPosRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() };
3521
3689
  const rect = dpadRef.current?.getBoundingClientRect();
3522
3690
  if (!rect) return;
3523
- const centerX = rect.left + rect.width / 2;
3524
- const centerY = rect.top + rect.height / 2;
3525
- const distFromCenter = Math.sqrt(
3526
- Math.pow(touch.clientX - centerX, 2) + Math.pow(touch.clientY - centerY, 2)
3527
- );
3528
- const centerRadius = size * CENTER_TOUCH_RADIUS;
3529
- if (distFromCenter < centerRadius && onPositionChange) {
3530
- dragTimerRef.current = setTimeout(() => {
3531
- startDragging(touch.clientX, touch.clientY);
3532
- }, DRAG_HOLD_DELAY2);
3691
+ if (onPositionChange) {
3692
+ drag.checkDragStart(touch.clientX, touch.clientY, rect);
3533
3693
  }
3534
- if (!isDragging) {
3694
+ if (!drag.isDragging) {
3535
3695
  updateDirections(getDirectionsFromTouch(touch.clientX, touch.clientY, rect));
3536
3696
  }
3537
- }, [getDirectionsFromTouch, updateDirections, isDragging, size, onPositionChange, startDragging]);
3697
+ }, [getDirectionsFromTouch, updateDirections, onPositionChange, drag]);
3538
3698
  const handleTouchMove = React2.useCallback((e) => {
3539
3699
  e.preventDefault();
3540
3700
  let touch = null;
@@ -3545,31 +3705,19 @@ var Dpad = React2__default.default.memo(function Dpad2({
3545
3705
  }
3546
3706
  }
3547
3707
  if (!touch) return;
3548
- if (isDragging && onPositionChange) {
3549
- const deltaX = touch.clientX - dragStartRef.current.touchX;
3550
- const deltaY = touch.clientY - dragStartRef.current.touchY;
3551
- const newXPercent = dragStartRef.current.x + deltaX / containerWidth * 100;
3552
- const newYPercent = dragStartRef.current.y + deltaY / containerHeight * 100;
3553
- const margin = size / 2 / Math.min(containerWidth, containerHeight) * 100;
3554
- const constrainedX = Math.max(margin, Math.min(100 - margin, newXPercent));
3555
- const constrainedY = Math.max(margin, Math.min(100 - margin, newYPercent));
3556
- onPositionChange(constrainedX, constrainedY);
3708
+ if (drag.isDragging) {
3709
+ drag.handleDragMove(touch.clientX, touch.clientY);
3557
3710
  } else {
3558
- const moveDistance = Math.sqrt(
3559
- Math.pow(touch.clientX - touchStartPosRef.current.x, 2) + Math.pow(touch.clientY - touchStartPosRef.current.y, 2)
3560
- );
3561
- if (moveDistance > 15) {
3562
- clearDragTimer();
3563
- }
3564
3711
  const rect = dpadRef.current?.getBoundingClientRect();
3565
3712
  if (rect) {
3713
+ drag.clearDragTimer();
3566
3714
  updateDirections(getDirectionsFromTouch(touch.clientX, touch.clientY, rect));
3567
3715
  }
3568
3716
  }
3569
- }, [isDragging, onPositionChange, containerWidth, containerHeight, size, getDirectionsFromTouch, updateDirections, clearDragTimer]);
3717
+ }, [drag, getDirectionsFromTouch, updateDirections]);
3570
3718
  const handleTouchEnd = React2.useCallback((e) => {
3571
3719
  e.preventDefault();
3572
- clearDragTimer();
3720
+ drag.clearDragTimer();
3573
3721
  let touchEnded = false;
3574
3722
  for (let i = 0; i < e.changedTouches.length; i++) {
3575
3723
  if (e.changedTouches[i].identifier === activeTouchRef.current) {
@@ -3579,8 +3727,8 @@ var Dpad = React2__default.default.memo(function Dpad2({
3579
3727
  }
3580
3728
  if (touchEnded) {
3581
3729
  activeTouchRef.current = null;
3582
- if (isDragging) {
3583
- setIsDragging(false);
3730
+ if (drag.isDragging) {
3731
+ drag.handleDragEnd();
3584
3732
  } else {
3585
3733
  activeDirectionsRef.current.forEach((dir) => {
3586
3734
  const keyCode = getKeyCode(dir);
@@ -3590,22 +3738,13 @@ var Dpad = React2__default.default.memo(function Dpad2({
3590
3738
  updateVisuals(/* @__PURE__ */ new Set());
3591
3739
  }
3592
3740
  }
3593
- }, [getKeyCode, updateVisuals, isDragging, clearDragTimer]);
3594
- React2.useEffect(() => {
3595
- const dpad = dpadRef.current;
3596
- if (!dpad) return;
3597
- dpad.addEventListener("touchstart", handleTouchStart, { passive: false });
3598
- dpad.addEventListener("touchmove", handleTouchMove, { passive: false });
3599
- dpad.addEventListener("touchend", handleTouchEnd, { passive: false });
3600
- dpad.addEventListener("touchcancel", handleTouchEnd, { passive: false });
3601
- return () => {
3602
- dpad.removeEventListener("touchstart", handleTouchStart);
3603
- dpad.removeEventListener("touchmove", handleTouchMove);
3604
- dpad.removeEventListener("touchend", handleTouchEnd);
3605
- dpad.removeEventListener("touchcancel", handleTouchEnd);
3606
- clearDragTimer();
3607
- };
3608
- }, [handleTouchStart, handleTouchMove, handleTouchEnd, clearDragTimer]);
3741
+ }, [getKeyCode, updateVisuals, drag]);
3742
+ useTouchEvents(dpadRef, {
3743
+ onTouchStart: handleTouchStart,
3744
+ onTouchMove: handleTouchMove,
3745
+ onTouchEnd: handleTouchEnd,
3746
+ onTouchCancel: handleTouchEnd
3747
+ }, { cleanup: drag.clearDragTimer });
3609
3748
  const leftPx = displayX / 100 * containerWidth - size / 2;
3610
3749
  const topPx = displayY / 100 * containerHeight - size / 2;
3611
3750
  const dUp = "M 35,5 L 65,5 L 65,35 L 50,50 L 35,35 Z";
@@ -3616,21 +3755,21 @@ var Dpad = React2__default.default.memo(function Dpad2({
3616
3755
  "div",
3617
3756
  {
3618
3757
  ref: dpadRef,
3619
- className: `absolute pointer-events-auto touch-manipulation select-none ${isDragging ? "opacity-60" : ""}`,
3758
+ className: `absolute pointer-events-auto touch-manipulation select-none ${drag.isDragging ? "opacity-60" : ""}`,
3620
3759
  style: {
3621
3760
  top: 0,
3622
3761
  left: 0,
3623
- transform: `translate3d(${leftPx}px, ${topPx}px, 0)${isDragging ? " scale(1.05)" : ""}`,
3762
+ transform: `translate3d(${leftPx}px, ${topPx}px, 0)${drag.isDragging ? " scale(1.05)" : ""}`,
3624
3763
  width: size,
3625
3764
  height: size,
3626
3765
  opacity: isLandscape ? 0.75 : 0.9,
3627
3766
  WebkitTouchCallout: "none",
3628
3767
  WebkitUserSelect: "none",
3629
3768
  touchAction: "none",
3630
- transition: isDragging ? "none" : "transform 0.1s ease-out"
3769
+ transition: drag.isDragging ? "none" : "transform 0.1s ease-out"
3631
3770
  },
3632
3771
  children: [
3633
- /* @__PURE__ */ jsxRuntime.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"}` }),
3772
+ /* @__PURE__ */ jsxRuntime.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"}` }),
3634
3773
  /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "100%", height: "100%", viewBox: "0 0 100 100", className: "drop-shadow-xl relative z-10", children: [
3635
3774
  /* @__PURE__ */ jsxRuntime.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" }),
3636
3775
  /* @__PURE__ */ jsxRuntime.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" }),
@@ -3643,9 +3782,9 @@ var Dpad = React2__default.default.memo(function Dpad2({
3643
3782
  cx: "50",
3644
3783
  cy: "50",
3645
3784
  r: "12",
3646
- fill: isDragging ? systemColor : "rgba(0,0,0,0.5)",
3647
- stroke: isDragging ? "#fff" : "rgba(255,255,255,0.3)",
3648
- strokeWidth: isDragging ? 2 : 1
3785
+ fill: drag.isDragging ? systemColor : "rgba(0,0,0,0.5)",
3786
+ stroke: drag.isDragging ? "#fff" : "rgba(255,255,255,0.3)",
3787
+ strokeWidth: drag.isDragging ? 2 : 1
3649
3788
  }
3650
3789
  ),
3651
3790
  /* @__PURE__ */ jsxRuntime.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" }),
@@ -3805,6 +3944,33 @@ function ControlsHint({ isVisible }) {
3805
3944
  }
3806
3945
  );
3807
3946
  }
3947
+ function LockButton({
3948
+ isLocked,
3949
+ onToggle,
3950
+ systemColor = "#00FF41"
3951
+ }) {
3952
+ const Icon = isLocked ? lucideReact.Lock : lucideReact.Unlock;
3953
+ return /* @__PURE__ */ jsxRuntime.jsx(
3954
+ "button",
3955
+ {
3956
+ onClick: onToggle,
3957
+ 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",
3958
+ style: {
3959
+ backgroundColor: isLocked ? "rgba(0,0,0,0.6)" : `${systemColor}20`,
3960
+ border: `1px solid ${isLocked ? "rgba(255,255,255,0.2)" : systemColor}`
3961
+ },
3962
+ "aria-label": isLocked ? "Unlock controls for repositioning" : "Lock controls",
3963
+ children: /* @__PURE__ */ jsxRuntime.jsx(
3964
+ Icon,
3965
+ {
3966
+ size: 18,
3967
+ style: { color: isLocked ? "rgba(255,255,255,0.6)" : systemColor }
3968
+ }
3969
+ )
3970
+ }
3971
+ );
3972
+ }
3973
+ var LOCK_KEY = "koin-controls-locked";
3808
3974
  function VirtualController({
3809
3975
  system,
3810
3976
  isRunning,
@@ -3816,8 +3982,22 @@ function VirtualController({
3816
3982
  const [pressedButtons, setPressedButtons] = React2.useState(/* @__PURE__ */ new Set());
3817
3983
  const [containerSize, setContainerSize] = React2.useState({ width: 0, height: 0 });
3818
3984
  const [isFullscreenState, setIsFullscreenState] = React2.useState(false);
3985
+ const [isLocked, setIsLocked] = React2.useState(true);
3819
3986
  const { getPosition, savePosition } = useButtonPositions();
3820
- const layout = getLayoutForSystem(system);
3987
+ React2.useEffect(() => {
3988
+ const stored = localStorage.getItem(LOCK_KEY);
3989
+ if (stored !== null) {
3990
+ setIsLocked(stored === "true");
3991
+ }
3992
+ }, []);
3993
+ const toggleLock = React2.useCallback(() => {
3994
+ setIsLocked((prev) => {
3995
+ const newValue = !prev;
3996
+ localStorage.setItem(LOCK_KEY, String(newValue));
3997
+ return newValue;
3998
+ });
3999
+ }, []);
4000
+ const layout = getLayoutForSystem(system);
3821
4001
  const visibleButtons = layout.buttons.filter((btn) => {
3822
4002
  if (isPortrait) {
3823
4003
  return btn.showInPortrait;
@@ -3971,6 +4151,14 @@ function VirtualController({
3971
4151
  className: "fixed inset-0 z-30 pointer-events-none",
3972
4152
  style: { touchAction: "none" },
3973
4153
  children: [
4154
+ /* @__PURE__ */ jsxRuntime.jsx(
4155
+ LockButton,
4156
+ {
4157
+ isLocked,
4158
+ onToggle: toggleLock,
4159
+ systemColor
4160
+ }
4161
+ ),
3974
4162
  /* @__PURE__ */ jsxRuntime.jsx(
3975
4163
  Dpad_default,
3976
4164
  {
@@ -3983,7 +4171,7 @@ function VirtualController({
3983
4171
  systemColor,
3984
4172
  isLandscape,
3985
4173
  customPosition: getPosition("up", isLandscape),
3986
- onPositionChange: (x, y) => savePosition("up", x, y, isLandscape)
4174
+ onPositionChange: isLocked ? void 0 : (x, y) => savePosition("up", x, y, isLandscape)
3987
4175
  }
3988
4176
  ),
3989
4177
  memoizedButtonElements.filter(({ buttonConfig }) => !DPAD_TYPES.includes(buttonConfig.type)).map(({ buttonConfig, adjustedConfig, customPosition, width, height }) => /* @__PURE__ */ jsxRuntime.jsx(
@@ -3997,7 +4185,7 @@ function VirtualController({
3997
4185
  containerWidth: width,
3998
4186
  containerHeight: height,
3999
4187
  customPosition,
4000
- onPositionChange: (x, y) => savePosition(buttonConfig.type, x, y, isLandscape),
4188
+ onPositionChange: isLocked ? void 0 : (x, y) => savePosition(buttonConfig.type, x, y, isLandscape),
4001
4189
  isLandscape,
4002
4190
  console: layout.console
4003
4191
  },
@@ -4073,6 +4261,45 @@ function FloatingFullscreenButton({ onClick, disabled = false }) {
4073
4261
  }
4074
4262
  );
4075
4263
  }
4264
+ function FloatingPauseButton({
4265
+ isPaused,
4266
+ onClick,
4267
+ disabled = false,
4268
+ systemColor = "#00FF41"
4269
+ }) {
4270
+ return /* @__PURE__ */ jsxRuntime.jsx(
4271
+ "button",
4272
+ {
4273
+ onClick,
4274
+ disabled,
4275
+ className: `
4276
+ fixed top-3 left-3 z-50
4277
+ px-3 py-2 rounded-xl
4278
+ bg-black/80 backdrop-blur-md
4279
+ border-2
4280
+ shadow-xl
4281
+ flex items-center gap-2
4282
+ transition-all duration-300
4283
+ hover:scale-105
4284
+ active:scale-95
4285
+ disabled:opacity-40 disabled:cursor-not-allowed
4286
+ touch-manipulation
4287
+ `,
4288
+ style: {
4289
+ paddingTop: "max(env(safe-area-inset-top, 0px), 8px)",
4290
+ borderColor: isPaused ? systemColor : "rgba(255,255,255,0.3)"
4291
+ },
4292
+ "aria-label": isPaused ? "Resume game" : "Pause game",
4293
+ children: isPaused ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
4294
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Play, { size: 16, style: { color: systemColor }, fill: systemColor }),
4295
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-white text-xs font-bold uppercase tracking-wider", children: "Play" })
4296
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
4297
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Pause, { size: 16, className: "text-white/80" }),
4298
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-white text-xs font-bold uppercase tracking-wider", children: "Pause" })
4299
+ ] })
4300
+ }
4301
+ );
4302
+ }
4076
4303
  function LoadingSpinner({ color, size = "lg" }) {
4077
4304
  const sizeClass = size === "lg" ? "w-12 h-12" : "w-8 h-8";
4078
4305
  return /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Loader2, { className: `${sizeClass} animate-spin`, style: { color } });
@@ -4278,6 +4505,45 @@ var GameCanvas = React2.memo(function GameCanvas2({
4278
4505
  ] });
4279
4506
  });
4280
4507
  var GameCanvas_default = GameCanvas;
4508
+ function useInputCapture({
4509
+ isOpen,
4510
+ onClose
4511
+ }) {
4512
+ const [listeningFor, setListeningFor] = React2.useState(null);
4513
+ const startListening = React2.useCallback((target) => {
4514
+ setListeningFor(target);
4515
+ }, []);
4516
+ const stopListening = React2.useCallback(() => {
4517
+ setListeningFor(null);
4518
+ }, []);
4519
+ React2.useEffect(() => {
4520
+ if (!isOpen) {
4521
+ setListeningFor(null);
4522
+ }
4523
+ }, [isOpen]);
4524
+ React2.useEffect(() => {
4525
+ if (!isOpen) return;
4526
+ const handleKeyDown = (e) => {
4527
+ if (e.code === "Escape") {
4528
+ if (listeningFor !== null) {
4529
+ e.preventDefault();
4530
+ e.stopPropagation();
4531
+ setListeningFor(null);
4532
+ } else {
4533
+ onClose();
4534
+ }
4535
+ }
4536
+ };
4537
+ window.addEventListener("keydown", handleKeyDown);
4538
+ return () => window.removeEventListener("keydown", handleKeyDown);
4539
+ }, [isOpen, listeningFor, onClose]);
4540
+ return {
4541
+ listeningFor,
4542
+ startListening,
4543
+ stopListening,
4544
+ isListening: listeningFor !== null
4545
+ };
4546
+ }
4281
4547
  function getFilteredGroups(activeButtons) {
4282
4548
  return BUTTON_GROUPS.map((group) => ({
4283
4549
  ...group,
@@ -4293,7 +4559,10 @@ function ControlMapper({
4293
4559
  }) {
4294
4560
  const t = useKoinTranslation();
4295
4561
  const [localControls, setLocalControls] = React2.useState(controls);
4296
- const [listeningFor, setListeningFor] = React2.useState(null);
4562
+ const { listeningFor, startListening, stopListening, isListening } = useInputCapture({
4563
+ isOpen,
4564
+ onClose
4565
+ });
4297
4566
  const activeButtons = React2.useMemo(() => {
4298
4567
  return getConsoleButtons(system || "SNES");
4299
4568
  }, [system]);
@@ -4309,27 +4578,20 @@ function ControlMapper({
4309
4578
  }
4310
4579
  }, [isOpen, controls]);
4311
4580
  React2.useEffect(() => {
4312
- if (!isOpen) {
4313
- setListeningFor(null);
4314
- return;
4315
- }
4581
+ if (!isOpen || !listeningFor) return;
4316
4582
  const handleKeyDown = (e) => {
4317
- if (!listeningFor) return;
4583
+ if (e.code === "Escape") return;
4318
4584
  e.preventDefault();
4319
4585
  e.stopPropagation();
4320
- if (e.code === "Escape") {
4321
- setListeningFor(null);
4322
- return;
4323
- }
4324
4586
  setLocalControls((prev) => ({
4325
4587
  ...prev,
4326
4588
  [listeningFor]: e.code
4327
4589
  }));
4328
- setListeningFor(null);
4590
+ stopListening();
4329
4591
  };
4330
4592
  window.addEventListener("keydown", handleKeyDown);
4331
4593
  return () => window.removeEventListener("keydown", handleKeyDown);
4332
- }, [isOpen, listeningFor]);
4594
+ }, [isOpen, listeningFor, stopListening]);
4333
4595
  const handleReset = () => {
4334
4596
  setLocalControls(defaultControls);
4335
4597
  };
@@ -4337,52 +4599,58 @@ function ControlMapper({
4337
4599
  onSave(localControls);
4338
4600
  onClose();
4339
4601
  };
4340
- if (!isOpen) return null;
4341
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center", children: [
4342
- /* @__PURE__ */ jsxRuntime.jsx(
4343
- "div",
4344
- {
4345
- className: "absolute inset-0 bg-black/80 backdrop-blur-sm",
4346
- onClick: onClose
4347
- }
4348
- ),
4349
- /* @__PURE__ */ jsxRuntime.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: [
4350
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between px-6 py-4 border-b border-white/10 bg-black/50", children: [
4351
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3", children: [
4352
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Gamepad2, { className: "text-retro-primary", size: 24 }),
4353
- /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
4354
- /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "text-lg font-bold text-white", children: t.modals.controls.title }),
4355
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-gray-400", children: t.modals.controls.description })
4356
- ] })
4357
- ] }),
4358
- /* @__PURE__ */ jsxRuntime.jsx(
4602
+ return /* @__PURE__ */ jsxRuntime.jsx(
4603
+ ModalShell,
4604
+ {
4605
+ isOpen,
4606
+ onClose,
4607
+ title: t.modals.controls.title,
4608
+ subtitle: t.modals.controls.description,
4609
+ icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Gamepad2, { className: "text-retro-primary", size: 24 }),
4610
+ closeOnBackdrop: !isListening,
4611
+ footer: /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
4612
+ /* @__PURE__ */ jsxRuntime.jsxs(
4613
+ "button",
4614
+ {
4615
+ onClick: handleReset,
4616
+ 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",
4617
+ children: [
4618
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.RotateCcw, { size: 16 }),
4619
+ t.modals.controls.reset
4620
+ ]
4621
+ }
4622
+ ),
4623
+ /* @__PURE__ */ jsxRuntime.jsxs(
4359
4624
  "button",
4360
4625
  {
4361
- onClick: onClose,
4362
- className: "p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-colors",
4363
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { size: 20 })
4626
+ onClick: handleSave,
4627
+ 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",
4628
+ children: [
4629
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Check, { size: 16 }),
4630
+ t.modals.controls.save
4631
+ ]
4364
4632
  }
4365
4633
  )
4366
4634
  ] }),
4367
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-4 space-y-6 max-h-[400px] overflow-y-auto", children: controlGroups.map((group) => /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
4635
+ children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-4 space-y-6 max-h-[400px] overflow-y-auto", children: controlGroups.map((group) => /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
4368
4636
  /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "text-xs font-bold text-gray-500 uppercase tracking-wider mb-2", children: group.label }),
4369
4637
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-2 gap-2", children: group.buttons.map((btn) => /* @__PURE__ */ jsxRuntime.jsxs(
4370
4638
  "button",
4371
4639
  {
4372
- onClick: () => setListeningFor(btn),
4640
+ onClick: () => startListening(btn),
4373
4641
  className: `
4374
- flex items-center justify-between px-4 py-3 rounded-lg border transition-all
4375
- ${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"}
4376
- `,
4642
+ flex items-center justify-between px-4 py-3 rounded-lg border transition-all
4643
+ ${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"}
4644
+ `,
4377
4645
  children: [
4378
4646
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm text-gray-300", children: BUTTON_LABELS[btn] }),
4379
4647
  /* @__PURE__ */ jsxRuntime.jsx(
4380
4648
  "span",
4381
4649
  {
4382
4650
  className: `
4383
- px-2 py-1 rounded text-xs font-mono
4384
- ${listeningFor === btn ? "bg-retro-primary/30 text-retro-primary animate-pulse" : "bg-black/50 text-white"}
4385
- `,
4651
+ px-2 py-1 rounded text-xs font-mono
4652
+ ${listeningFor === btn ? "bg-retro-primary/30 text-retro-primary animate-pulse" : "bg-black/50 text-white"}
4653
+ `,
4386
4654
  children: listeningFor === btn ? t.modals.controls.pressKey : formatKeyCode(localControls[btn] || "")
4387
4655
  }
4388
4656
  )
@@ -4390,199 +4658,10 @@ function ControlMapper({
4390
4658
  },
4391
4659
  btn
4392
4660
  )) })
4393
- ] }, group.label)) }),
4394
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between px-6 py-4 bg-black/30 border-t border-white/10", children: [
4395
- /* @__PURE__ */ jsxRuntime.jsxs(
4396
- "button",
4397
- {
4398
- onClick: handleReset,
4399
- 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",
4400
- children: [
4401
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.RotateCcw, { size: 16 }),
4402
- t.modals.controls.reset
4403
- ]
4404
- }
4405
- ),
4406
- /* @__PURE__ */ jsxRuntime.jsxs(
4407
- "button",
4408
- {
4409
- onClick: handleSave,
4410
- 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",
4411
- children: [
4412
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Check, { size: 16 }),
4413
- t.modals.controls.save
4414
- ]
4415
- }
4416
- )
4417
- ] })
4418
- ] })
4419
- ] });
4420
- }
4421
- function getDisplayName(id) {
4422
- let name = id;
4423
- name = name.replace(/^[0-9a-f]{4}-[0-9a-f]{4}-/i, "");
4424
- name = name.replace(/\s*\(.*\)\s*$/i, "");
4425
- name = name.replace(/\s*STANDARD GAMEPAD\s*/i, "");
4426
- if (/xbox/i.test(name)) {
4427
- if (/series/i.test(name)) return "Xbox Series Controller";
4428
- if (/one/i.test(name)) return "Xbox One Controller";
4429
- if (/360/i.test(name)) return "Xbox 360 Controller";
4430
- return "Xbox Controller";
4431
- }
4432
- if (/dualsense/i.test(name)) return "DualSense";
4433
- if (/dualshock\s*4/i.test(name)) return "DualShock 4";
4434
- if (/dualshock/i.test(name)) return "DualShock";
4435
- if (/playstation/i.test(name) || /sony/i.test(name)) return "PlayStation Controller";
4436
- if (/pro\s*controller/i.test(name)) return "Switch Pro Controller";
4437
- if (/joy-?con/i.test(name)) return "Joy-Con";
4438
- if (/nintendo/i.test(name)) return "Nintendo Controller";
4439
- return name.trim() || "Gamepad";
4440
- }
4441
- function detectControllerBrand(id) {
4442
- const lowerId = id.toLowerCase();
4443
- if (/xbox|xinput|microsoft/i.test(lowerId)) return "xbox";
4444
- if (/playstation|sony|dualshock|dualsense/i.test(lowerId)) return "playstation";
4445
- if (/nintendo|switch|joy-?con|pro controller/i.test(lowerId)) return "nintendo";
4446
- return "generic";
4447
- }
4448
- function toGamepadInfo(gamepad) {
4449
- return {
4450
- index: gamepad.index,
4451
- id: gamepad.id,
4452
- name: getDisplayName(gamepad.id),
4453
- connected: gamepad.connected,
4454
- buttons: gamepad.buttons.length,
4455
- axes: gamepad.axes.length,
4456
- mapping: gamepad.mapping
4457
- };
4458
- }
4459
- function useGamepad(options) {
4460
- const { onConnect, onDisconnect } = options || {};
4461
- const [gamepads, setGamepads] = React2.useState([]);
4462
- const rafRef = React2.useRef(null);
4463
- const lastStateRef = React2.useRef("");
4464
- const prevCountRef = React2.useRef(0);
4465
- const onConnectRef = React2.useRef(onConnect);
4466
- const onDisconnectRef = React2.useRef(onDisconnect);
4467
- React2.useEffect(() => {
4468
- onConnectRef.current = onConnect;
4469
- onDisconnectRef.current = onDisconnect;
4470
- }, [onConnect, onDisconnect]);
4471
- const getGamepads = React2.useCallback(() => {
4472
- if (typeof navigator === "undefined" || typeof navigator.getGamepads !== "function") {
4473
- return [];
4474
- }
4475
- const rawGamepads = navigator.getGamepads() ?? [];
4476
- const connected = [];
4477
- for (let i = 0; i < rawGamepads.length; i++) {
4478
- const gp = rawGamepads[i];
4479
- if (gp && gp.connected) {
4480
- connected.push(toGamepadInfo(gp));
4481
- }
4482
- }
4483
- return connected;
4484
- }, []);
4485
- const getRawGamepad = React2.useCallback((index) => {
4486
- const rawGamepads = navigator.getGamepads?.() ?? [];
4487
- return rawGamepads[index] ?? null;
4488
- }, []);
4489
- const refresh = React2.useCallback(() => {
4490
- setGamepads(getGamepads());
4491
- }, [getGamepads]);
4492
- React2.useEffect(() => {
4493
- if (typeof window === "undefined" || typeof navigator === "undefined") {
4494
- return;
4495
- }
4496
- if (typeof navigator.getGamepads !== "function") {
4497
- console.warn("[useGamepad] Gamepad API not supported in this browser");
4498
- return;
4661
+ ] }, group.label)) })
4499
4662
  }
4500
- let isActive = true;
4501
- const poll = () => {
4502
- if (!isActive) return;
4503
- const current = getGamepads();
4504
- let hasChanged = current.length !== prevCountRef.current;
4505
- if (!hasChanged) {
4506
- for (let i = 0; i < current.length; i++) {
4507
- const saved = gamepads[i];
4508
- if (!saved || saved.id !== current[i].id || saved.connected !== current[i].connected) {
4509
- hasChanged = true;
4510
- break;
4511
- }
4512
- }
4513
- }
4514
- if (hasChanged) {
4515
- const prevCount = prevCountRef.current;
4516
- const currentCount = current.length;
4517
- if (currentCount > prevCount && prevCount >= 0 && onConnectRef.current) {
4518
- const newGamepad = current[current.length - 1];
4519
- onConnectRef.current(newGamepad);
4520
- } else if (currentCount < prevCount && prevCount > 0 && onDisconnectRef.current) {
4521
- onDisconnectRef.current();
4522
- }
4523
- prevCountRef.current = currentCount;
4524
- setGamepads(current);
4525
- }
4526
- rafRef.current = requestAnimationFrame(poll);
4527
- };
4528
- const handleConnect = (e) => {
4529
- console.log("[useGamepad] \u{1F3AE} Gamepad connected:", e.gamepad.id);
4530
- const current = getGamepads();
4531
- const prevCount = prevCountRef.current;
4532
- prevCountRef.current = current.length;
4533
- lastStateRef.current = current.map((g) => `${g.index}:${g.id}`).join("|");
4534
- setGamepads(current);
4535
- if (onConnectRef.current && current.length > prevCount) {
4536
- const newGamepad = current[current.length - 1];
4537
- onConnectRef.current(newGamepad);
4538
- }
4539
- };
4540
- const handleDisconnect = (e) => {
4541
- console.log("[useGamepad] \u{1F3AE} Gamepad disconnected:", e.gamepad.id);
4542
- const current = getGamepads();
4543
- const prevCount = prevCountRef.current;
4544
- prevCountRef.current = current.length;
4545
- lastStateRef.current = current.map((g) => `${g.index}:${g.id}`).join("|");
4546
- setGamepads(current);
4547
- if (onDisconnectRef.current && current.length < prevCount) {
4548
- onDisconnectRef.current();
4549
- }
4550
- };
4551
- window.addEventListener("gamepadconnected", handleConnect);
4552
- window.addEventListener("gamepaddisconnected", handleDisconnect);
4553
- rafRef.current = requestAnimationFrame(poll);
4554
- const initial = getGamepads();
4555
- if (initial.length > 0) {
4556
- console.log("[useGamepad] Initial gamepads found:", initial.map((g) => g.name).join(", "));
4557
- prevCountRef.current = initial.length;
4558
- setGamepads(initial);
4559
- lastStateRef.current = initial.map((g) => `${g.index}:${g.id}`).join("|");
4560
- } else {
4561
- prevCountRef.current = 0;
4562
- }
4563
- return () => {
4564
- isActive = false;
4565
- if (rafRef.current) {
4566
- cancelAnimationFrame(rafRef.current);
4567
- }
4568
- window.removeEventListener("gamepadconnected", handleConnect);
4569
- window.removeEventListener("gamepaddisconnected", handleDisconnect);
4570
- };
4571
- }, [getGamepads]);
4572
- return {
4573
- gamepads,
4574
- isAnyConnected: gamepads.length > 0,
4575
- connectedCount: gamepads.length,
4576
- getRawGamepad,
4577
- refresh
4578
- };
4663
+ );
4579
4664
  }
4580
- var STANDARD_AXIS_MAP = {
4581
- leftStickX: 0,
4582
- leftStickY: 1,
4583
- rightStickX: 2,
4584
- rightStickY: 3
4585
- };
4586
4665
  function GamepadMapper({
4587
4666
  isOpen,
4588
4667
  gamepads,
@@ -4593,7 +4672,10 @@ function GamepadMapper({
4593
4672
  const t = useKoinTranslation();
4594
4673
  const [selectedPlayer, setSelectedPlayer] = React2.useState(1);
4595
4674
  const [bindings, setBindings] = React2.useState({});
4596
- const [listeningFor, setListeningFor] = React2.useState(null);
4675
+ const { listeningFor, startListening, stopListening, isListening } = useInputCapture({
4676
+ isOpen,
4677
+ onClose
4678
+ });
4597
4679
  const rafRef = React2.useRef(null);
4598
4680
  React2.useEffect(() => {
4599
4681
  if (isOpen) {
@@ -4628,7 +4710,7 @@ function GamepadMapper({
4628
4710
  [listeningFor]: i
4629
4711
  }
4630
4712
  }));
4631
- setListeningFor(null);
4713
+ stopListening();
4632
4714
  return;
4633
4715
  }
4634
4716
  }
@@ -4641,21 +4723,7 @@ function GamepadMapper({
4641
4723
  cancelAnimationFrame(rafRef.current);
4642
4724
  }
4643
4725
  };
4644
- }, [isOpen, listeningFor, selectedPlayer]);
4645
- React2.useEffect(() => {
4646
- if (!isOpen) return;
4647
- const handleKeyDown = (e) => {
4648
- if (e.code === "Escape") {
4649
- if (listeningFor) {
4650
- setListeningFor(null);
4651
- } else {
4652
- onClose();
4653
- }
4654
- }
4655
- };
4656
- window.addEventListener("keydown", handleKeyDown);
4657
- return () => window.removeEventListener("keydown", handleKeyDown);
4658
- }, [isOpen, listeningFor, onClose]);
4726
+ }, [isOpen, listeningFor, selectedPlayer, stopListening]);
4659
4727
  const handleReset = () => {
4660
4728
  setBindings((prev) => ({
4661
4729
  ...prev,
@@ -4670,127 +4738,19 @@ function GamepadMapper({
4670
4738
  onSave?.(bindings[selectedPlayer], selectedPlayer);
4671
4739
  onClose();
4672
4740
  };
4673
- if (!isOpen) return null;
4674
4741
  const currentBindings = bindings[selectedPlayer] ?? DEFAULT_GAMEPAD;
4675
4742
  const currentGamepad = gamepads.find((g) => g.index === selectedPlayer - 1);
4676
- currentGamepad ? detectControllerBrand(currentGamepad.id) : "generic";
4677
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center", children: [
4678
- /* @__PURE__ */ jsxRuntime.jsx(
4679
- "div",
4680
- {
4681
- className: "absolute inset-0 bg-black/80 backdrop-blur-sm",
4682
- onClick: () => !listeningFor && onClose()
4683
- }
4684
- ),
4685
- /* @__PURE__ */ jsxRuntime.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: [
4686
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between px-6 py-4 border-b border-white/10 bg-black/50", children: [
4687
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3", children: [
4688
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Joystick, { className: "text-retro-primary", size: 24, style: { color: systemColor } }),
4689
- /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
4690
- /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "text-lg font-bold text-white", children: t.modals.gamepad.title }),
4691
- /* @__PURE__ */ jsxRuntime.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 })
4692
- ] })
4693
- ] }),
4694
- /* @__PURE__ */ jsxRuntime.jsx(
4695
- "button",
4696
- {
4697
- onClick: onClose,
4698
- className: "p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-colors",
4699
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { size: 20 })
4700
- }
4701
- )
4702
- ] }),
4703
- gamepads.length > 1 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-6 py-3 border-b border-white/10 bg-black/30", children: [
4704
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
4705
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-gray-400 font-medium", children: t.modals.gamepad.player }),
4706
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex gap-1", children: gamepads.map((gp) => /* @__PURE__ */ jsxRuntime.jsxs(
4707
- "button",
4708
- {
4709
- onClick: () => setSelectedPlayer(gp.index + 1),
4710
- className: `
4711
- flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all
4712
- ${selectedPlayer === gp.index + 1 ? "bg-retro-primary/20 text-retro-primary" : "bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white"}
4713
- `,
4714
- style: selectedPlayer === gp.index + 1 ? {
4715
- backgroundColor: `${systemColor}20`,
4716
- color: systemColor
4717
- } : {},
4718
- children: [
4719
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.User, { size: 14 }),
4720
- "P",
4721
- gp.index + 1
4722
- ]
4723
- },
4724
- gp.index
4725
- )) })
4726
- ] }),
4727
- currentGamepad && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-gray-500 mt-1", children: currentGamepad.name })
4728
- ] }),
4729
- gamepads.length === 1 && currentGamepad && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-2 border-b border-white/10 bg-black/30", children: /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "text-xs text-gray-400", children: [
4730
- currentGamepad.name,
4731
- " \u2022 Player 1"
4732
- ] }) }),
4733
- gamepads.length === 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-6 py-10 text-center", children: [
4734
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative inline-block mb-4", children: [
4735
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Joystick, { size: 56, className: "text-gray-600 animate-pulse" }),
4736
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "absolute inset-0 -m-2 rounded-full border-2 border-dashed border-gray-600 animate-spin", style: { animationDuration: "8s" } })
4737
- ] }),
4738
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-gray-300 font-medium mb-2", children: t.modals.gamepad.noController }),
4739
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-gray-500 mb-4", children: t.modals.gamepad.pressAny }),
4740
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-white/5 border border-white/10", children: [
4741
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-gray-400", children: t.modals.gamepad.waiting }),
4742
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-1", children: [
4743
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-1.5 h-1.5 rounded-full bg-gray-500 animate-bounce", style: { animationDelay: "0ms" } }),
4744
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-1.5 h-1.5 rounded-full bg-gray-500 animate-bounce", style: { animationDelay: "150ms" } }),
4745
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-1.5 h-1.5 rounded-full bg-gray-500 animate-bounce", style: { animationDelay: "300ms" } })
4746
- ] })
4747
- ] })
4748
- ] }),
4749
- gamepads.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "p-4 space-y-6 max-h-[400px] overflow-y-auto", children: [
4750
- listeningFor && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "p-4 rounded-lg bg-black/50 border border-retro-primary/50 text-center animate-pulse", style: { borderColor: `${systemColor}50` }, children: [
4751
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-white mb-1", children: t.modals.gamepad.pressButton.replace("{{button}}", BUTTON_LABELS[listeningFor]) }),
4752
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-gray-400", children: t.modals.gamepad.pressEsc })
4753
- ] }),
4754
- BUTTON_GROUPS.map((group) => /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
4755
- /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "text-xs font-bold text-gray-500 uppercase tracking-wider mb-2", children: group.label }),
4756
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-2 gap-2", children: group.buttons.map((btn) => /* @__PURE__ */ jsxRuntime.jsxs(
4757
- "button",
4758
- {
4759
- onClick: () => setListeningFor(btn),
4760
- disabled: !!listeningFor && listeningFor !== btn,
4761
- className: `
4762
- flex items-center justify-between px-4 py-3 rounded-lg border transition-all
4763
- disabled:opacity-50
4764
- ${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"}
4765
- `,
4766
- style: listeningFor === btn ? {
4767
- borderColor: systemColor,
4768
- backgroundColor: `${systemColor}20`,
4769
- boxShadow: `0 0 0 2px ${systemColor}50`
4770
- } : {},
4771
- children: [
4772
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm text-gray-300", children: BUTTON_LABELS[btn] }),
4773
- /* @__PURE__ */ jsxRuntime.jsx(
4774
- "span",
4775
- {
4776
- className: `
4777
- px-2 py-1 rounded text-xs font-mono
4778
- ${listeningFor === btn ? "bg-retro-primary/30 text-retro-primary animate-pulse" : "bg-black/50 text-white"}
4779
- `,
4780
- style: listeningFor === btn ? {
4781
- backgroundColor: `${systemColor}30`,
4782
- color: systemColor
4783
- } : {},
4784
- children: listeningFor === btn ? t.controls.press : formatGamepadButton(currentBindings[btn])
4785
- }
4786
- )
4787
- ]
4788
- },
4789
- btn
4790
- )) })
4791
- ] }, group.label))
4792
- ] }),
4793
- gamepads.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between px-6 py-4 bg-black/30 border-t border-white/10", children: [
4743
+ return /* @__PURE__ */ jsxRuntime.jsxs(
4744
+ ModalShell,
4745
+ {
4746
+ isOpen,
4747
+ onClose,
4748
+ title: t.modals.gamepad.title,
4749
+ subtitle: gamepads.length > 0 ? t.modals.gamepad.connected.replace("{{count}}", gamepads.length.toString()) : t.modals.gamepad.none,
4750
+ icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Joystick, { size: 24, style: { color: systemColor } }),
4751
+ systemColor,
4752
+ closeOnBackdrop: !isListening,
4753
+ footer: gamepads.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
4794
4754
  /* @__PURE__ */ jsxRuntime.jsxs(
4795
4755
  "button",
4796
4756
  {
@@ -4816,9 +4776,101 @@ function GamepadMapper({
4816
4776
  ]
4817
4777
  }
4818
4778
  )
4819
- ] })
4820
- ] })
4821
- ] });
4779
+ ] }) : void 0,
4780
+ children: [
4781
+ gamepads.length > 1 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-6 py-3 border-b border-white/10 bg-black/30", children: [
4782
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
4783
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-gray-400 font-medium", children: t.modals.gamepad.player }),
4784
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex gap-1", children: gamepads.map((gp) => /* @__PURE__ */ jsxRuntime.jsxs(
4785
+ "button",
4786
+ {
4787
+ onClick: () => setSelectedPlayer(gp.index + 1),
4788
+ className: `
4789
+ flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all
4790
+ ${selectedPlayer === gp.index + 1 ? "bg-retro-primary/20 text-retro-primary" : "bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white"}
4791
+ `,
4792
+ style: selectedPlayer === gp.index + 1 ? {
4793
+ backgroundColor: `${systemColor}20`,
4794
+ color: systemColor
4795
+ } : {},
4796
+ children: [
4797
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.User, { size: 14 }),
4798
+ "P",
4799
+ gp.index + 1
4800
+ ]
4801
+ },
4802
+ gp.index
4803
+ )) })
4804
+ ] }),
4805
+ currentGamepad && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-gray-500 mt-1", children: currentGamepad.name })
4806
+ ] }),
4807
+ gamepads.length === 1 && currentGamepad && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-2 border-b border-white/10 bg-black/30", children: /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "text-xs text-gray-400", children: [
4808
+ currentGamepad.name,
4809
+ " \u2022 Player 1"
4810
+ ] }) }),
4811
+ gamepads.length === 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-6 py-10 text-center", children: [
4812
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative inline-block mb-4", children: [
4813
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Joystick, { size: 56, className: "text-gray-600 animate-pulse" }),
4814
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "absolute inset-0 -m-2 rounded-full border-2 border-dashed border-gray-600 animate-spin", style: { animationDuration: "8s" } })
4815
+ ] }),
4816
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-gray-300 font-medium mb-2", children: t.modals.gamepad.noController }),
4817
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-gray-500 mb-4", children: t.modals.gamepad.pressAny }),
4818
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-white/5 border border-white/10", children: [
4819
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-gray-400", children: t.modals.gamepad.waiting }),
4820
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-1", children: [
4821
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-1.5 h-1.5 rounded-full bg-gray-500 animate-bounce", style: { animationDelay: "0ms" } }),
4822
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-1.5 h-1.5 rounded-full bg-gray-500 animate-bounce", style: { animationDelay: "150ms" } }),
4823
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-1.5 h-1.5 rounded-full bg-gray-500 animate-bounce", style: { animationDelay: "300ms" } })
4824
+ ] })
4825
+ ] })
4826
+ ] }),
4827
+ gamepads.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "p-4 space-y-6 max-h-[400px] overflow-y-auto", children: [
4828
+ listeningFor && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "p-4 rounded-lg bg-black/50 border border-retro-primary/50 text-center animate-pulse", style: { borderColor: `${systemColor}50` }, children: [
4829
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-white mb-1", children: t.modals.gamepad.pressButton.replace("{{button}}", BUTTON_LABELS[listeningFor]) }),
4830
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-gray-400", children: t.modals.gamepad.pressEsc })
4831
+ ] }),
4832
+ BUTTON_GROUPS.map((group) => /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
4833
+ /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "text-xs font-bold text-gray-500 uppercase tracking-wider mb-2", children: group.label }),
4834
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-2 gap-2", children: group.buttons.map((btn) => /* @__PURE__ */ jsxRuntime.jsxs(
4835
+ "button",
4836
+ {
4837
+ onClick: () => startListening(btn),
4838
+ disabled: !!listeningFor && listeningFor !== btn,
4839
+ className: `
4840
+ flex items-center justify-between px-4 py-3 rounded-lg border transition-all
4841
+ disabled:opacity-50
4842
+ ${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"}
4843
+ `,
4844
+ style: listeningFor === btn ? {
4845
+ borderColor: systemColor,
4846
+ backgroundColor: `${systemColor}20`,
4847
+ boxShadow: `0 0 0 2px ${systemColor}50`
4848
+ } : {},
4849
+ children: [
4850
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm text-gray-300", children: BUTTON_LABELS[btn] }),
4851
+ /* @__PURE__ */ jsxRuntime.jsx(
4852
+ "span",
4853
+ {
4854
+ className: `
4855
+ px-2 py-1 rounded text-xs font-mono
4856
+ ${listeningFor === btn ? "bg-retro-primary/30 text-retro-primary animate-pulse" : "bg-black/50 text-white"}
4857
+ `,
4858
+ style: listeningFor === btn ? {
4859
+ backgroundColor: `${systemColor}30`,
4860
+ color: systemColor
4861
+ } : {},
4862
+ children: listeningFor === btn ? t.controls.press : formatGamepadButton(currentBindings[btn])
4863
+ }
4864
+ )
4865
+ ]
4866
+ },
4867
+ btn
4868
+ )) })
4869
+ ] }, group.label))
4870
+ ] })
4871
+ ]
4872
+ }
4873
+ );
4822
4874
  }
4823
4875
  function CheatModal({
4824
4876
  isOpen,
@@ -4829,39 +4881,21 @@ function CheatModal({
4829
4881
  }) {
4830
4882
  const t = useKoinTranslation();
4831
4883
  const [copiedId, setCopiedId] = React2__default.default.useState(null);
4832
- if (!isOpen) return null;
4833
4884
  const handleCopy = async (code, id) => {
4834
4885
  await navigator.clipboard.writeText(code);
4835
- setCopiedId(id);
4836
- setTimeout(() => setCopiedId(null), 2e3);
4837
- };
4838
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center", children: [
4839
- /* @__PURE__ */ jsxRuntime.jsx(
4840
- "div",
4841
- {
4842
- className: "absolute inset-0 bg-black/80 backdrop-blur-sm",
4843
- onClick: onClose
4844
- }
4845
- ),
4846
- /* @__PURE__ */ jsxRuntime.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: [
4847
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between px-6 py-4 border-b border-white/10 bg-black/50", children: [
4848
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3", children: [
4849
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Code, { className: "text-purple-400", size: 24 }),
4850
- /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
4851
- /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "text-lg font-bold text-white", children: t.modals.cheats.title }),
4852
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-gray-400", children: t.modals.cheats.available.replace("{{count}}", cheats.length.toString()) })
4853
- ] })
4854
- ] }),
4855
- /* @__PURE__ */ jsxRuntime.jsx(
4856
- "button",
4857
- {
4858
- onClick: onClose,
4859
- className: "p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-colors",
4860
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { size: 20 })
4861
- }
4862
- )
4863
- ] }),
4864
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-4 space-y-2 max-h-[400px] overflow-y-auto", children: cheats.length === 0 ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "text-center py-12 text-gray-500", children: [
4886
+ setCopiedId(id);
4887
+ setTimeout(() => setCopiedId(null), 2e3);
4888
+ };
4889
+ return /* @__PURE__ */ jsxRuntime.jsx(
4890
+ ModalShell,
4891
+ {
4892
+ isOpen,
4893
+ onClose,
4894
+ title: t.modals.cheats.title,
4895
+ subtitle: t.modals.cheats.available.replace("{{count}}", cheats.length.toString()),
4896
+ icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Code, { size: 24, className: "text-purple-400" }),
4897
+ footer: /* @__PURE__ */ jsxRuntime.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 }),
4898
+ children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-4 space-y-2 max-h-[400px] overflow-y-auto", children: cheats.length === 0 ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "text-center py-12 text-gray-500", children: [
4865
4899
  /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Code, { size: 48, className: "mx-auto mb-3 opacity-50" }),
4866
4900
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "font-medium", children: t.modals.cheats.emptyTitle }),
4867
4901
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm mt-1", children: t.modals.cheats.emptyDesc })
@@ -4871,18 +4905,18 @@ function CheatModal({
4871
4905
  "div",
4872
4906
  {
4873
4907
  className: `
4874
- group flex items-start gap-4 p-4 rounded-lg border transition-all cursor-pointer
4875
- ${isActive ? "border-purple-500/50 bg-purple-500/10" : "border-white/10 bg-white/5 hover:border-white/20 hover:bg-white/10"}
4876
- `,
4908
+ group flex items-start gap-4 p-4 rounded-lg border transition-all cursor-pointer
4909
+ ${isActive ? "border-purple-500/50 bg-purple-500/10" : "border-white/10 bg-white/5 hover:border-white/20 hover:bg-white/10"}
4910
+ `,
4877
4911
  onClick: () => onToggle(cheat.id),
4878
4912
  children: [
4879
4913
  /* @__PURE__ */ jsxRuntime.jsx(
4880
4914
  "div",
4881
4915
  {
4882
4916
  className: `
4883
- flex-shrink-0 w-6 h-6 rounded border-2 flex items-center justify-center transition-all
4884
- ${isActive ? "border-purple-500 bg-purple-500" : "border-gray-600 group-hover:border-gray-400"}
4885
- `,
4917
+ flex-shrink-0 w-6 h-6 rounded border-2 flex items-center justify-center transition-all
4918
+ ${isActive ? "border-purple-500 bg-purple-500" : "border-gray-600 group-hover:border-gray-400"}
4919
+ `,
4886
4920
  children: isActive && /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Check, { size: 14, className: "text-white" })
4887
4921
  }
4888
4922
  ),
@@ -4908,10 +4942,9 @@ function CheatModal({
4908
4942
  },
4909
4943
  cheat.id
4910
4944
  );
4911
- }) }),
4912
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-3 bg-black/30 border-t border-white/10", children: /* @__PURE__ */ jsxRuntime.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 }) })
4913
- ] })
4914
- ] });
4945
+ }) })
4946
+ }
4947
+ );
4915
4948
  }
4916
4949
  var AUTO_SAVE_SLOT = 5;
4917
4950
  function formatBytes(bytes) {
@@ -4952,7 +4985,6 @@ function SaveSlotModal({
4952
4985
  onUpgrade
4953
4986
  }) {
4954
4987
  const t = useKoinTranslation();
4955
- if (!isOpen) return null;
4956
4988
  const isSaveMode = mode === "save";
4957
4989
  const allSlots = [1, 2, 3, 4, 5];
4958
4990
  const isUnlimited = maxSlots === -1 || maxSlots >= 5;
@@ -4967,33 +4999,17 @@ function SaveSlotModal({
4967
4999
  const getSlotData = (slotNum) => {
4968
5000
  return slots.find((s) => s.slot === slotNum);
4969
5001
  };
4970
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center", children: [
4971
- /* @__PURE__ */ jsxRuntime.jsx(
4972
- "div",
4973
- {
4974
- className: "absolute inset-0 bg-black/80 backdrop-blur-sm",
4975
- onClick: onClose
4976
- }
4977
- ),
4978
- /* @__PURE__ */ jsxRuntime.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: [
4979
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between px-6 py-4 border-b border-white/10 bg-black/50", children: [
4980
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3", children: [
4981
- isSaveMode ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Save, { className: "text-retro-primary", size: 24 }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Download, { className: "text-retro-primary", size: 24 }),
4982
- /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
4983
- /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "text-lg font-bold text-white", children: isSaveMode ? t.modals.saveSlots.saveTitle : t.modals.saveSlots.loadTitle }),
4984
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-gray-400", children: isSaveMode ? t.modals.saveSlots.subtitleSave : t.modals.saveSlots.subtitleLoad })
4985
- ] })
4986
- ] }),
4987
- /* @__PURE__ */ jsxRuntime.jsx(
4988
- "button",
4989
- {
4990
- onClick: onClose,
4991
- className: "p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-colors",
4992
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { size: 20 })
4993
- }
4994
- )
4995
- ] }),
4996
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-4 space-y-2 max-h-[400px] overflow-y-auto", children: isLoading ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col items-center justify-center py-12 text-gray-400", children: [
5002
+ return /* @__PURE__ */ jsxRuntime.jsx(
5003
+ ModalShell,
5004
+ {
5005
+ isOpen,
5006
+ onClose,
5007
+ title: isSaveMode ? t.modals.saveSlots.saveTitle : t.modals.saveSlots.loadTitle,
5008
+ subtitle: isSaveMode ? t.modals.saveSlots.subtitleSave : t.modals.saveSlots.subtitleLoad,
5009
+ icon: isSaveMode ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Save, { className: "text-retro-primary", size: 24 }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Download, { className: "text-retro-primary", size: 24 }),
5010
+ maxWidth: "md",
5011
+ footer: /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-gray-500 text-center w-full", children: isSaveMode ? t.modals.saveSlots.footerSave : t.modals.saveSlots.footerLoad }),
5012
+ children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-4 space-y-2 max-h-[400px] overflow-y-auto", children: isLoading ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col items-center justify-center py-12 text-gray-400", children: [
4997
5013
  /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Loader2, { className: "w-8 h-8 animate-spin mb-3" }),
4998
5014
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm", children: t.modals.saveSlots.loading })
4999
5015
  ] }) : displaySlots.map((slotNum) => {
@@ -5112,10 +5128,9 @@ function SaveSlotModal({
5112
5128
  },
5113
5129
  slotNum
5114
5130
  );
5115
- }) }),
5116
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-3 bg-black/30 border-t border-white/10", children: /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-gray-500 text-center", children: isSaveMode ? t.modals.saveSlots.footerSave : t.modals.saveSlots.footerLoad }) })
5117
- ] })
5118
- ] });
5131
+ }) })
5132
+ }
5133
+ );
5119
5134
  }
5120
5135
  function BiosSelectionModal({
5121
5136
  isOpen,
@@ -5262,36 +5277,28 @@ function SettingsModal({
5262
5277
  systemColor = "#00FF41"
5263
5278
  }) {
5264
5279
  const t = useKoinTranslation();
5265
- if (!isOpen) return null;
5266
5280
  const languages = [
5267
5281
  { code: "en", name: "English" },
5268
5282
  { code: "es", name: "Espa\xF1ol" },
5269
5283
  { code: "fr", name: "Fran\xE7ais" }
5270
5284
  ];
5271
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center", children: [
5272
- /* @__PURE__ */ jsxRuntime.jsx(
5273
- "div",
5274
- {
5275
- className: "absolute inset-0 bg-black/80 backdrop-blur-sm",
5276
- onClick: onClose
5277
- }
5278
- ),
5279
- /* @__PURE__ */ jsxRuntime.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: [
5280
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between px-6 py-4 border-b border-white/10 bg-black/50", children: [
5281
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3", children: [
5282
- /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Settings, { className: "text-white", size: 20 }),
5283
- /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "text-lg font-bold text-white", children: t.settings.title })
5284
- ] }),
5285
- /* @__PURE__ */ jsxRuntime.jsx(
5286
- "button",
5287
- {
5288
- onClick: onClose,
5289
- className: "p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-colors",
5290
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { size: 20 })
5291
- }
5292
- )
5293
- ] }),
5294
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-6 space-y-6", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-3", children: [
5285
+ return /* @__PURE__ */ jsxRuntime.jsx(
5286
+ ModalShell,
5287
+ {
5288
+ isOpen,
5289
+ onClose,
5290
+ title: t.settings.title,
5291
+ icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Settings, { size: 20, className: "text-white" }),
5292
+ maxWidth: "sm",
5293
+ footer: /* @__PURE__ */ jsxRuntime.jsx(
5294
+ "button",
5295
+ {
5296
+ onClick: onClose,
5297
+ className: "text-sm text-gray-500 hover:text-white transition-colors w-full text-center",
5298
+ children: t.modals.shortcuts.pressEsc
5299
+ }
5300
+ ),
5301
+ children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-6 space-y-6", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-3", children: [
5295
5302
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 text-sm font-medium text-gray-400", children: [
5296
5303
  /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Globe, { size: 16 }),
5297
5304
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: t.settings.language })
@@ -5303,9 +5310,9 @@ function SettingsModal({
5303
5310
  {
5304
5311
  onClick: () => onLanguageChange(lang.code),
5305
5312
  className: `
5306
- flex items-center justify-between px-4 py-3 rounded-lg border transition-all
5307
- ${isActive ? "bg-white/10 border-white/20 text-white" : "bg-black/20 border-transparent text-gray-400 hover:bg-white/5 hover:text-white"}
5308
- `,
5313
+ flex items-center justify-between px-4 py-3 rounded-lg border transition-all
5314
+ ${isActive ? "bg-white/10 border-white/20 text-white" : "bg-black/20 border-transparent text-gray-400 hover:bg-white/5 hover:text-white"}
5315
+ `,
5309
5316
  children: [
5310
5317
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: lang.name }),
5311
5318
  isActive && /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Check, { size: 16, style: { color: systemColor } })
@@ -5314,17 +5321,9 @@ function SettingsModal({
5314
5321
  lang.code
5315
5322
  );
5316
5323
  }) })
5317
- ] }) }),
5318
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4 bg-black/30 border-t border-white/10 text-center", children: /* @__PURE__ */ jsxRuntime.jsx(
5319
- "button",
5320
- {
5321
- onClick: onClose,
5322
- className: "text-sm text-gray-500 hover:text-white transition-colors",
5323
- children: t.modals.shortcuts.pressEsc
5324
- }
5325
- ) })
5326
- ] })
5327
- ] });
5324
+ ] }) })
5325
+ }
5326
+ );
5328
5327
  }
5329
5328
  function GameModals({
5330
5329
  controlsModalOpen,
@@ -7535,6 +7534,171 @@ var useNostalgist = ({
7535
7534
  ]);
7536
7535
  return hookReturn;
7537
7536
  };
7537
+ function getDisplayName(id) {
7538
+ let name = id;
7539
+ name = name.replace(/^[0-9a-f]{4}-[0-9a-f]{4}-/i, "");
7540
+ name = name.replace(/\s*\(.*\)\s*$/i, "");
7541
+ name = name.replace(/\s*STANDARD GAMEPAD\s*/i, "");
7542
+ if (/xbox/i.test(name)) {
7543
+ if (/series/i.test(name)) return "Xbox Series Controller";
7544
+ if (/one/i.test(name)) return "Xbox One Controller";
7545
+ if (/360/i.test(name)) return "Xbox 360 Controller";
7546
+ return "Xbox Controller";
7547
+ }
7548
+ if (/dualsense/i.test(name)) return "DualSense";
7549
+ if (/dualshock\s*4/i.test(name)) return "DualShock 4";
7550
+ if (/dualshock/i.test(name)) return "DualShock";
7551
+ if (/playstation/i.test(name) || /sony/i.test(name)) return "PlayStation Controller";
7552
+ if (/pro\s*controller/i.test(name)) return "Switch Pro Controller";
7553
+ if (/joy-?con/i.test(name)) return "Joy-Con";
7554
+ if (/nintendo/i.test(name)) return "Nintendo Controller";
7555
+ return name.trim() || "Gamepad";
7556
+ }
7557
+ function detectControllerBrand(id) {
7558
+ const lowerId = id.toLowerCase();
7559
+ if (/xbox|xinput|microsoft/i.test(lowerId)) return "xbox";
7560
+ if (/playstation|sony|dualshock|dualsense/i.test(lowerId)) return "playstation";
7561
+ if (/nintendo|switch|joy-?con|pro controller/i.test(lowerId)) return "nintendo";
7562
+ return "generic";
7563
+ }
7564
+ function toGamepadInfo(gamepad) {
7565
+ return {
7566
+ index: gamepad.index,
7567
+ id: gamepad.id,
7568
+ name: getDisplayName(gamepad.id),
7569
+ connected: gamepad.connected,
7570
+ buttons: gamepad.buttons.length,
7571
+ axes: gamepad.axes.length,
7572
+ mapping: gamepad.mapping
7573
+ };
7574
+ }
7575
+ function useGamepad(options) {
7576
+ const { onConnect, onDisconnect } = options || {};
7577
+ const [gamepads, setGamepads] = React2.useState([]);
7578
+ const rafRef = React2.useRef(null);
7579
+ const lastStateRef = React2.useRef("");
7580
+ const prevCountRef = React2.useRef(0);
7581
+ const onConnectRef = React2.useRef(onConnect);
7582
+ const onDisconnectRef = React2.useRef(onDisconnect);
7583
+ React2.useEffect(() => {
7584
+ onConnectRef.current = onConnect;
7585
+ onDisconnectRef.current = onDisconnect;
7586
+ }, [onConnect, onDisconnect]);
7587
+ const getGamepads = React2.useCallback(() => {
7588
+ if (typeof navigator === "undefined" || typeof navigator.getGamepads !== "function") {
7589
+ return [];
7590
+ }
7591
+ const rawGamepads = navigator.getGamepads() ?? [];
7592
+ const connected = [];
7593
+ for (let i = 0; i < rawGamepads.length; i++) {
7594
+ const gp = rawGamepads[i];
7595
+ if (gp && gp.connected) {
7596
+ connected.push(toGamepadInfo(gp));
7597
+ }
7598
+ }
7599
+ return connected;
7600
+ }, []);
7601
+ const getRawGamepad = React2.useCallback((index) => {
7602
+ const rawGamepads = navigator.getGamepads?.() ?? [];
7603
+ return rawGamepads[index] ?? null;
7604
+ }, []);
7605
+ const refresh = React2.useCallback(() => {
7606
+ setGamepads(getGamepads());
7607
+ }, [getGamepads]);
7608
+ React2.useEffect(() => {
7609
+ if (typeof window === "undefined" || typeof navigator === "undefined") {
7610
+ return;
7611
+ }
7612
+ if (typeof navigator.getGamepads !== "function") {
7613
+ console.warn("[useGamepad] Gamepad API not supported in this browser");
7614
+ return;
7615
+ }
7616
+ let isActive = true;
7617
+ const poll = () => {
7618
+ if (!isActive) return;
7619
+ const current = getGamepads();
7620
+ let hasChanged = current.length !== prevCountRef.current;
7621
+ if (!hasChanged) {
7622
+ for (let i = 0; i < current.length; i++) {
7623
+ const saved = gamepads[i];
7624
+ if (!saved || saved.id !== current[i].id || saved.connected !== current[i].connected) {
7625
+ hasChanged = true;
7626
+ break;
7627
+ }
7628
+ }
7629
+ }
7630
+ if (hasChanged) {
7631
+ const prevCount = prevCountRef.current;
7632
+ const currentCount = current.length;
7633
+ if (currentCount > prevCount && prevCount >= 0 && onConnectRef.current) {
7634
+ const newGamepad = current[current.length - 1];
7635
+ onConnectRef.current(newGamepad);
7636
+ } else if (currentCount < prevCount && prevCount > 0 && onDisconnectRef.current) {
7637
+ onDisconnectRef.current();
7638
+ }
7639
+ prevCountRef.current = currentCount;
7640
+ setGamepads(current);
7641
+ }
7642
+ rafRef.current = requestAnimationFrame(poll);
7643
+ };
7644
+ const handleConnect = (e) => {
7645
+ console.log("[useGamepad] \u{1F3AE} Gamepad connected:", e.gamepad.id);
7646
+ const current = getGamepads();
7647
+ const prevCount = prevCountRef.current;
7648
+ prevCountRef.current = current.length;
7649
+ lastStateRef.current = current.map((g) => `${g.index}:${g.id}`).join("|");
7650
+ setGamepads(current);
7651
+ if (onConnectRef.current && current.length > prevCount) {
7652
+ const newGamepad = current[current.length - 1];
7653
+ onConnectRef.current(newGamepad);
7654
+ }
7655
+ };
7656
+ const handleDisconnect = (e) => {
7657
+ console.log("[useGamepad] \u{1F3AE} Gamepad disconnected:", e.gamepad.id);
7658
+ const current = getGamepads();
7659
+ const prevCount = prevCountRef.current;
7660
+ prevCountRef.current = current.length;
7661
+ lastStateRef.current = current.map((g) => `${g.index}:${g.id}`).join("|");
7662
+ setGamepads(current);
7663
+ if (onDisconnectRef.current && current.length < prevCount) {
7664
+ onDisconnectRef.current();
7665
+ }
7666
+ };
7667
+ window.addEventListener("gamepadconnected", handleConnect);
7668
+ window.addEventListener("gamepaddisconnected", handleDisconnect);
7669
+ rafRef.current = requestAnimationFrame(poll);
7670
+ const initial = getGamepads();
7671
+ if (initial.length > 0) {
7672
+ console.log("[useGamepad] Initial gamepads found:", initial.map((g) => g.name).join(", "));
7673
+ prevCountRef.current = initial.length;
7674
+ setGamepads(initial);
7675
+ lastStateRef.current = initial.map((g) => `${g.index}:${g.id}`).join("|");
7676
+ } else {
7677
+ prevCountRef.current = 0;
7678
+ }
7679
+ return () => {
7680
+ isActive = false;
7681
+ if (rafRef.current) {
7682
+ cancelAnimationFrame(rafRef.current);
7683
+ }
7684
+ window.removeEventListener("gamepadconnected", handleConnect);
7685
+ window.removeEventListener("gamepaddisconnected", handleDisconnect);
7686
+ };
7687
+ }, [getGamepads]);
7688
+ return {
7689
+ gamepads,
7690
+ isAnyConnected: gamepads.length > 0,
7691
+ connectedCount: gamepads.length,
7692
+ getRawGamepad,
7693
+ refresh
7694
+ };
7695
+ }
7696
+ var STANDARD_AXIS_MAP = {
7697
+ leftStickX: 0,
7698
+ leftStickY: 1,
7699
+ rightStickX: 2,
7700
+ rightStickY: 3
7701
+ };
7538
7702
  function useVolume({
7539
7703
  setVolume: setVolumeInHook,
7540
7704
  toggleMute: toggleMuteInHook
@@ -9206,6 +9370,14 @@ var GamePlayerInner = React2.memo(function GamePlayerInner2(props) {
9206
9370
  disabled: status === "loading" || status === "error"
9207
9371
  }
9208
9372
  ),
9373
+ isFullscreen2 && isMobile && (status === "running" || status === "paused") && /* @__PURE__ */ jsxRuntime.jsx(
9374
+ FloatingPauseButton,
9375
+ {
9376
+ isPaused,
9377
+ onClick: handlePauseToggle,
9378
+ systemColor
9379
+ }
9380
+ ),
9209
9381
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute top-2 right-2 z-40 flex flex-col items-end gap-2 pointer-events-auto", children: [
9210
9382
  /* @__PURE__ */ jsxRuntime.jsx(
9211
9383
  RecordingIndicator_default,
@@ -9414,27 +9586,15 @@ function AchievementPopup({
9414
9586
  onDismiss,
9415
9587
  autoDismissMs = 5e3
9416
9588
  }) {
9417
- const [isVisible, setIsVisible] = React2.useState(false);
9418
- const [isExiting, setIsExiting] = React2.useState(false);
9419
- React2.useEffect(() => {
9420
- requestAnimationFrame(() => {
9421
- setIsVisible(true);
9422
- });
9423
- const timer = setTimeout(() => {
9424
- handleDismiss();
9425
- }, autoDismissMs);
9426
- return () => clearTimeout(timer);
9427
- }, [autoDismissMs]);
9428
- const handleDismiss = () => {
9429
- setIsExiting(true);
9430
- setTimeout(() => {
9431
- onDismiss();
9432
- }, 300);
9433
- };
9589
+ const { slideInRightClasses, triggerExit } = useAnimatedVisibility({
9590
+ exitDuration: 300,
9591
+ onExit: onDismiss,
9592
+ autoDismissMs
9593
+ });
9434
9594
  return /* @__PURE__ */ jsxRuntime.jsxs(
9435
9595
  "div",
9436
9596
  {
9437
- className: `fixed top-4 right-4 z-[100] transition-all duration-300 ${isVisible && !isExiting ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"}`,
9597
+ className: `fixed top-4 right-4 z-[100] transition-all duration-300 ${slideInRightClasses}`,
9438
9598
  children: [
9439
9599
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "absolute inset-0 bg-gradient-to-r from-yellow-500 to-orange-500 blur-lg opacity-50 animate-pulse" }),
9440
9600
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "relative bg-gradient-to-r from-yellow-500 to-orange-500 p-[2px] rounded-lg", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "bg-gray-900 rounded-lg p-4 flex items-center gap-4 min-w-[320px]", children: [
@@ -9471,7 +9631,7 @@ function AchievementPopup({
9471
9631
  /* @__PURE__ */ jsxRuntime.jsx(
9472
9632
  "button",
9473
9633
  {
9474
- onClick: handleDismiss,
9634
+ onClick: triggerExit,
9475
9635
  className: "flex-shrink-0 text-gray-500 hover:text-white transition-colors",
9476
9636
  children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.X, { size: 18 })
9477
9637
  }