tracky-mouse 2.6.0 → 2.8.0

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.
Files changed (76) hide show
  1. package/README.md +2 -1
  2. package/audio/click-press.wav +0 -0
  3. package/audio/click-release.wav +0 -0
  4. package/audio/middle-click-press.wav +0 -0
  5. package/audio/middle-click-release.wav +0 -0
  6. package/audio/pause.wav +0 -0
  7. package/audio/unpause.wav +0 -0
  8. package/lib/face-landmarks-detection.min.js +1 -1
  9. package/lib/face_mesh/face_mesh.js +1 -1
  10. package/locales/ar/translation.json +8 -2
  11. package/locales/ar-EG/translation.json +8 -2
  12. package/locales/bg/translation.json +8 -2
  13. package/locales/bn/translation.json +8 -2
  14. package/locales/ca/translation.json +8 -2
  15. package/locales/ce/translation.json +8 -2
  16. package/locales/ceb/translation.json +9 -3
  17. package/locales/cs/translation.json +8 -2
  18. package/locales/da/translation.json +8 -2
  19. package/locales/de/translation.json +8 -2
  20. package/locales/el/translation.json +8 -2
  21. package/locales/emoji/emoji-translation-notes.md +1 -0
  22. package/locales/emoji/translation.json +12 -6
  23. package/locales/en/translation.json +8 -2
  24. package/locales/eo/translation.json +8 -2
  25. package/locales/es/translation.json +8 -2
  26. package/locales/eu/translation.json +8 -2
  27. package/locales/fa/translation.json +8 -2
  28. package/locales/fi/translation.json +8 -2
  29. package/locales/fr/translation.json +8 -2
  30. package/locales/gu/translation.json +8 -2
  31. package/locales/ha/translation.json +8 -2
  32. package/locales/he/translation.json +8 -2
  33. package/locales/hi/translation.json +8 -2
  34. package/locales/hr/translation.json +8 -2
  35. package/locales/hu/translation.json +8 -2
  36. package/locales/hy/translation.json +8 -2
  37. package/locales/id/translation.json +8 -2
  38. package/locales/it/translation.json +8 -2
  39. package/locales/ja/translation.json +8 -2
  40. package/locales/jv/translation.json +9 -3
  41. package/locales/ko/translation.json +8 -2
  42. package/locales/mr/translation.json +8 -2
  43. package/locales/ms/translation.json +8 -2
  44. package/locales/nan/translation.json +8 -2
  45. package/locales/nb/translation.json +8 -2
  46. package/locales/nl/translation.json +8 -2
  47. package/locales/pa/translation.json +8 -2
  48. package/locales/pl/translation.json +8 -2
  49. package/locales/pt/translation.json +8 -2
  50. package/locales/pt-BR/translation.json +8 -2
  51. package/locales/ro/translation.json +8 -2
  52. package/locales/ru/translation.json +8 -2
  53. package/locales/sk/translation.json +8 -2
  54. package/locales/sl/translation.json +8 -2
  55. package/locales/sr/translation.json +8 -2
  56. package/locales/sv/translation.json +8 -2
  57. package/locales/sw/translation.json +8 -2
  58. package/locales/ta/translation.json +8 -2
  59. package/locales/te/translation.json +8 -2
  60. package/locales/th/translation.json +8 -2
  61. package/locales/tl/translation.json +9 -3
  62. package/locales/tr/translation.json +8 -2
  63. package/locales/tt/translation.json +8 -2
  64. package/locales/uk/translation.json +8 -2
  65. package/locales/ur/translation.json +8 -2
  66. package/locales/uz/translation.json +8 -2
  67. package/locales/vi/translation.json +8 -2
  68. package/locales/war/translation.json +9 -3
  69. package/locales/zh/translation.json +8 -2
  70. package/locales/zh-simplified/translation.json +8 -2
  71. package/package.json +3 -1
  72. package/src/audio.js +145 -0
  73. package/src/autoscroll.js +189 -0
  74. package/src/input-simulator.js +518 -0
  75. package/tracky-mouse.css +32 -1
  76. package/tracky-mouse.js +457 -185
package/tracky-mouse.js CHANGED
@@ -52,24 +52,30 @@ const isSelectorValid = ((dummyElement) =>
52
52
 
53
53
  const dwellClickers = [];
54
54
 
55
+ let playSound = () => { console.log("audio module not loaded yet; can't play sound effect"); };
56
+ let initialAudioEnabled = false;
57
+ let setAudioEnabled = (enabled) => { initialAudioEnabled = enabled; };
58
+
59
+ /**
60
+ * @param {Object} config
61
+ * @param {string} config.targets - a CSS selector for the elements to click. Anything else will be ignored (except as an occluder).
62
+ * @param {(el: Element) => boolean} [config.shouldDrag] - a function that returns true if the element should be dragged rather than simply clicked.
63
+ * @param {(el: Element) => boolean} [config.noCenter] - a function that returns true if the element should be clicked anywhere on the element, rather than always at the center.
64
+ * @param {Array<{
65
+ * from: string | Element | ((el: Element) => boolean), // - an array of `{ from, to, withinMargin }` objects, which define rules for dynamically changing what is hovered/clicked when the mouse is over a different element.
66
+ * to: string | Element | ((el: Element) => Element | null), // - the element to retarget from. Can be a CSS selector, an element, or a function taking the element under the mouse and returning whether it should be retargeted.
67
+ * withinMargin?: number // - the element to retarget to. Can be a CSS selector for an element which is an ancestor or descendant of the `from` element, or an element, or a function taking the element under the mouse and returning an element to retarget to, or null to ignore the element.
68
+ * }>} [config.retarget] - a number of pixels within which to consider the mouse over the `to` element. Default to infinity.
69
+ * @param {(el1: Element, el2: Element) => boolean} [config.isEquivalentTarget] - a function that returns true if two elements should be considered part of the same control, i.e. if clicking either should do the same thing. Elements that are equal are always considered equivalent even if you return false. This option is used for preventing the system from detecting occluding elements as separate controls, and rejecting the click. (When an occlusion is detected, it flashes a red box.)
70
+ * @param {(el: Element) => boolean} [config.dwellClickEvenIfPaused] - a function that returns true if the element should be clicked even while dwell clicking is otherwise paused. Use this for a dwell clicking toggle button, so it's possible to resume dwell clicking. With dwell clicking it's important to let users take a break, since otherwise you have to constantly move the cursor in order to not click on things!
71
+ * @param {(el: Element) => boolean} [config.shouldClickThrough] - a function that returns true if the element should be totally ignored, allowing clicking on content behind it. Prefer `pointer-events: none` when possible, which will work for all input methods. Use this only if you need to differentiate input methods. Default: `(el) => el.matches(".tracky-mouse-click-through, .tracky-mouse-click-through *")`
72
+ * @param {(args: {x: number, y: number, target: Element}) => void} config.click - a function to trigger a click on the given target element.
73
+ * @param {() => void} [config.beforeDispatch] - a function to call before a pointer event is dispatched. For detecting un-trusted user gestures, outside of an event handler.
74
+ * @param {() => void} [config.afterDispatch] - a function to call after a pointer event is dispatched. For detecting un-trusted user gestures, outside of an event handler.
75
+ * @param {() => void} [config.beforePointerDownDispatch] - a function to call before a `pointerdown` event is dispatched. Likely to be merged with `config.beforeDispatch()` in the future.
76
+ * @param {() => boolean} [config.isHeld] - a function that returns true if the next dwell should be a release (triggering `pointerup`).
77
+ */
55
78
  const initDwellClicking = (config) => {
56
- /*
57
- Arguments:
58
- - `config.targets` (required): a CSS selector for the elements to click. Anything else will be ignored.
59
- - `config.shouldDrag(el)` (optional): a function that returns true if the element should be dragged rather than simply clicked.
60
- - `config.noCenter(el)` (optional): a function that returns true if the element should be clicked anywhere on the element, rather than always at the center.
61
- - `config.retarget` (optional): an array of `{ from, to, withinMargin }` objects, which define rules for dynamically changing what is hovered/clicked when the mouse is over a different element.
62
- - `from` (required): the element to retarget from. Can be a CSS selector, an element, or a function taking the element under the mouse and returning whether it should be retargeted.
63
- - `to` (required): the element to retarget to. Can be a CSS selector for an element which is an ancestor or descendant of the `from` element, or an element, or a function taking the element under the mouse and returning an element to retarget to, or null to ignore the element.
64
- - `withinMargin` (optional): a number of pixels within which to consider the mouse over the `to` element. Default to infinity.
65
- - `config.isEquivalentTarget(el1, el2)` (optional): a function that returns true if two elements should be considered part of the same control, i.e. if clicking either should do the same thing. Elements that are equal are always considered equivalent even if you return false. This option is used for preventing the system from detecting occluding elements as separate controls, and rejecting the click. (When an occlusion is detected, it flashes a red box.)
66
- - `config.dwellClickEvenIfPaused(el)` (optional): a function that returns true if the element should be clicked even while dwell clicking is otherwise paused. Use this for a dwell clicking toggle button, so it's possible to resume dwell clicking. With dwell clicking it's important to let users take a break, since otherwise you have to constantly move the cursor in order to not click on things!
67
- - `config.click({x, y, target})` (required): a function to trigger a click on the given target element.
68
- - `config.beforeDispatch()` (optional): a function to call before a pointer event is dispatched. For detecting un-trusted user gestures, outside of an event handler.
69
- - `config.afterDispatch()` (optional): a function to call after a pointer event is dispatched. For detecting un-trusted user gestures, outside of an event handler.
70
- - `config.beforePointerDownDispatch()` (optional): a function to call before a `pointerdown` event is dispatched. Likely to be merged with `config.beforeDispatch()` in the future.
71
- - `config.isHeld()` (optional): a function that returns true if the next dwell should be a release (triggering `pointerup`).
72
- */
73
79
 
74
80
  /** translation placeholder */
75
81
  const t = (key, options = {}) => options.defaultValue ?? key;
@@ -104,6 +110,9 @@ const initDwellClicking = (config) => {
104
110
  if (config.dwellClickEvenIfPaused !== undefined && typeof config.dwellClickEvenIfPaused !== "function") {
105
111
  throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.dwellClickEvenIfPaused"));
106
112
  }
113
+ if (config.shouldClickThrough !== undefined && typeof config.shouldClickThrough !== "function") {
114
+ throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.shouldClickThrough"));
115
+ }
107
116
  if (config.beforeDispatch !== undefined && typeof config.beforeDispatch !== "function") {
108
117
  throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.beforeDispatch"));
109
118
  }
@@ -149,6 +158,8 @@ const initDwellClicking = (config) => {
149
158
  }
150
159
  }
151
160
 
161
+ const shouldClickThrough = config.shouldClickThrough ?? ((el) => el.matches(".tracky-mouse-click-through, .tracky-mouse-click-through *"));
162
+
152
163
  // trackyMouseContainer.querySelector(".tracky-mouse-canvas").classList.add("inset-deep");
153
164
 
154
165
  const circleRadiusMax = 50; // dwell indicator size in pixels
@@ -222,6 +233,14 @@ const initDwellClicking = (config) => {
222
233
  return null;
223
234
  }
224
235
 
236
+ if (shouldClickThrough(target)) {
237
+ const elements = document.elementsFromPoint(clientX, clientY);
238
+ target = elements.find(el => !shouldClickThrough(el));
239
+ if (!target) {
240
+ return null;
241
+ }
242
+ }
243
+
225
244
  let hoverCandidate = {
226
245
  x: clientX,
227
246
  y: clientY,
@@ -365,6 +384,9 @@ const initDwellClicking = (config) => {
365
384
  showOccluderIndicator(apparentHoverCandidate.target);
366
385
  }
367
386
  } else {
387
+ // TODO: ignore .tracky-mouse-click-through elements here as well
388
+ // TODO: distinguish occlusion vs moved element (i.e. element is no longer in the elementsFromPoint list)
389
+ // for example for the archery targets in the demo on the website, which animate
368
390
  let occluder = document.elementFromPoint(hoverCandidate.x, hoverCandidate.y);
369
391
  hoverCandidate = null;
370
392
  deactivateForAtLeast(inactiveAfterInvalidTimespan);
@@ -391,6 +413,7 @@ const initDwellClicking = (config) => {
391
413
  })
392
414
  ));
393
415
  config.afterDispatch?.();
416
+ playSound("clickRelease");
394
417
  } else {
395
418
  config.beforePointerDownDispatch?.();
396
419
  config.beforeDispatch?.();
@@ -403,6 +426,7 @@ const initDwellClicking = (config) => {
403
426
  config.afterDispatch?.();
404
427
  if (config.shouldDrag?.(hoverCandidate.target)) {
405
428
  dwellDragging = hoverCandidate.target;
429
+ playSound("clickPress");
406
430
  } else {
407
431
  config.beforeDispatch?.();
408
432
  hoverCandidate.target.dispatchEvent(new PointerEvent("pointerup",
@@ -413,6 +437,8 @@ const initDwellClicking = (config) => {
413
437
  ));
414
438
  config.click(hoverCandidate);
415
439
  config.afterDispatch?.();
440
+ playSound("clickPress");
441
+ playSound("clickRelease", { delay: 0.03 }); // fully separating the sounds sounded worse
416
442
  }
417
443
  }
418
444
  hoverCandidate = null;
@@ -571,6 +597,28 @@ TrackyMouse.cleanupDwellClicking = function () {
571
597
  }
572
598
  };
573
599
 
600
+ TrackyMouse._initAudio = async function () {
601
+ let module;
602
+ try {
603
+ // console.log("Loading audio support...");
604
+ module = await import("./src/audio.js");
605
+ } catch (e) {
606
+ console.warn("Failed to load audio module, click sounds will be disabled:", e);
607
+ }
608
+ // console.log("Audio module loaded.");
609
+ try {
610
+ const { initAudio } = module;
611
+ initAudio();
612
+ playSound = module.playSound;
613
+ setAudioEnabled = module.setAudioEnabled;
614
+ setAudioEnabled(initialAudioEnabled);
615
+ // console.log("Audio is initially " + (initialAudioEnabled ? "enabled" : "disabled"));
616
+ } catch (e) {
617
+ console.warn("Failed to initialize audio support, click sounds will be disabled:", e);
618
+ }
619
+ return module;
620
+ };
621
+
574
622
  TrackyMouse._initInner = function (div, initOptions, reinit) {
575
623
 
576
624
  const {
@@ -590,6 +638,17 @@ TrackyMouse._initInner = function (div, initOptions, reinit) {
590
638
  // Could group things under an "unstable" object, or ideally, design nice APIs for everything.
591
639
  } = initOptions;
592
640
 
641
+ /** @type {SleepSweep | null} */
642
+ let sleepSweep = null;
643
+
644
+ TrackyMouse._initAudio().then((module) => {
645
+ // _initAudio warns in the console and resolves to undefined if it fails to load audio support
646
+ if (module) {
647
+ const { SleepSweep } = module;
648
+ sleepSweep = new SleepSweep();
649
+ }
650
+ });
651
+
593
652
  const isDesktopApp = !!window.electronAPI;
594
653
 
595
654
  let translations = {};
@@ -843,7 +902,7 @@ TrackyMouse._initInner = function (div, initOptions, reinit) {
843
902
  };
844
903
 
845
904
 
846
- var languageToDefaultRegion = {
905
+ let languageToDefaultRegion = {
847
906
  aa: "ET",
848
907
  ab: "GE",
849
908
  abr: "GH",
@@ -1588,9 +1647,9 @@ TrackyMouse._initInner = function (div, initOptions, reinit) {
1588
1647
  // </svg>`;
1589
1648
  }
1590
1649
 
1591
- var split = locale.toUpperCase().split(/-|_/);
1592
- var lang = split.shift();
1593
- var code = split.pop();
1650
+ let split = locale.toUpperCase().split(/-|_/);
1651
+ let lang = split.shift();
1652
+ let code = split.pop();
1594
1653
 
1595
1654
  if (!/^[A-Z]{2}$/.test(code)) {
1596
1655
  code = languageToDefaultRegion[lang.toLowerCase()];
@@ -1605,7 +1664,7 @@ TrackyMouse._initInner = function (div, initOptions, reinit) {
1605
1664
  return a + b;
1606
1665
  }
1607
1666
 
1608
- var uiContainer = div || document.createElement("div");
1667
+ let uiContainer = div || document.createElement("div");
1609
1668
  uiContainer.classList.add("tracky-mouse-ui");
1610
1669
  uiContainer.classList.toggle("tracky-mouse-rtl", isRTL);
1611
1670
  uiContainer.dir = isRTL ? "rtl" : "ltr";
@@ -1613,7 +1672,7 @@ TrackyMouse._initInner = function (div, initOptions, reinit) {
1613
1672
  <div class="tracky-mouse-controls">
1614
1673
  <button class="tracky-mouse-start-stop-button" aria-pressed="false" aria-keyshortcuts="F9">${t("ui.startStopButton.start", { defaultValue: "Start" })}</button>
1615
1674
  </div>
1616
- <div class="tracky-mouse-canvas-container-container">
1675
+ <div class="tracky-mouse-camera-area">
1617
1676
  <div class="tracky-mouse-canvas-container">
1618
1677
  <div class="tracky-mouse-canvas-overlay">
1619
1678
  <button class="tracky-mouse-use-camera-button">${t("ui.camera.allowAccess", { defaultValue: "Allow Camera Access" })}</button>
@@ -1631,12 +1690,43 @@ TrackyMouse._initInner = function (div, initOptions, reinit) {
1631
1690
  if (!div) {
1632
1691
  document.body.appendChild(uiContainer);
1633
1692
  }
1634
- var startStopButton = uiContainer.querySelector(".tracky-mouse-start-stop-button");
1635
- var useCameraButton = uiContainer.querySelector(".tracky-mouse-use-camera-button");
1636
- var useDemoFootageButton = uiContainer.querySelector(".tracky-mouse-use-demo-footage-button");
1637
- var errorMessage = uiContainer.querySelector(".tracky-mouse-error-message");
1638
- var canvasContainer = uiContainer.querySelector('.tracky-mouse-canvas-container');
1639
- var desktopAppDownloadMessage = uiContainer.querySelector('.tracky-mouse-desktop-app-download-message');
1693
+ let startStopButton = uiContainer.querySelector(".tracky-mouse-start-stop-button");
1694
+ let useCameraButton = uiContainer.querySelector(".tracky-mouse-use-camera-button");
1695
+ let useDemoFootageButton = uiContainer.querySelector(".tracky-mouse-use-demo-footage-button");
1696
+ let errorMessage = uiContainer.querySelector(".tracky-mouse-error-message");
1697
+ let canvasContainer = uiContainer.querySelector('.tracky-mouse-canvas-container');
1698
+ let desktopAppDownloadMessage = uiContainer.querySelector('.tracky-mouse-desktop-app-download-message');
1699
+
1700
+ let lastShownErrorDetails = null;
1701
+ function showError(message, error, { warningIcon = true, errorClass = "other" } = {}) {
1702
+ const alreadyShown = !errorMessage.hidden && lastShownErrorDetails?.message === message && lastShownErrorDetails?.error?.name === error?.name && lastShownErrorDetails?.error?.message === error?.message;
1703
+ if (alreadyShown) {
1704
+ // Play CSS animation to indicate repeated errors
1705
+ // but not if they're occurring constantly
1706
+ // Note: for constant errors, with this scheme, it may animate
1707
+ // when returning to the tab due to timer throttling, or due to lag.
1708
+ if (performance.now() > lastShownErrorDetails.time + 100) {
1709
+ errorMessage.style.animation = "none";
1710
+ if (alreadyShown) {
1711
+ void errorMessage.offsetWidth; // trigger reflow to allow restarting animation
1712
+ errorMessage.style.animation = "";
1713
+ }
1714
+ }
1715
+ } else {
1716
+ if (warningIcon) {
1717
+ errorMessage.textContent = `${t("common.warningIcon", { defaultValue: "⚠️" })} ${message}`;
1718
+ } else {
1719
+ errorMessage.textContent = message;
1720
+ }
1721
+ if (error) {
1722
+ const pre = document.createElement("pre");
1723
+ pre.textContent = error.name + ": " + error.message;
1724
+ errorMessage.appendChild(pre);
1725
+ }
1726
+ errorMessage.hidden = false;
1727
+ }
1728
+ lastShownErrorDetails = { message, error, time: performance.now(), errorClass };
1729
+ }
1640
1730
 
1641
1731
  // Settings (initialized later; defaults are defined in settingsCategories)
1642
1732
  const s = {};
@@ -1975,11 +2065,21 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1975
2065
  type: "button",
1976
2066
  visible: () => isDesktopApp,
1977
2067
  onClick: async () => {
2068
+ function showToast(message) {
2069
+ const toast = document.createElement("div");
2070
+ toast.className = "tracky-mouse-toast";
2071
+ toast.textContent = message;
2072
+ document.body.appendChild(toast);
2073
+ setTimeout(() => {
2074
+ toast.remove();
2075
+ }, 5000);
2076
+ }
2077
+
1978
2078
  let knownCameras = {};
1979
2079
  try {
1980
2080
  knownCameras = JSON.parse(localStorage.getItem("tracky-mouse-known-cameras")) || {};
1981
2081
  } catch (error) {
1982
- alert(t("openCameraSettings.errors.sharedHeading", { defaultValue: "Failed to open camera settings:" }) + "\n" + t("openCameraSettings.errors.parseKnownCameras", { defaultValue: "Failed to parse known cameras from localStorage:" }) + "\n" + error.message);
2082
+ showToast(t("openCameraSettings.errors.sharedHeading", { defaultValue: "Failed to open camera settings:" }) + "\n" + t("openCameraSettings.errors.parseKnownCameras", { defaultValue: "Failed to parse known cameras from localStorage:" }) + "\n" + error.name + ": " + error.message);
1983
2083
  return;
1984
2084
  }
1985
2085
 
@@ -1990,10 +2090,10 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1990
2090
  try {
1991
2091
  const result = await window.electronAPI.openCameraSettings(selectedDeviceName);
1992
2092
  if (result?.error) {
1993
- alert(t("openCameraSettings.errors.sharedHeading", { defaultValue: "Failed to open camera settings:" }) + "\n" + result.error);
2093
+ showToast(t("openCameraSettings.errors.sharedHeading", { defaultValue: "Failed to open camera settings:" }) + "\n" + result.error);
1994
2094
  }
1995
2095
  } catch (error) {
1996
- alert(t("openCameraSettings.errors.sharedHeading", { defaultValue: "Failed to open camera settings:" }) + "\n" + error.message);
2096
+ showToast(t("openCameraSettings.errors.sharedHeading", { defaultValue: "Failed to open camera settings:" }) + "\n" + error.name + ": " + error.message);
1997
2097
  }
1998
2098
  },
1999
2099
  // description: t("settings.openCameraSettings.description.alt1", { defaultValue: "Open your camera's system settings window to adjust properties like brightness and contrast." }),
@@ -2015,6 +2115,20 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2015
2115
  type: "group",
2016
2116
  label: t("settings.sections.general.label", { defaultValue: "General" }),
2017
2117
  settings: [
2118
+ {
2119
+ label: t("settings.soundEffects.label", { defaultValue: "Sound effects" }),
2120
+ className: "tracky-mouse-sound-effects",
2121
+ key: "soundEffects",
2122
+ type: "checkbox",
2123
+ default: true,
2124
+ afterInitialLoad: () => {
2125
+ setAudioEnabled(s.soundEffects);
2126
+ },
2127
+ handleSettingChange: () => {
2128
+ setAudioEnabled(s.soundEffects);
2129
+ },
2130
+ description: t("settings.soundEffects.description", { defaultValue: "Plays sounds when you click." }),
2131
+ },
2018
2132
  // opposite, "Start paused", might be clearer, especially if I add a "pause" button
2019
2133
  {
2020
2134
  label: t("settings.startEnabled.label", { defaultValue: "Start enabled" }),
@@ -2036,7 +2150,8 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2036
2150
  // - I considered adding "⚠︎" but it feels a little too alarming
2037
2151
  // label: "Close eyes to start/stop (<span style=\"border-bottom: 1px dotted;\" title=\"Planned refinements include: visual and auditory feedback, improved detection accuracy, and separate settings for durations to toggle on and off.\">experimental</span>)",
2038
2152
  // label: "Close eyes to start/stop (<span style=\"border-bottom: 1px dotted;\" title=\"• Missing visual and auditory feedback.\n• Missing settings for duration(s) to toggle on and off.\n• Affected by false positive blink detections, especially when looking downward.\">Experimental</span>)",
2039
- label: t("settings.closeEyesToToggle.label", { defaultValue: "Close eyes to start/stop (<span style=\"border-bottom: 1px dotted;\" title=\"• There is currently no visual or auditory feedback.\n• There are no settings for duration(s) to toggle on and off.\n• It is affected by false positive blink detections, especially when looking downward.\">Experimental</span>)" }),
2153
+ // label: t("settings.closeEyesToToggle.label", { defaultValue: "Close eyes to start/stop (<span style=\"border-bottom: 1px dotted;\" title=\"• There is currently no visual or auditory feedback.\n• There are no settings for duration(s) to toggle on and off.\n• It is affected by false positive blink detections, especially when looking downward.\">Experimental</span>)" }),
2154
+ label: t("settings.closeEyesToToggle.label", { defaultValue: "Close eyes to start/stop" }),
2040
2155
  className: "tracky-mouse-close-eyes-to-toggle",
2041
2156
  key: "closeEyesToToggle",
2042
2157
  type: "checkbox",
@@ -2309,26 +2424,27 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2309
2424
  });
2310
2425
  }
2311
2426
 
2312
- var canvas = uiContainer.querySelector(".tracky-mouse-canvas");
2313
- var ctx = canvas.getContext('2d', { willReadFrequently: true });
2427
+ let canvas = uiContainer.querySelector(".tracky-mouse-canvas");
2428
+ let ctx = canvas.getContext('2d', { willReadFrequently: true });
2314
2429
 
2315
- var debugEyeCanvas = document.createElement("canvas");
2430
+ let debugEyeCanvas = document.createElement("canvas");
2316
2431
  debugEyeCanvas.className = "tracky-mouse-debug-eye-canvas";
2317
2432
  debugEyeCanvas.style.display = "none";
2318
- uiContainer.querySelector(".tracky-mouse-canvas-container-container").appendChild(debugEyeCanvas);
2319
- var debugEyeCtx = debugEyeCanvas.getContext('2d');
2433
+ uiContainer.querySelector(".tracky-mouse-camera-area").appendChild(debugEyeCanvas);
2434
+ let debugEyeCtx = debugEyeCanvas.getContext('2d');
2320
2435
 
2321
- var pointerEl = document.createElement('div');
2436
+ let pointerEl = document.createElement('div');
2322
2437
  pointerEl.className = "tracky-mouse-pointer";
2323
2438
  pointerEl.style.display = "none";
2324
2439
  document.body.appendChild(pointerEl);
2325
2440
 
2326
- var cameraVideo = document.createElement('video');
2441
+ let cameraVideo = document.createElement('video');
2327
2442
  // required to work in iOS 11 & up:
2328
2443
  cameraVideo.setAttribute('playsinline', '');
2329
2444
 
2445
+ let stats;
2330
2446
  if (statsJs) {
2331
- var stats = new Stats();
2447
+ stats = new Stats();
2332
2448
  stats.domElement.style.position = 'fixed';
2333
2449
  stats.domElement.style.top = '0px';
2334
2450
  stats.domElement.style.right = '0px';
@@ -2337,72 +2453,86 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2337
2453
  }
2338
2454
 
2339
2455
  // Debug flags (not shown in the UI; could become Advanced Settings in the future)
2340
- var debugAcceleration = false;
2341
- var showDebugText = false;
2342
- var showDebugEyeZoom = false;
2343
- var showDebugHeadTilt = false;
2456
+ let debugAcceleration = false;
2457
+ let showDebugText = false;
2458
+ let showDebugEyeZoom = false;
2459
+ let showDebugHeadTilt = false;
2460
+ let showDebugRegionFilter = false;
2344
2461
 
2345
2462
  // Constants (could become Advanced Settings in the future)
2346
- var defaultWidth = 640;
2347
- var defaultHeight = 480;
2348
- var maxPoints = 1000;
2349
- var faceScoreThreshold = 0.5;
2350
- var facemeshOptions = {
2463
+ let defaultWidth = 640;
2464
+ let defaultHeight = 480;
2465
+ let maxPoints = 1000;
2466
+ let faceScoreThreshold = 0.5;
2467
+ let facemeshOptions = {
2351
2468
  maxContinuousChecks: 5,
2352
2469
  detectionConfidence: 0.9,
2353
2470
  maxFaces: 1,
2354
2471
  iouThreshold: 0.3,
2355
2472
  scoreThreshold: 0.75
2356
2473
  };
2357
- var useFacemesh = true;
2474
+ let useFacemesh = true;
2475
+ let sleepGestureEyesClosedDuration = 2000;
2358
2476
  // maybe should be based on size of head in view?
2359
2477
  const pruningGridSize = 5;
2360
2478
  const minDistanceToAddPoint = pruningGridSize * 1.5;
2361
2479
 
2362
2480
  // Head tracking and facial gesture state
2363
2481
  // ## Clmtrackr state
2364
- var face;
2365
- var faceScore = 0;
2366
- var faceConvergence = 0;
2367
- // var faceConvergenceThreshold = 50;
2368
- var pointsBasedOnFaceScore = 0;
2482
+ let face;
2483
+ let faceScore = 0;
2484
+ let faceConvergence = 0;
2485
+ // let faceConvergenceThreshold = 50;
2486
+ let pointsBasedOnFaceScore = 0;
2369
2487
  // ## Facemesh state
2370
2488
  let detector;
2371
2489
  let currentCameraImageData;
2372
- var facemeshLoaded = false;
2373
- var facemeshFirstEstimation = true;
2374
- var facemeshEstimating = false;
2375
- var facemeshRejectNext = 0;
2376
- var facemeshPrediction;
2377
- var facemeshEstimateFaces;
2378
- var faceInViewConfidenceThreshold = 0.7;
2379
- var pointsBasedOnFaceInViewConfidence = 0;
2380
- var cameraFramesSinceFacemeshUpdate = [];
2381
- var blinkInfo;
2382
- var mouthInfo;
2383
- var headTilt = { pitch: 0, yaw: 0, roll: 0 };
2384
- var headTiltFilters = { pitch: null, yaw: null, roll: null };
2385
- var lastTimeWhenAnEyeWasOpen = Infinity; // far future rather than far past so that sleep gesture doesn't trigger initially, skipping the delay
2490
+ let facemeshLoaded = false;
2491
+ let facemeshFirstEstimation = true;
2492
+ let facemeshEstimating = false;
2493
+ let facemeshRejectNext = 0;
2494
+ let facemeshPrediction;
2495
+ let facemeshEstimateFaces;
2496
+ let faceInViewConfidenceThreshold = 0.7;
2497
+ let pointsBasedOnFaceInViewConfidence = 0;
2498
+ let cameraFramesSinceFacemeshUpdate = [];
2499
+ let blinkInfo;
2500
+ let mouthInfo;
2501
+ let headTilt = { pitch: 0, yaw: 0, roll: 0 };
2502
+ let headTiltFilters = { pitch: null, yaw: null, roll: null };
2503
+ let sleepGestureProgress = 0;
2386
2504
  // ## State related to switching between head trackers
2387
- var useClmTracking = true;
2388
- var showClmTracking = useClmTracking;
2389
- var fallbackTimeoutID;
2505
+ let useClmTracking = true;
2506
+ let showClmTracking = useClmTracking;
2507
+ let fallbackTimeoutID;
2390
2508
 
2391
2509
  // Mouse state
2392
- var mouseX = 0;
2393
- var mouseY = 0;
2394
- var buttonStates = {
2510
+ let mouseX = 0;
2511
+ let mouseY = 0;
2512
+ let buttonStates = {
2395
2513
  left: false,
2396
2514
  right: false,
2397
2515
  middle: false,
2398
2516
  };
2399
- var mouseButtonUntilMouthCloses = -1;
2400
- var lastMouseDownTime = -Infinity;
2401
- var mouseNeedsInitPos = true;
2517
+ let mouseButtonUntilMouthCloses = -1;
2518
+ let lastMouseDownTime = -Infinity;
2519
+ let mouseNeedsInitPos = true;
2520
+
2521
+ // Virtual display bounds cache (Electron only); covers all connected monitors.
2522
+ let virtualDisplayBounds = null;
2523
+ if (window.electronAPI?.getVirtualDisplayBounds) {
2524
+ window.electronAPI.getVirtualDisplayBounds().then((bounds) => {
2525
+ virtualDisplayBounds = bounds;
2526
+ });
2527
+ window.electronAPI.onVirtualDisplayBoundsChanged?.((bounds) => {
2528
+ virtualDisplayBounds = bounds;
2529
+ mouseNeedsInitPos = true;
2530
+ });
2531
+ }
2402
2532
 
2403
2533
  // Other state
2404
- var paused = true;
2405
- var pointTracker;
2534
+ let paused = true;
2535
+ let pointTracker;
2406
2536
 
2407
2537
  // Named lists of facemesh landmark indices
2408
2538
  const MESH_ANNOTATIONS = {
@@ -2471,14 +2601,17 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2471
2601
 
2472
2602
  try {
2473
2603
  detector = await faceLandmarksDetection.createDetector(model, detectorConfig);
2604
+ if (lastShownErrorDetails?.errorClass === "faceLandmarksDetection.createDetector") {
2605
+ errorMessage.hidden = true;
2606
+ }
2474
2607
  } catch (error) {
2475
2608
  detector = null;
2476
- // TODO: avoid alert
2477
2609
  console.error("Failed to create facemesh detector:", error);
2478
- alert(error);
2610
+ showError(t("faceDetectorInitError", { defaultValue: "Failed to create face detector" }), error, { errorClass: "faceLandmarksDetection.createDetector" });
2479
2611
  }
2480
2612
 
2481
2613
  facemeshLoaded = true;
2614
+ let loggedDetectorError = false;
2482
2615
  facemeshEstimateFaces = async () => {
2483
2616
  const imageData = currentCameraImageData;//getCameraImageData();
2484
2617
  if (!imageData) {
@@ -2492,11 +2625,17 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2492
2625
  }
2493
2626
  return faces;
2494
2627
  } catch (error) {
2495
- detector.dispose();
2628
+ if (!loggedDetectorError) {
2629
+ console.error("Facemesh estimation failed:", error);
2630
+ loggedDetectorError = true;
2631
+ }
2632
+ try {
2633
+ detector?.dispose();
2634
+ } catch (disposeError) {
2635
+ console.error("Failed to dispose facemesh detector after estimation error:", disposeError);
2636
+ }
2496
2637
  detector = null;
2497
- // TODO: avoid alert
2498
- console.error("Facemesh estimation failed:", error);
2499
- alert(error);
2638
+ showError(t("faceDetectorError", { defaultValue: "Face detector error" }), error);
2500
2639
  }
2501
2640
  return [];
2502
2641
  };
@@ -2514,6 +2653,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2514
2653
  setting._load?.(settings, initialLoad);
2515
2654
  });
2516
2655
  }
2656
+ setAudioEnabled(s.soundEffects);
2517
2657
 
2518
2658
  // Now that all settings are loaded, update disabled states
2519
2659
  for (const func of functionsToUpdateDisabledStates) {
@@ -2579,6 +2719,12 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2579
2719
  }
2580
2720
  if (stored) {
2581
2721
  deserializeSettings(stored, initialLoad);
2722
+ } else {
2723
+ // HACK: ensure handleInitialLoad is called even for first run
2724
+ // Combined with the below, this feels very redundant, and I'd like to
2725
+ // move to a subscription-based pattern, more of a formal "settings store", something like that.
2726
+ // This is currently necessary for sound effects to work on the first run of the web demo.
2727
+ deserializeSettings(serializeSettings(), initialLoad);
2582
2728
  }
2583
2729
  if (initialLoad && (!stored || !stored.globalSettings || Object.keys(stored.globalSettings).length === 0)) {
2584
2730
  // We could just call setOptions in both cases,
@@ -2692,9 +2838,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2692
2838
  const settingsLoadedPromise = loadOptions(true);
2693
2839
 
2694
2840
  // Don't use WebGL because clmTracker is our fallback! It's also not much slower than with WebGL.
2695
- var clmTracker = new clm.tracker({ useWebGL: false });
2841
+ let clmTracker = new clm.tracker({ useWebGL: false });
2696
2842
  clmTracker.init();
2697
- var clmTrackingStarted = false;
2843
+ let clmTrackingStarted = false;
2698
2844
 
2699
2845
  const stopCameraStream = () => {
2700
2846
  if (cameraVideo.srcObject) {
@@ -2720,11 +2866,21 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2720
2866
  pointsBasedOnFaceScore = 0;
2721
2867
  faceScore = 0;
2722
2868
  faceConvergence = 0;
2723
- lastTimeWhenAnEyeWasOpen = Infinity; // far future rather than far past so that sleep gesture doesn't trigger initially, skipping the delay
2869
+ sleepGestureProgress = 0;
2724
2870
  updateStartStopButton();
2725
2871
  };
2726
2872
 
2727
- let showedCameraError = false;
2873
+ // Handle monkey-patched alert() replacement in face-landmarks-detection.min.js
2874
+ // (Hm, could make it throw instead. Then we wouldn't need this.)
2875
+ window._TrackyMouse_faceLandmarksDetectionAlert = (message) => {
2876
+ // TODO: i18n (it's just one message; we could check for the string (or not) and translate it)
2877
+ // const isContextCreationMessage = message === "Failed to create WebGL canvas context when passing video frame.";
2878
+ errorMessage.textContent = `${t("common.warningIcon", { defaultValue: "⚠️" })} ${message}`;
2879
+ errorMessage.hidden = false;
2880
+ };
2881
+
2882
+ const cameraAccessSlowWarningDelayMS = 5000;
2883
+ let cameraAccessSlowWarningTimeoutID;
2728
2884
  useCameraButton.onclick = TrackyMouse.useCamera = async (optionsOrEvent = {}) => {
2729
2885
  // Phases:
2730
2886
  // 1. "tryPreferredCamera"
@@ -2814,8 +2970,15 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2814
2970
  delete constraints.video.facingMode;
2815
2971
  constraints.video.deviceId = { exact: deviceIdToTry };
2816
2972
  }
2973
+ clearTimeout(cameraAccessSlowWarningTimeoutID);
2974
+ errorMessage.hidden = true;
2975
+ cameraAccessSlowWarningTimeoutID = setTimeout(() => {
2976
+ errorMessage.textContent = t("video.status.accessTakingLongerThanExpected", { defaultValue: "Accessing the camera is taking longer than expected..." });
2977
+ errorMessage.hidden = false;
2978
+ }, cameraAccessSlowWarningDelayMS);
2817
2979
  console.log("TrackyMouse.useCamera phase", phase, "constraints", constraints);
2818
2980
  navigator.mediaDevices.getUserMedia(constraints).then(async (stream) => {
2981
+ clearTimeout(cameraAccessSlowWarningTimeoutID);
2819
2982
  if (phase === "justGetPermission") {
2820
2983
  for (const track of stream.getTracks()) {
2821
2984
  track.stop();
@@ -2838,6 +3001,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2838
3001
  useCameraButton.hidden = true;
2839
3002
  errorMessage.hidden = true;
2840
3003
  }, async (error) => {
3004
+ clearTimeout(cameraAccessSlowWarningTimeoutID);
2841
3005
  console.log("TrackyMouse.useCamera phase", phase, "error", error);
2842
3006
  if (
2843
3007
  phase === "tryPreferredCamera" &&
@@ -2849,7 +3013,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2849
3013
  }
2850
3014
  if (error.name == "NotFoundError" || error.name == "DevicesNotFoundError") {
2851
3015
  // required track is missing
2852
- errorMessage.textContent = t("video.errors.noCameraFound", { defaultValue: "No camera found. Please make sure you have a camera connected and enabled." });
3016
+ showError(t("video.errors.noCameraFound", { defaultValue: "No camera found. Please make sure you have a camera connected and enabled." }));
2853
3017
  } else if (error.name == "NotReadableError" || error.name == "TrackStartError") {
2854
3018
  // webcam is already in use
2855
3019
  // or: OBS Virtual Camera is present but OBS is not running with Virtual Camera started
@@ -2857,18 +3021,18 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2857
3021
  // (listing devices and showing only the OBS Virtual Camera would also be a good clue in itself;
2858
3022
  // though care should be given to make it clear it's a list with one item, with something like "(no more cameras detected)" following the list
2859
3023
  // or "1 camera source detected" preceding it)
2860
- errorMessage.textContent = t("video.errors.cameraInUse", { defaultValue: "Webcam is already in use. Please make sure you have no other programs using the camera." });
3024
+ showError(t("video.errors.cameraInUse", { defaultValue: "Webcam is already in use. Please make sure you have no other programs using the camera." }));
2861
3025
  } else if (error.name === "AbortError") {
2862
3026
  // webcam is likely already in use
2863
3027
  // I observed AbortError in Firefox 132.0.2 but I don't know it's used exclusively for this case.
2864
3028
  // Update: it definitely isn't, but I can't say exactly what it means in other cases.
2865
3029
  // Like, it might have to do with permissions being denied outside of a user gesture (distinct from the user denying the permission)
2866
3030
  // I really hope that isn't the problem.
2867
- // errorMessage.textContent = "Webcam may already be in use. Please make sure you have no other programs using the camera.";
2868
- errorMessage.textContent = t("video.errors.retryAfterClosingOtherPrograms", { defaultValue: "Please make sure no other programs are using the camera and try again." });
3031
+ // showError("Webcam may already be in use. Please make sure you have no other programs using the camera.");
3032
+ showError(t("video.errors.retryAfterClosingOtherPrograms", { defaultValue: "Please make sure no other programs are using the camera and try again." }));
2869
3033
  // A more honest/helpful message might be:
2870
- // errorMessage.textContent = "Please try again and then make sure no other programs are using the camera and try again again.";
2871
- // errorMessage.textContent = "Please try again before/after making sure no other programs are using the camera.";
3034
+ // showError("Please try again and then make sure no other programs are using the camera and try again again.");
3035
+ // showError("Please try again before/after making sure no other programs are using the camera.");
2872
3036
  // if it were not to be confusing.
2873
3037
  // That is, one could save some time by just hitting the button to try again before trying to figure out of another program is using the camera,
2874
3038
  // because sometimes that's enough.
@@ -2879,36 +3043,27 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2879
3043
  // either due to the device not being present, or the ID having changed (don't ask me why that can happen but it can)
2880
3044
  // Note: OverconstrainedError has a `constraint` property but not in Firefox so it's not very helpful.
2881
3045
  if (constraints.video.deviceId?.exact) {
2882
- // errorMessage.textContent = "The previously selected camera is not available. Please select a different camera from the dropdown and try again.";
2883
- // errorMessage.textContent = "The previously selected camera is not available. Please mess around with Video > Camera source.";
2884
- // errorMessage.textContent = "The previously selected camera is not available. Try changing Video > Camera source.";
2885
- // errorMessage.textContent = "The previously selected camera is not available. Please select a camera from the \"Camera source\" dropdown in the Video settings and if it doesn't show up, it might after you select Default.";
2886
- errorMessage.textContent = t("video.errors.previouslySelectedUnavailable", { defaultValue: "The previously selected camera is not available. Try selecting \"Default\" for Video > Camera source, and then select a specific camera if you need to." });
3046
+ // showError("The previously selected camera is not available. Please select a different camera from the dropdown and try again.");
3047
+ // showError("The previously selected camera is not available. Please mess around with Video > Camera source.");
3048
+ // showError("The previously selected camera is not available. Try changing Video > Camera source.");
3049
+ // showError("The previously selected camera is not available. Please select a camera from the \"Camera source\" dropdown in the Video settings and if it doesn't show up, it might after you select Default.");
3050
+ showError(t("video.errors.previouslySelectedUnavailable", { defaultValue: "The previously selected camera is not available. Try selecting \"Default\" for Video > Camera source, and then select a specific camera if you need to." }));
2887
3051
  // It's awkward but that's my best attempt at conveying how you may need to proceed
2888
3052
  // without complicated description of how/why the dropdown might be populated with
2889
3053
  // fake information until a camera stream is successfully opened.
2890
3054
  } else {
2891
- errorMessage.textContent = t("video.errors.unsupportedResolution", { defaultValue: "Webcam does not support the required resolution. Please change your settings." });
3055
+ showError(t("video.errors.unsupportedResolution", { defaultValue: "Webcam does not support the required resolution. Please change your settings." }));
2892
3056
  }
2893
3057
  } else if (error.name == "NotAllowedError" || error.name == "PermissionDeniedError") {
2894
3058
  // permission denied in browser
2895
- errorMessage.textContent = t("video.errors.permissionDenied", { defaultValue: "Permission denied. Please enable access to the camera." });
3059
+ showError(t("video.errors.permissionDenied", { defaultValue: "Permission denied. Please enable access to the camera." }));
2896
3060
  } else if (error.name == "TypeError") {
2897
3061
  // empty constraints object
2898
- errorMessage.textContent = `${t("video.errors.accessFailed", { defaultValue: "Something went wrong accessing the camera." })} (${error.name}: ${error.message})`;
3062
+ showError(t("video.errors.accessFailed", { defaultValue: "Something went wrong accessing the camera." }), error);
2899
3063
  } else {
2900
3064
  // other errors
2901
- errorMessage.textContent = `${t("video.errors.accessFailedRetry", { defaultValue: "Something went wrong accessing the camera. Please try again." })} (${error.name}: ${error.message})`;
3065
+ showError(t("video.errors.accessFailedRetry", { defaultValue: "Something went wrong accessing the camera. Please try again." }), error);
2902
3066
  }
2903
- errorMessage.textContent = `${t("common.warningIcon", { defaultValue: "⚠️" })} ${errorMessage.textContent}`;
2904
- errorMessage.hidden = false;
2905
- // Play CSS animation only on retries
2906
- errorMessage.style.animation = "none";
2907
- if (showedCameraError) {
2908
- void errorMessage.offsetWidth; // trigger reflow to allow restarting animation
2909
- errorMessage.style.animation = "";
2910
- }
2911
- showedCameraError = true;
2912
3067
  });
2913
3068
  };
2914
3069
  useDemoFootageButton.onclick = TrackyMouse.useDemoFootage = () => {
@@ -3008,7 +3163,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3008
3163
  }
3009
3164
  addPoint(x, y) {
3010
3165
  if (this.pointCount < maxPoints) {
3011
- var pointIndex = this.pointCount * 2;
3166
+ let pointIndex = this.pointCount * 2;
3012
3167
  this.curXY[pointIndex] = x;
3013
3168
  this.curXY[pointIndex + 1] = y;
3014
3169
  this.prevXY[pointIndex] = x;
@@ -3017,8 +3172,8 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3017
3172
  }
3018
3173
  }
3019
3174
  filterPoints(condition) {
3020
- var outputPointIndex = 0;
3021
- for (var inputPointIndex = 0; inputPointIndex < this.pointCount; inputPointIndex++) {
3175
+ let outputPointIndex = 0;
3176
+ for (let inputPointIndex = 0; inputPointIndex < this.pointCount; inputPointIndex++) {
3022
3177
  if (condition(inputPointIndex)) {
3023
3178
  if (outputPointIndex < inputPointIndex) {
3024
3179
  const inputOffset = inputPointIndex * 2;
@@ -3066,10 +3221,10 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3066
3221
  [this.prevPyramid, this.curPyramid] = [this.curPyramid, this.prevPyramid];
3067
3222
 
3068
3223
  // these are options worth breaking out and exploring
3069
- var winSize = 20;
3070
- var maxIterations = 30;
3071
- var epsilon = 0.01;
3072
- var minEigen = 0.001;
3224
+ let winSize = 20;
3225
+ let maxIterations = 30;
3226
+ let epsilon = 0.01;
3227
+ let minEigen = 0.001;
3073
3228
 
3074
3229
  jsfeat.imgproc.grayscale(imageData.data, imageData.width, imageData.height, this.curPyramid.data[0]);
3075
3230
  this.curPyramid.build(this.curPyramid.data[0], true);
@@ -3083,9 +3238,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3083
3238
  this.prunePoints();
3084
3239
  }
3085
3240
  draw(ctx) {
3086
- for (var i = 0; i < this.pointCount; i++) {
3087
- var pointOffset = i * 2;
3088
- // var distMoved = Math.hypot(
3241
+ for (let i = 0; i < this.pointCount; i++) {
3242
+ let pointOffset = i * 2;
3243
+ // let distMoved = Math.hypot(
3089
3244
  // this.prevXY[pointOffset] - this.curXY[pointOffset],
3090
3245
  // this.prevXY[pointOffset + 1] - this.curXY[pointOffset + 1]
3091
3246
  // );
@@ -3103,11 +3258,11 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3103
3258
  }
3104
3259
  }
3105
3260
  getMovement() {
3106
- var movementX = 0;
3107
- var movementY = 0;
3108
- var numMovements = 0;
3109
- for (var i = 0; i < this.pointCount; i++) {
3110
- var pointOffset = i * 2;
3261
+ let movementX = 0;
3262
+ let movementY = 0;
3263
+ let numMovements = 0;
3264
+ for (let i = 0; i < this.pointCount; i++) {
3265
+ let pointOffset = i * 2;
3111
3266
  movementX += this.curXY[pointOffset] - this.prevXY[pointOffset];
3112
3267
  movementY += this.curXY[pointOffset + 1] - this.prevXY[pointOffset + 1];
3113
3268
  numMovements += 1;
@@ -3144,9 +3299,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3144
3299
  // in order to keep a smooth overall tracking calculation,
3145
3300
  // don't add points if they're close to an existing point.
3146
3301
  // Otherwise, it would not just be redundant, but often remove the older points, in the pruning.
3147
- for (var pointIndex = 0; pointIndex < oops.pointCount; pointIndex++) {
3148
- var pointOffset = pointIndex * 2;
3149
- // var distance = Math.hypot(
3302
+ for (let pointIndex = 0; pointIndex < oops.pointCount; pointIndex++) {
3303
+ let pointOffset = pointIndex * 2;
3304
+ // let distance = Math.hypot(
3150
3305
  // x - oops.curXY[pointOffset],
3151
3306
  // y - oops.curXY[pointOffset + 1]
3152
3307
  // );
@@ -3183,6 +3338,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3183
3338
  return ((px - x1) * nx + (py - y1) * ny) / Math.hypot(nx, ny);
3184
3339
  }
3185
3340
 
3341
+ let lastTimestamp = -Infinity;
3186
3342
  function draw(update = true) {
3187
3343
  ctx.resetTransform(); // in case there is an error, don't flip constantly back and forth due to mirroring
3188
3344
  ctx.clearRect(0, 0, canvas.width, canvas.height); // in case there's no footage
@@ -3197,6 +3353,13 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3197
3353
  ctx.drawImage(cameraVideo, 0, 0, canvas.width, canvas.height);
3198
3354
  }
3199
3355
 
3356
+ const timestamp = performance.now();
3357
+ const deltaTime = Math.min(timestamp - lastTimestamp, 100);
3358
+ lastTimestamp = timestamp;
3359
+
3360
+ sleepSweep?.setEnabled(s.closeEyesToToggle);
3361
+ sleepSweep?.update(sleepGestureProgress);
3362
+
3200
3363
  if (!pointTracker) {
3201
3364
  return;
3202
3365
  }
@@ -3332,39 +3495,77 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3332
3495
 
3333
3496
  // TODO: separate confidence threshold for removing vs adding points?
3334
3497
 
3498
+
3335
3499
  // cull points to those within useful facial region
3336
- pointTracker.filterPoints((pointIndex) => {
3337
- var pointOffset = pointIndex * 2;
3500
+ function regionFilter([x, y]) {
3501
+
3338
3502
  // distance from tip of nose (stretched so make an ellipse taller than wide)
3339
- var distance = Math.hypot(
3340
- (annotations.noseTip[0][0] - pointTracker.curXY[pointOffset]) * 1.4,
3341
- annotations.noseTip[0][1] - pointTracker.curXY[pointOffset + 1]
3503
+ let distance = Math.hypot(
3504
+ (annotations.noseTip[0][0] - x) * 1.4,
3505
+ annotations.noseTip[0][1] - y
3342
3506
  );
3343
- var headSize = Math.hypot(
3507
+ let headSize = Math.hypot(
3344
3508
  annotations.leftCheek[0][0] - annotations.rightCheek[0][0],
3345
3509
  annotations.leftCheek[0][1] - annotations.rightCheek[0][1]
3346
3510
  );
3347
3511
  if (distance > headSize) {
3348
3512
  return false;
3349
3513
  }
3514
+ // Avoid mouth affecting pointer position.
3515
+ distance = annotations.lipsLowerInner.map((lipPoint) =>
3516
+ Math.min(
3517
+ Math.hypot(lipPoint[0] - x, lipPoint[1] - y),
3518
+ Math.hypot(lipPoint[0] - x, lipPoint[1] + headSize * 0.1 - y), // a bit below too
3519
+ Math.hypot(lipPoint[0] - x, lipPoint[1] + headSize * 0.2 - y), // a bit below too
3520
+ Math.hypot(lipPoint[0] - x, lipPoint[1] + headSize * 0.3 - y), // a bit below too
3521
+ Math.hypot(lipPoint[0] - x, lipPoint[1] + headSize * 0.4 - y), // a bit below too (yeah I'm being a little lazy here)
3522
+ )
3523
+ ).reduce((a, b) => Math.min(a, b), Infinity);
3524
+ if (distance < headSize * 0.1) {
3525
+ return false;
3526
+ }
3350
3527
  // Avoid blinking eyes affecting pointer position.
3351
3528
  // distance to outer corners of eyes
3352
3529
  distance = Math.min(
3353
3530
  Math.hypot(
3354
- annotations.leftEyeLower0[0][0] - pointTracker.curXY[pointOffset],
3355
- annotations.leftEyeLower0[0][1] - pointTracker.curXY[pointOffset + 1]
3531
+ annotations.leftEyeLower0[0][0] - x,
3532
+ annotations.leftEyeLower0[0][1] - y
3356
3533
  ),
3357
3534
  Math.hypot(
3358
- annotations.rightEyeLower0[0][0] - pointTracker.curXY[pointOffset],
3359
- annotations.rightEyeLower0[0][1] - pointTracker.curXY[pointOffset + 1]
3535
+ annotations.rightEyeLower0[0][0] - x,
3536
+ annotations.rightEyeLower0[0][1] - y
3360
3537
  ),
3361
3538
  );
3362
3539
  if (distance < headSize * 0.42) {
3363
3540
  return false;
3364
3541
  }
3365
3542
  return true;
3543
+ }
3544
+ pointTracker.filterPoints((pointIndex) => {
3545
+ let pointOffset = pointIndex * 2;
3546
+ const point = [pointTracker.curXY[pointOffset], pointTracker.curXY[pointOffset + 1]];
3547
+ return regionFilter(point);
3366
3548
  });
3367
3549
 
3550
+ // Debug visualization for region filter (a sort of heatmap of where points will be culled)
3551
+ if (showDebugRegionFilter) {
3552
+ ctx.save();
3553
+ if (s.mirror) {
3554
+ ctx.translate(canvas.width, 0);
3555
+ ctx.scale(-1, 1);
3556
+ }
3557
+ ctx.fillStyle = "rgba(255, 0, 0, 0.5)";
3558
+ const vizStep = 4;
3559
+ for (let x = 0; x < canvas.width; x += vizStep) {
3560
+ for (let y = 0; y < canvas.height; y += vizStep) {
3561
+ if (!regionFilter([x, y])) {
3562
+ ctx.fillRect(x - 5, y - 5, vizStep, vizStep);
3563
+ }
3564
+ }
3565
+ }
3566
+ ctx.restore();
3567
+ }
3568
+
3368
3569
  const keypoints = facemeshPrediction.keypoints;
3369
3570
  if (keypoints) {
3370
3571
  const top = keypoints[10]; // Top of forehead
@@ -3546,16 +3747,19 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3546
3747
 
3547
3748
  blinkInfo = detectBlinks();
3548
3749
  mouthInfo = detectMouthOpen();
3549
- if (blinkInfo.rightEye.open || blinkInfo.leftEye.open) {
3550
- lastTimeWhenAnEyeWasOpen = performance.now();
3750
+ if (!blinkInfo.rightEye.open && !blinkInfo.leftEye.open) {
3751
+ sleepGestureProgress += deltaTime / sleepGestureEyesClosedDuration;
3752
+ sleepGestureProgress = Math.min(sleepGestureProgress, 1);
3753
+ } else {
3754
+ sleepGestureProgress -= deltaTime / sleepGestureEyesClosedDuration;
3755
+ sleepGestureProgress = Math.max(sleepGestureProgress, 0);
3551
3756
  }
3552
- if (performance.now() - lastTimeWhenAnEyeWasOpen > 2000) {
3757
+ if (sleepGestureProgress >= 1) {
3758
+ sleepGestureProgress = 0;
3553
3759
  if (s.closeEyesToToggle) {
3554
3760
  paused = !paused;
3555
3761
  updatePaused();
3556
- // TODO: handle edge cases
3557
- // TODO: try to keep variable names meaningful
3558
- lastTimeWhenAnEyeWasOpen = Infinity;
3762
+ sleepSweep?.sleepModeWasToggled(paused);
3559
3763
  }
3560
3764
  }
3561
3765
 
@@ -3617,10 +3821,41 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3617
3821
 
3618
3822
  const buttonNames = ["left", "middle", "right"];
3619
3823
  for (let buttonIndex = 0; buttonIndex < 3; buttonIndex++) {
3620
- if ((clickButton === buttonIndex) !== buttonStates[buttonNames[buttonIndex]]) {
3621
- setMouseButtonState(buttonIndex, clickButton === buttonIndex);
3622
- buttonStates[buttonNames[buttonIndex]] = clickButton === buttonIndex;
3623
- if ((clickButton === buttonIndex)) {
3824
+ const buttonIsActive = clickButton === buttonIndex;
3825
+ if (buttonIsActive !== buttonStates[buttonNames[buttonIndex]]) {
3826
+ // Wait for confirmation of the button state change before playing SFX
3827
+ // but not before updating buttonStates, since we check that in this loop
3828
+ // to decide whether to call setMouseButtonState.
3829
+ // We don't want to send extraneous mouse button changes to the main process,
3830
+ // even if it does track button states itself. If nothing else it's wasted IPC.
3831
+ // That said, an argument could be made for updating lastMouseDownTime later
3832
+ // if the IPC is slow, to extend the time frame for making a simple click
3833
+ // rather than a drag.
3834
+ if (!setMouseButtonState) {
3835
+ console.warn("setMouseButtonState function not provided");
3836
+ } else {
3837
+ const maybeAPromise = setMouseButtonState(buttonIndex, buttonIsActive);
3838
+ const playSoundForButton = (changedButtonState) => {
3839
+ if (changedButtonState) {
3840
+ if (buttonIndex === 1) {
3841
+ playSound(buttonIsActive ? "middleClickPress" : "middleClickRelease", {
3842
+ volume: 4,
3843
+ });
3844
+ } else {
3845
+ playSound(buttonIsActive ? "clickPress" : "clickRelease", {
3846
+ playbackRate: buttonIndex === 0 ? 1 : buttonIndex === 2 ? 1.2 : 1.5,
3847
+ });
3848
+ }
3849
+ }
3850
+ };
3851
+ if (maybeAPromise instanceof Promise) {
3852
+ maybeAPromise.then(playSoundForButton);
3853
+ } else {
3854
+ playSoundForButton(maybeAPromise);
3855
+ }
3856
+ }
3857
+ buttonStates[buttonNames[buttonIndex]] = buttonIsActive;
3858
+ if (buttonIsActive) {
3624
3859
  lastMouseDownTime = performance.now();
3625
3860
  } else {
3626
3861
  // Limit "Delay Before Dragging" effect to the duration of a click.
@@ -3893,14 +4128,14 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3893
4128
 
3894
4129
  // cull points to those within useful facial region
3895
4130
  pointTracker.filterPoints((pointIndex) => {
3896
- var pointOffset = pointIndex * 2;
4131
+ let pointOffset = pointIndex * 2;
3897
4132
  // distance from tip of nose (stretched so make an ellipse taller than wide)
3898
- var distance = Math.hypot(
4133
+ let distance = Math.hypot(
3899
4134
  (face[62][0] - pointTracker.curXY[pointOffset]) * 1.4,
3900
4135
  face[62][1] - pointTracker.curXY[pointOffset + 1]
3901
4136
  );
3902
4137
  // distance based on outer eye corners
3903
- var headSize = Math.hypot(
4138
+ let headSize = Math.hypot(
3904
4139
  face[23][0] - face[28][0],
3905
4140
  face[23][1] - face[28][1]
3906
4141
  );
@@ -3925,21 +4160,23 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3925
4160
  pointTracker.draw(debugPointsCtx);
3926
4161
 
3927
4162
  if (update) {
3928
- const screenWidth = window.electronAPI ? screen.width : innerWidth;
3929
- const screenHeight = window.electronAPI ? screen.height : innerHeight;
4163
+ const screenWidth = window.electronAPI ? (virtualDisplayBounds?.width ?? screen.width) : innerWidth;
4164
+ const screenHeight = window.electronAPI ? (virtualDisplayBounds?.height ?? screen.height) : innerHeight;
4165
+ const screenOffsetX = window.electronAPI ? (virtualDisplayBounds?.x ?? 0) : 0;
4166
+ const screenOffsetY = window.electronAPI ? (virtualDisplayBounds?.y ?? 0) : 0;
3930
4167
 
3931
- var [movementX, movementY] = pointTracker.getMovement();
4168
+ let [movementX, movementY] = pointTracker.getMovement();
3932
4169
 
3933
4170
  // Acceleration curves add a lot of stability,
3934
4171
  // letting you focus on a specific point without jitter, but still move quickly.
3935
4172
 
3936
- // var accelerate = (delta, distance) => (delta / 10) * (distance ** 0.8);
3937
- // var accelerate = (delta, distance) => (delta / 1) * (Math.abs(delta) ** 0.8);
3938
- var accelerate = (delta, _distance) => (delta / 1) * (Math.abs(delta * 5) ** s.headTrackingAcceleration);
4173
+ // let accelerate = (delta, distance) => (delta / 10) * (distance ** 0.8);
4174
+ // let accelerate = (delta, distance) => (delta / 1) * (Math.abs(delta) ** 0.8);
4175
+ let accelerate = (delta, _distance) => (delta / 1) * (Math.abs(delta * 5) ** s.headTrackingAcceleration);
3939
4176
 
3940
- var distance = Math.hypot(movementX, movementY);
3941
- var deltaX = accelerate(movementX * s.headTrackingSensitivityX, distance);
3942
- var deltaY = accelerate(movementY * s.headTrackingSensitivityY, distance);
4177
+ let distance = Math.hypot(movementX, movementY);
4178
+ let deltaX = accelerate(movementX * s.headTrackingSensitivityX, distance);
4179
+ let deltaY = accelerate(movementY * s.headTrackingSensitivityY, distance);
3943
4180
 
3944
4181
  if (s.headTrackingTiltInfluence > 0) {
3945
4182
  const yawRange = [
@@ -4056,13 +4293,13 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
4056
4293
  mouseX -= deltaX * screenWidth;
4057
4294
  mouseY += deltaY * screenHeight;
4058
4295
 
4059
- mouseX = Math.min(Math.max(0, mouseX), screenWidth);
4060
- mouseY = Math.min(Math.max(0, mouseY), screenHeight);
4296
+ mouseX = Math.min(Math.max(screenOffsetX, mouseX), screenOffsetX + screenWidth);
4297
+ mouseY = Math.min(Math.max(screenOffsetY, mouseY), screenOffsetY + screenHeight);
4061
4298
 
4062
4299
  if (mouseNeedsInitPos) {
4063
4300
  // TODO: option to get preexisting mouse position instead of set it to center of screen
4064
- mouseX = screenWidth / 2;
4065
- mouseY = screenHeight / 2;
4301
+ mouseX = screenOffsetX + screenWidth / 2;
4302
+ mouseY = screenOffsetY + screenHeight / 2;
4066
4303
  mouseNeedsInitPos = false;
4067
4304
  }
4068
4305
  if (window.electronAPI) {
@@ -4112,7 +4349,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
4112
4349
 
4113
4350
  // Can't use requestAnimationFrame, doesn't work with webPreferences.backgroundThrottling: false (at least in some version of Electron (v12 I think, when I tested it), on Ubuntu, with XFCE)
4114
4351
  const iid = setInterval(function animationLoop() {
4115
- draw(!paused || document.visibilityState === "visible");
4352
+ draw(!paused || document.visibilityState === "visible" || isDesktopApp);
4116
4353
  }, 15);
4117
4354
 
4118
4355
  let autoDemo = false;
@@ -4191,6 +4428,15 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
4191
4428
  _waitForSettingsLoaded() {
4192
4429
  return settingsLoadedPromise;
4193
4430
  },
4431
+ get _facemeshPrediction() {
4432
+ return facemeshPrediction;
4433
+ },
4434
+ get _headTilt() {
4435
+ return headTilt;
4436
+ },
4437
+ get _video() {
4438
+ return cameraVideo;
4439
+ },
4194
4440
  dispose() {
4195
4441
  // TODO: re-structure so that cleanup can succeed even if initialization fails
4196
4442
  // OOP would help with this, by storing references in an object, but it doesn't necessarily
@@ -4285,11 +4531,13 @@ TrackyMouse.init = function (div, opts = {}) {
4285
4531
 
4286
4532
  createInner();
4287
4533
 
4288
- return {
4289
- dispose() {
4290
- inner.dispose();
4291
- },
4292
- };
4534
+ return new Proxy({}, {
4535
+ get(_target, prop) {
4536
+ if (prop in inner) {
4537
+ return inner[prop];
4538
+ }
4539
+ }
4540
+ });
4293
4541
 
4294
4542
  };
4295
4543
 
@@ -4297,21 +4545,24 @@ TrackyMouse.initScreenOverlay = () => {
4297
4545
 
4298
4546
  const template = `
4299
4547
  <div class="tracky-mouse-hide-near-cursor">
4300
- <div class="tracky-mouse-absolute-center">
4301
- <div class="tracky-mouse-screen-overlay-status-indicator tracky-mouse-manual-takeback-indicator">
4302
- <img src="${TrackyMouse.dependenciesRoot}/images/manual-takeback.svg" alt="hand reaching for mouse" width="128" height="128">
4303
- </div>
4304
- <div class="tracky-mouse-screen-overlay-status-indicator tracky-mouse-head-not-found-indicator">
4305
- <img src="${TrackyMouse.dependenciesRoot}/images/head-not-found.svg" alt="head not found" width="128" height="128">
4548
+ <div id="tracky-mouse-screen-overlay-work-area">
4549
+ <div class="tracky-mouse-absolute-center">
4550
+ <div class="tracky-mouse-screen-overlay-status-indicator tracky-mouse-manual-takeback-indicator">
4551
+ <img src="${TrackyMouse.dependenciesRoot}/images/manual-takeback.svg" alt="hand reaching for mouse" width="128" height="128">
4552
+ </div>
4553
+ <div class="tracky-mouse-screen-overlay-status-indicator tracky-mouse-head-not-found-indicator">
4554
+ <img src="${TrackyMouse.dependenciesRoot}/images/head-not-found.svg" alt="head not found" width="128" height="128">
4555
+ </div>
4306
4556
  </div>
4557
+ <div id="tracky-mouse-screen-overlay-message"></div>
4307
4558
  </div>
4308
- <div id="tracky-mouse-screen-overlay-message"></div>
4309
4559
  </div>
4310
4560
  `;
4311
4561
  const fragment = document.createRange().createContextualFragment(template);
4312
4562
  document.body.appendChild(fragment);
4313
4563
 
4314
4564
  const message = document.getElementById("tracky-mouse-screen-overlay-message");
4565
+ const workAreaContainer = document.getElementById("tracky-mouse-screen-overlay-work-area");
4315
4566
  message.dir = "auto";
4316
4567
 
4317
4568
  const hideNearCursorEls = document.querySelectorAll(".tracky-mouse-hide-near-cursor");
@@ -4358,9 +4609,30 @@ TrackyMouse.initScreenOverlay = () => {
4358
4609
  }
4359
4610
 
4360
4611
  function update(data) {
4361
- const { messageText, isEnabled, isManualTakeback, inputFeedback, bottomOffset, systemMousePosition } = data;
4362
-
4363
- message.style.bottom = `${bottomOffset}px`;
4612
+ const {
4613
+ messageText,
4614
+ isEnabled,
4615
+ isManualTakeback,
4616
+ inputFeedback,
4617
+ workAreaContainerBounds,
4618
+ bottomOffset,
4619
+ systemMousePosition,
4620
+ } = data;
4621
+
4622
+ if (workAreaContainerBounds) {
4623
+ workAreaContainer.style.left = `${workAreaContainerBounds.x}px`;
4624
+ workAreaContainer.style.top = `${workAreaContainerBounds.y}px`;
4625
+ workAreaContainer.style.width = `${workAreaContainerBounds.width}px`;
4626
+ workAreaContainer.style.height = `${workAreaContainerBounds.height}px`;
4627
+ message.style.bottom = "0px";
4628
+ } else {
4629
+ // bottomOffset was a never-released part of an unstable API.
4630
+ // workAreaContainerBounds could be made required, just like bottomOffset was.
4631
+ workAreaContainer.style.left = "0px";
4632
+ workAreaContainer.style.top = "0px";
4633
+ workAreaContainer.style.width = "100%";
4634
+ workAreaContainer.style.height = `calc(100% - ${bottomOffset ?? 0}px)`;
4635
+ }
4364
4636
 
4365
4637
  // Other diagnostics in the future would be stuff like:
4366
4638
  // - head too far away (smaller than a certain size) https://github.com/1j01/tracky-mouse/issues/49