tracky-mouse 2.5.0 → 2.7.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 (73) 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/audio.js +145 -0
  9. package/images/head-not-found.svg +135 -0
  10. package/images/manual-takeback.svg +127 -0
  11. package/locales/ar/translation.json +199 -202
  12. package/locales/ar-EG/translation.json +199 -202
  13. package/locales/bg/translation.json +199 -202
  14. package/locales/bn/translation.json +199 -202
  15. package/locales/ca/translation.json +199 -202
  16. package/locales/ce/translation.json +199 -202
  17. package/locales/ceb/translation.json +199 -202
  18. package/locales/cs/translation.json +199 -202
  19. package/locales/da/translation.json +199 -202
  20. package/locales/de/translation.json +199 -202
  21. package/locales/el/translation.json +199 -202
  22. package/locales/emoji/translation.json +199 -202
  23. package/locales/en/translation.json +199 -202
  24. package/locales/eo/translation.json +199 -202
  25. package/locales/es/translation.json +199 -202
  26. package/locales/eu/translation.json +199 -202
  27. package/locales/fa/translation.json +199 -202
  28. package/locales/fi/translation.json +199 -202
  29. package/locales/fr/translation.json +199 -202
  30. package/locales/gu/translation.json +199 -202
  31. package/locales/ha/translation.json +199 -202
  32. package/locales/he/translation.json +199 -202
  33. package/locales/hi/translation.json +199 -202
  34. package/locales/hr/translation.json +199 -202
  35. package/locales/hu/translation.json +199 -202
  36. package/locales/hy/translation.json +199 -202
  37. package/locales/id/translation.json +199 -202
  38. package/locales/it/translation.json +199 -202
  39. package/locales/ja/translation.json +199 -202
  40. package/locales/jv/translation.json +199 -202
  41. package/locales/ko/translation.json +199 -202
  42. package/locales/mr/translation.json +199 -202
  43. package/locales/ms/translation.json +199 -202
  44. package/locales/nan/translation.json +199 -202
  45. package/locales/nb/translation.json +199 -202
  46. package/locales/nl/translation.json +199 -202
  47. package/locales/pa/translation.json +199 -202
  48. package/locales/pl/translation.json +199 -202
  49. package/locales/pt/translation.json +199 -202
  50. package/locales/pt-BR/translation.json +199 -202
  51. package/locales/ro/translation.json +199 -202
  52. package/locales/ru/translation.json +199 -202
  53. package/locales/sk/translation.json +199 -202
  54. package/locales/sl/translation.json +199 -202
  55. package/locales/sr/translation.json +199 -202
  56. package/locales/sv/translation.json +199 -202
  57. package/locales/sw/translation.json +199 -202
  58. package/locales/ta/translation.json +199 -202
  59. package/locales/te/translation.json +199 -202
  60. package/locales/th/translation.json +199 -202
  61. package/locales/tl/translation.json +199 -202
  62. package/locales/tr/translation.json +199 -202
  63. package/locales/tt/translation.json +199 -202
  64. package/locales/uk/translation.json +199 -202
  65. package/locales/ur/translation.json +199 -202
  66. package/locales/uz/translation.json +199 -202
  67. package/locales/vi/translation.json +199 -202
  68. package/locales/war/translation.json +199 -202
  69. package/locales/zh/translation.json +199 -202
  70. package/locales/zh-simplified/translation.json +200 -203
  71. package/package.json +4 -1
  72. package/tracky-mouse.css +73 -7
  73. package/tracky-mouse.js +590 -343
package/tracky-mouse.js CHANGED
@@ -52,103 +52,114 @@ 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
- const t = (s) => s;
81
+ const t = (key, options = {}) => options.defaultValue ?? key;
76
82
 
77
83
  if (typeof config !== "object") {
78
- throw new Error(t("configuration object required for initDwellClicking"));
84
+ throw new Error(t("api.errors.configRequired", { defaultValue: "configuration object required for initDwellClicking" }));
79
85
  }
80
86
  if (config.targets === undefined) {
81
- throw new Error(t("config.targets is required (must be a CSS selector)"));
87
+ throw new Error(t("api.errors.targetsRequired", { defaultValue: "config.targets is required (must be a CSS selector)" }));
82
88
  }
83
89
  if (typeof config.targets !== "string") {
84
- throw new Error(t("config.targets must be a string (a CSS selector)"));
90
+ throw new Error(t("api.errors.targetsMustBeSelectorString", { defaultValue: "config.targets must be a string (a CSS selector)" }));
85
91
  }
86
92
  if (!isSelectorValid(config.targets)) {
87
- throw new Error(t("config.targets is not a valid CSS selector"));
93
+ throw new Error(t("api.errors.targetsInvalidSelector", { defaultValue: "config.targets is not a valid CSS selector" }));
88
94
  }
89
95
  if (config.click === undefined) {
90
- throw new Error(t("config.click is required"));
96
+ throw new Error(t("api.errors.clickRequired", { defaultValue: "config.click is required" }));
91
97
  }
92
98
  if (typeof config.click !== "function") {
93
- throw new Error(t("config.click must be a function"));
99
+ throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.click"));
94
100
  }
95
101
  if (config.shouldDrag !== undefined && typeof config.shouldDrag !== "function") {
96
- throw new Error(t("config.shouldDrag must be a function"));
102
+ throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.shouldDrag"));
97
103
  }
98
104
  if (config.noCenter !== undefined && typeof config.noCenter !== "function") {
99
- throw new Error(t("config.noCenter must be a function"));
105
+ throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.noCenter"));
100
106
  }
101
107
  if (config.isEquivalentTarget !== undefined && typeof config.isEquivalentTarget !== "function") {
102
- throw new Error(t("config.isEquivalentTarget must be a function"));
108
+ throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.isEquivalentTarget"));
103
109
  }
104
110
  if (config.dwellClickEvenIfPaused !== undefined && typeof config.dwellClickEvenIfPaused !== "function") {
105
- throw new Error(t("config.dwellClickEvenIfPaused must be a function"));
111
+ throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.dwellClickEvenIfPaused"));
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"));
106
115
  }
107
116
  if (config.beforeDispatch !== undefined && typeof config.beforeDispatch !== "function") {
108
- throw new Error(t("config.beforeDispatch must be a function"));
117
+ throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.beforeDispatch"));
109
118
  }
110
119
  if (config.afterDispatch !== undefined && typeof config.afterDispatch !== "function") {
111
- throw new Error(t("config.afterDispatch must be a function"));
120
+ throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.afterDispatch"));
112
121
  }
113
122
  if (config.beforePointerDownDispatch !== undefined && typeof config.beforePointerDownDispatch !== "function") {
114
- throw new Error(t("config.beforePointerDownDispatch must be a function"));
123
+ throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.beforePointerDownDispatch"));
115
124
  }
116
125
  if (config.isHeld !== undefined && typeof config.isHeld !== "function") {
117
- throw new Error(t("config.isHeld must be a function"));
126
+ throw new Error(t("api.errors.functionRequired", { defaultValue: "%0 must be a function" }).replace("%0", "config.isHeld"));
118
127
  }
119
128
  if (config.retarget !== undefined) {
120
129
  if (!Array.isArray(config.retarget)) {
121
- throw new Error(t("config.retarget must be an array of objects"));
130
+ throw new Error(t("api.errors.retargetMustBeArray", { defaultValue: "config.retarget must be an array of objects" }));
122
131
  }
123
132
  for (let i = 0; i < config.retarget.length; i++) {
124
133
  const rule = config.retarget[i];
125
134
  if (typeof rule !== "object") {
126
- throw new Error(t("config.retarget must be an array of objects"));
135
+ throw new Error(t("api.errors.retargetMustBeArray", { defaultValue: "config.retarget must be an array of objects" }));
127
136
  }
128
137
  if (rule.from === undefined) {
129
- throw new Error(t("config.retarget[%0].from is required").replace("%0", i));
138
+ throw new Error(t("api.errors.retargetFromRequired", { defaultValue: "config.retarget[%0].from is required" }).replace("%0", i));
130
139
  }
131
140
  if (rule.to === undefined) {
132
- throw new Error(t("config.retarget[%0].to is required (although can be null to ignore the element)").replace("%0", i));
141
+ throw new Error(t("api.errors.retargetToRequired", { defaultValue: "config.retarget[%0].to is required (although can be null to ignore the element)" }).replace("%0", i));
133
142
  }
134
143
  if (rule.withinMargin !== undefined && typeof rule.withinMargin !== "number") {
135
- throw new Error(t("config.retarget[%0].withinMargin must be a number").replace("%0", i));
144
+ throw new Error(t("api.errors.numberRequired", { defaultValue: "%0 must be a number" }).replace("%0", `config.retarget[${i}].withinMargin`));
136
145
  }
137
146
  if (typeof rule.from !== "string" && typeof rule.from !== "function" && !(rule.from instanceof Element)) {
138
- throw new Error(t("config.retarget[%0].from must be a CSS selector string, an Element, or a function").replace("%0", i));
147
+ throw new Error(t("api.errors.retargetFromInvalidType", { defaultValue: "config.retarget[%0].from must be a CSS selector string, an Element, or a function" }).replace("%0", i));
139
148
  }
140
149
  if (typeof rule.to !== "string" && typeof rule.to !== "function" && !(rule.to instanceof Element) && rule.to !== null) {
141
- throw new Error(t("config.retarget[%0].to must be a CSS selector string, an Element, a function, or null").replace("%0", i));
150
+ throw new Error(t("api.errors.retargetToInvalidType", { defaultValue: "config.retarget[%0].to must be a CSS selector string, an Element, a function, or null" }).replace("%0", i));
142
151
  }
143
152
  if (typeof rule.from === "string" && !isSelectorValid(rule.from)) {
144
- throw new Error(t("config.retarget[%0].from is not a valid CSS selector").replace("%0", i));
153
+ throw new Error(t("api.errors.retargetFromInvalidSelector", { defaultValue: "config.retarget[%0].from is not a valid CSS selector" }).replace("%0", i));
145
154
  }
146
155
  if (typeof rule.to === "string" && !isSelectorValid(rule.to)) {
147
- throw new Error(t("config.retarget[%0].to is not a valid CSS selector").replace("%0", i));
156
+ throw new Error(t("api.errors.retargetToInvalidSelector", { defaultValue: "config.retarget[%0].to is not a valid CSS selector" }).replace("%0", i));
148
157
  }
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,7 +597,57 @@ TrackyMouse.cleanupDwellClicking = function () {
571
597
  }
572
598
  };
573
599
 
574
- TrackyMouse._initInner = function (div, { statsJs = false }, reinit) {
600
+ TrackyMouse._initAudio = async function () {
601
+ let module;
602
+ try {
603
+ // console.log("Loading audio support...");
604
+ module = await import("./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
+
622
+ TrackyMouse._initInner = function (div, initOptions, reinit) {
623
+
624
+ const {
625
+ statsJs = false,
626
+ // Unstable
627
+ updateInputFeedback = window.electronAPI?.updateInputFeedback,
628
+ // Unstable
629
+ setMouseButtonState = window.electronAPI?.setMouseButtonState,
630
+ // Unstable
631
+ notifyToggleState = window.electronAPI?.notifyToggleState,
632
+ // Unstable
633
+ handleSettingsUpdate,
634
+ // Unstable
635
+ clickingModeSupported = false,
636
+ // TODO: manage all of electronAPI similarly? well, setOptions is already a function in scope here,
637
+ // and it's not like we want to expose all electronAPI as part of the public API necessarily
638
+ // Could group things under an "unstable" object, or ideally, design nice APIs for everything.
639
+ } = initOptions;
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
+ });
575
651
 
576
652
  const isDesktopApp = !!window.electronAPI;
577
653
 
@@ -617,7 +693,7 @@ TrackyMouse._initInner = function (div, { statsJs = false }, reinit) {
617
693
  }
618
694
  const rtlLanguages = ["ar", "he", "fa", "ur"]; // Right-to-left languages (current and future)
619
695
  const isRTL = rtlLanguages.includes(locale.split("-")[0]);
620
- const t = (s) => translations[s] ?? s;
696
+ const t = (key, options = {}) => translations[key] ?? options.defaultValue ?? key;
621
697
  // console.trace("Initializing UI with locale", locale);
622
698
 
623
699
  // language name mappings marked with * may not be ISO 639-1
@@ -826,7 +902,7 @@ TrackyMouse._initInner = function (div, { statsJs = false }, reinit) {
826
902
  };
827
903
 
828
904
 
829
- var languageToDefaultRegion = {
905
+ let languageToDefaultRegion = {
830
906
  aa: "ET",
831
907
  ab: "GE",
832
908
  abr: "GH",
@@ -1571,9 +1647,9 @@ TrackyMouse._initInner = function (div, { statsJs = false }, reinit) {
1571
1647
  // </svg>`;
1572
1648
  }
1573
1649
 
1574
- var split = locale.toUpperCase().split(/-|_/);
1575
- var lang = split.shift();
1576
- var code = split.pop();
1650
+ let split = locale.toUpperCase().split(/-|_/);
1651
+ let lang = split.shift();
1652
+ let code = split.pop();
1577
1653
 
1578
1654
  if (!/^[A-Z]{2}$/.test(code)) {
1579
1655
  code = languageToDefaultRegion[lang.toLowerCase()];
@@ -1588,38 +1664,38 @@ TrackyMouse._initInner = function (div, { statsJs = false }, reinit) {
1588
1664
  return a + b;
1589
1665
  }
1590
1666
 
1591
- var uiContainer = div || document.createElement("div");
1667
+ let uiContainer = div || document.createElement("div");
1592
1668
  uiContainer.classList.add("tracky-mouse-ui");
1593
1669
  uiContainer.classList.toggle("tracky-mouse-rtl", isRTL);
1594
1670
  uiContainer.dir = isRTL ? "rtl" : "ltr";
1595
1671
  uiContainer.innerHTML = `
1596
1672
  <div class="tracky-mouse-controls">
1597
- <button class="tracky-mouse-start-stop-button" aria-pressed="false" aria-keyshortcuts="F9">${t("Start")}</button>
1673
+ <button class="tracky-mouse-start-stop-button" aria-pressed="false" aria-keyshortcuts="F9">${t("ui.startStopButton.start", { defaultValue: "Start" })}</button>
1598
1674
  </div>
1599
1675
  <div class="tracky-mouse-canvas-container-container">
1600
1676
  <div class="tracky-mouse-canvas-container">
1601
1677
  <div class="tracky-mouse-canvas-overlay">
1602
- <button class="tracky-mouse-use-camera-button">${t("Allow Camera Access")}</button>
1603
- <!--<button class="tracky-mouse-use-camera-button">${t("Use my camera")}</button>-->
1604
- <button class="tracky-mouse-use-demo-footage-button" hidden>${t("Use demo footage")}</button>
1678
+ <button class="tracky-mouse-use-camera-button">${t("ui.camera.allowAccess", { defaultValue: "Allow Camera Access" })}</button>
1679
+ <!--<button class="tracky-mouse-use-camera-button">${t("ui.camera.useMyCamera", { defaultValue: "Use my camera" })}</button>-->
1680
+ <button class="tracky-mouse-use-demo-footage-button" hidden>${t("ui.camera.useDemoFootage", { defaultValue: "Use demo footage" })}</button>
1605
1681
  <div class="tracky-mouse-error-message" role="alert" hidden></div>
1606
1682
  </div>
1607
1683
  <canvas class="tracky-mouse-canvas"></canvas>
1608
1684
  </div>
1609
1685
  </div>
1610
1686
  <p class="tracky-mouse-desktop-app-download-message">
1611
- ${t('You can control your entire computer with the <a href="https://trackymouse.js.org/">TrackyMouse</a> desktop app.')}
1687
+ ${t("ui.desktopAppPromo.message", { defaultValue: 'You can control your entire computer with the <a href="https://trackymouse.js.org/">TrackyMouse</a> desktop app.' })}
1612
1688
  </p>
1613
1689
  `;
1614
1690
  if (!div) {
1615
1691
  document.body.appendChild(uiContainer);
1616
1692
  }
1617
- var startStopButton = uiContainer.querySelector(".tracky-mouse-start-stop-button");
1618
- var useCameraButton = uiContainer.querySelector(".tracky-mouse-use-camera-button");
1619
- var useDemoFootageButton = uiContainer.querySelector(".tracky-mouse-use-demo-footage-button");
1620
- var errorMessage = uiContainer.querySelector(".tracky-mouse-error-message");
1621
- var canvasContainer = uiContainer.querySelector('.tracky-mouse-canvas-container');
1622
- 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');
1623
1699
 
1624
1700
  // Settings (initialized later; defaults are defined in settingsCategories)
1625
1701
  const s = {};
@@ -1631,10 +1707,10 @@ TrackyMouse._initInner = function (div, { statsJs = false }, reinit) {
1631
1707
  const settingsCategories = [
1632
1708
  {
1633
1709
  type: "group",
1634
- label: t("Cursor Movement"),
1710
+ label: t("settings.sections.cursorMovement.label", { defaultValue: "Cursor Movement" }),
1635
1711
  settings: [
1636
1712
  {
1637
- label: t("Tilt influence"),
1713
+ label: t("settings.tiltInfluence.label", { defaultValue: "Tilt influence" }),
1638
1714
  className: "tracky-mouse-tilt-influence",
1639
1715
  key: "headTrackingTiltInfluence",
1640
1716
  settingValueToInputValue: (settingValue) => settingValue * 100,
@@ -1644,20 +1720,21 @@ TrackyMouse._initInner = function (div, { statsJs = false }, reinit) {
1644
1720
  max: 100,
1645
1721
  default: 0,
1646
1722
  labels: {
1647
- // min: t("Optical flow"), // too technical
1648
- // min: t("Point tracking"), // still technical but at least it's terminology we're already using
1649
- min: t("Point tracking (2D)"),
1650
- // max: t("Head tilt"),
1651
- max: t("Head tilt (3D)"),
1723
+ // min: t("settings.tiltInfluence.sliderMin.alt1", { defaultValue: "Optical flow" }), // too technical
1724
+ // min: t("settings.tiltInfluence.sliderMin.alt2", { defaultValue: "Point tracking" }), // still technical but at least it's terminology we're already using
1725
+ min: t("settings.tiltInfluence.sliderMin", { defaultValue: "Point tracking (2D)" }),
1726
+ // max: t("settings.tiltInfluence.sliderMax.alt1", { defaultValue: "Head tilt" }),
1727
+ max: t("settings.tiltInfluence.sliderMax", { defaultValue: "Head tilt (3D)" }),
1652
1728
  },
1653
- // description: t("Determines whether cursor movement is based on 3D head tilt, or 2D motion of the face in the camera feed."),
1654
- description: t(`Blends between using point tracking (2D) and detected head tilt (3D).
1729
+ // description: t("settings.tiltInfluence.description.alt1", { defaultValue: "Determines whether cursor movement is based on 3D head tilt, or 2D motion of the face in the camera feed." }),
1730
+ description: t("settings.tiltInfluence.description", {
1731
+ defaultValue: `Blends between using point tracking (2D) and detected head tilt (3D).
1655
1732
  - At 0% it will use only point tracking. This moves the cursor according to visible movement of 2D points on your face within the camera's view, so it responds to both head rotation and translation.
1656
1733
  - At 100% it will use only head tilt. This uses an estimate of your face's orientation in 3D space, and ignores head translation. Note that this is smoothed, so it's not as responsive as point tracking. In this mode you never need to recenter by pushing the cursor to the edge of the screen.
1657
- - In between it will behave like an automatic calibration, subtly adjusting the point tracking to match the head tilt. This works by slowing down mouse movement that is moving away from the position that would be expected based on the head tilt, and (only past 80% on the slider) actively moving towards it.`),
1734
+ - In between it will behave like an automatic calibration, subtly adjusting the point tracking to match the head tilt. This works by slowing down mouse movement that is moving away from the position that would be expected based on the head tilt, and (only past 80% on the slider) actively moving towards it.` }),
1658
1735
  },
1659
1736
  {
1660
- label: t("Motion threshold"),
1737
+ label: t("settings.motionThreshold.label", { defaultValue: "Motion threshold" }),
1661
1738
  className: "tracky-mouse-min-distance",
1662
1739
  key: "headTrackingMinDistance",
1663
1740
  type: "slider",
@@ -1665,20 +1742,20 @@ TrackyMouse._initInner = function (div, { statsJs = false }, reinit) {
1665
1742
  max: 10,
1666
1743
  default: 0,
1667
1744
  labels: {
1668
- min: t("Free"),
1669
- max: t("Steady"),
1745
+ min: t("settings.motionThreshold.sliderMin", { defaultValue: "Free" }),
1746
+ max: t("settings.motionThreshold.sliderMax", { defaultValue: "Steady" }),
1670
1747
  },
1671
- description: t("Minimum distance to move the cursor in one frame, in pixels. Helps to fully stop the cursor."),
1672
- // description: t("Movement less than this distance in pixels will be ignored."),
1673
- // description: t("Speed in pixels/frame required to move the cursor."),
1748
+ description: t("settings.motionThreshold.description", { defaultValue: "Minimum distance to move the cursor in one frame, in pixels. Helps to fully stop the cursor." }),
1749
+ // description: t("settings.motionThreshold.description.alt1", { defaultValue: "Movement less than this distance in pixels will be ignored." }),
1750
+ // description: t("settings.motionThreshold.description.alt2", { defaultValue: "Speed in pixels/frame required to move the cursor." }),
1674
1751
  },
1675
1752
  {
1676
1753
  type: "group",
1677
- label: t("Point tracking"),
1754
+ label: t("settings.sections.pointTracking.label", { defaultValue: "Point tracking" }),
1678
1755
  disabled: () => s.headTrackingTiltInfluence === 1,
1679
1756
  settings: [
1680
1757
  {
1681
- label: t("Horizontal sensitivity"),
1758
+ label: t("settings.pointTracking.horizontalSensitivity.label", { defaultValue: "Horizontal sensitivity" }),
1682
1759
  className: "tracky-mouse-sensitivity-x",
1683
1760
  key: "headTrackingSensitivityX",
1684
1761
  settingValueToInputValue: (settingValue) => settingValue * 1000,
@@ -1688,13 +1765,13 @@ TrackyMouse._initInner = function (div, { statsJs = false }, reinit) {
1688
1765
  max: 100,
1689
1766
  default: 25,
1690
1767
  labels: {
1691
- min: t("Slow"),
1692
- max: t("Fast"),
1768
+ min: t("settings.shared.sliderMinSlow", { defaultValue: "Slow" }),
1769
+ max: t("settings.shared.sliderMaxFast", { defaultValue: "Fast" }),
1693
1770
  },
1694
- description: t("Speed of cursor movement in response to horizontal head movement."),
1771
+ description: t("settings.pointTracking.horizontalSensitivity.description", { defaultValue: "Speed of cursor movement in response to horizontal head movement." }),
1695
1772
  },
1696
1773
  {
1697
- label: t("Vertical sensitivity"),
1774
+ label: t("settings.pointTracking.verticalSensitivity.label", { defaultValue: "Vertical sensitivity" }),
1698
1775
  className: "tracky-mouse-sensitivity-y",
1699
1776
  key: "headTrackingSensitivityY",
1700
1777
  settingValueToInputValue: (settingValue) => settingValue * 1000,
@@ -1704,13 +1781,13 @@ TrackyMouse._initInner = function (div, { statsJs = false }, reinit) {
1704
1781
  max: 100,
1705
1782
  default: 50,
1706
1783
  labels: {
1707
- min: t("Slow"),
1708
- max: t("Fast"),
1784
+ min: t("settings.shared.sliderMinSlow", { defaultValue: "Slow" }),
1785
+ max: t("settings.shared.sliderMaxFast", { defaultValue: "Fast" }),
1709
1786
  },
1710
- description: t("Speed of cursor movement in response to vertical head movement."),
1787
+ description: t("settings.pointTracking.verticalSensitivity.description", { defaultValue: "Speed of cursor movement in response to vertical head movement." }),
1711
1788
  },
1712
1789
  // {
1713
- // label: t("Smoothing"),
1790
+ // label: t("settings.pointTracking.smoothing.label", { defaultValue: "Smoothing" }),
1714
1791
  // className: "tracky-mouse-smoothing",
1715
1792
  // key: "headTrackingSmoothing",
1716
1793
  // type: "slider",
@@ -1718,8 +1795,8 @@ TrackyMouse._initInner = function (div, { statsJs = false }, reinit) {
1718
1795
  // max: 100,
1719
1796
  // default: 50,
1720
1797
  // labels: {
1721
- // min: t("Linear"), // or "Direct", "Raw", "None"
1722
- // max: t("Smooth"), // or "Smoothed"
1798
+ // min: t("settings.shared.sliderMinLinear", { defaultValue: "Linear" }), // or "Direct", "Raw", "None"
1799
+ // max: t("settings.shared.sliderMaxSmooth", { defaultValue: "Smooth" }), // or "Smoothed"
1723
1800
  // },
1724
1801
  // },
1725
1802
 
@@ -1732,7 +1809,7 @@ TrackyMouse._initInner = function (div, { statsJs = false }, reinit) {
1732
1809
  // Should it be swapped? What does other software with acceleration control look like?
1733
1810
  // In Windows it's just a checkbox apparently, but it could go as far as a custom curve editor.
1734
1811
  {
1735
- label: t("Acceleration"),
1812
+ label: t("settings.pointTracking.acceleration.label", { defaultValue: "Acceleration" }),
1736
1813
  className: "tracky-mouse-acceleration",
1737
1814
  key: "headTrackingAcceleration",
1738
1815
  settingValueToInputValue: (settingValue) => settingValue * 100,
@@ -1742,23 +1819,24 @@ TrackyMouse._initInner = function (div, { statsJs = false }, reinit) {
1742
1819
  max: 100,
1743
1820
  default: 50,
1744
1821
  labels: {
1745
- min: t("Linear"), // or "Direct", "Raw"
1746
- max: t("Smooth"),
1822
+ min: t("settings.shared.sliderMinLinear", { defaultValue: "Linear" }), // or "Direct", "Raw"
1823
+ max: t("settings.shared.sliderMaxSmooth", { defaultValue: "Smooth" }),
1747
1824
  },
1748
- // description: t("Higher acceleration makes the cursor move faster when the head moves quickly, and slower when the head moves slowly."),
1749
- // description: t("Makes the cursor move extra fast for quick head movements, and extra slow for slow head movements. Helps to stabilize the cursor."),
1750
- description: t(`Makes the cursor move relatively fast for quick head movements, and relatively slow for slow head movements.
1751
- Helps to stabilize the cursor. However, when using point tracking in combination with head tilt, a lower value may work better since head tilt is linear, and you want the point tracking to roughly match the head tracking for it to act as a seamless auto- calibration.`),
1825
+ // description: t("settings.pointTracking.acceleration.description.alt1", { defaultValue: "Higher acceleration makes the cursor move faster when the head moves quickly, and slower when the head moves slowly." }),
1826
+ // description: t("settings.pointTracking.acceleration.description.alt2", { defaultValue: "Makes the cursor move extra fast for quick head movements, and extra slow for slow head movements. Helps to stabilize the cursor." }),
1827
+ description: t("settings.pointTracking.acceleration.description", {
1828
+ defaultValue: `Makes the cursor move relatively fast for quick head movements, and relatively slow for slow head movements.
1829
+ Helps to stabilize the cursor. However, when using point tracking in combination with head tilt, a lower value may work better since head tilt is linear, and you want the point tracking to roughly match the head tracking for it to act as a seamless auto-calibration.` }),
1752
1830
  },
1753
1831
  ],
1754
1832
  },
1755
1833
  {
1756
1834
  type: "group",
1757
- label: t("Head tilt calibration"),
1835
+ label: t("settings.sections.headTiltCalibration.label", { defaultValue: "Head tilt calibration" }),
1758
1836
  disabled: () => s.headTrackingTiltInfluence === 0,
1759
1837
  settings: [
1760
1838
  {
1761
- label: t("Horizontal tilt range"),
1839
+ label: t("settings.headTilt.horizontalRange.label", { defaultValue: "Horizontal tilt range" }),
1762
1840
  className: "tracky-mouse-head-tilt-yaw-range",
1763
1841
  key: "headTiltYawRange",
1764
1842
  settingValueToInputValue: (settingValue) => settingValue * 180 / Math.PI,
@@ -1768,16 +1846,16 @@ Helps to stabilize the cursor. However, when using point tracking in combination
1768
1846
  max: 90,
1769
1847
  default: 60,
1770
1848
  labels: {
1771
- min: t("Little neck movement"),
1772
- max: t("Large neck movement"),
1849
+ min: t("settings.headTilt.range.sliderMinLittleNeckMovement", { defaultValue: "Little neck movement" }),
1850
+ max: t("settings.headTilt.range.sliderMaxLargeNeckMovement", { defaultValue: "Large neck movement" }),
1773
1851
  },
1774
- // description: t("Range of horizontal head tilt that moves the cursor from one side of the screen to the other."),
1775
- // description: t("How much you need to tilt your head left and right to reach the edges of the screen."),
1776
- // description: t("How much you need to tilt your head left or right to reach the edge of the screen."),
1777
- description: t("Controls how much you need to tilt your head left or right to reach the edge of the screen."),
1852
+ // description: t("settings.headTilt.horizontalRange.description.alt1", { defaultValue: "Range of horizontal head tilt that moves the cursor from one side of the screen to the other." }),
1853
+ // description: t("settings.headTilt.horizontalRange.description.alt2", { defaultValue: "How much you need to tilt your head left and right to reach the edges of the screen." }),
1854
+ // description: t("settings.headTilt.horizontalRange.description.alt3", { defaultValue: "How much you need to tilt your head left or right to reach the edge of the screen." }),
1855
+ description: t("settings.headTilt.horizontalRange.description", { defaultValue: "Controls how much you need to tilt your head left or right to reach the edge of the screen." }),
1778
1856
  },
1779
1857
  {
1780
- label: t("Vertical tilt range"),
1858
+ label: t("settings.headTilt.verticalRange.label", { defaultValue: "Vertical tilt range" }),
1781
1859
  className: "tracky-mouse-head-tilt-pitch-range",
1782
1860
  key: "headTiltPitchRange",
1783
1861
  settingValueToInputValue: (settingValue) => settingValue * 180 / Math.PI,
@@ -1787,17 +1865,17 @@ Helps to stabilize the cursor. However, when using point tracking in combination
1787
1865
  max: 60,
1788
1866
  default: 25,
1789
1867
  labels: {
1790
- min: t("Little neck movement"),
1791
- max: t("Large neck movement"),
1868
+ min: t("settings.headTilt.range.sliderMinLittleNeckMovement", { defaultValue: "Little neck movement" }),
1869
+ max: t("settings.headTilt.range.sliderMaxLargeNeckMovement", { defaultValue: "Large neck movement" }),
1792
1870
  },
1793
- // description: t("Range of vertical head tilt required to move the cursor from the top to the bottom of the screen."),
1794
- // description: t("How much you need to tilt your head up and down to reach the edges of the screen."),
1795
- // description: t("How much you need to tilt your head up or down to reach the edge of the screen."),
1796
- description: t("Controls how much you need to tilt your head up or down to reach the edge of the screen."),
1871
+ // description: t("settings.headTilt.verticalRange.description.alt1", { defaultValue: "Range of vertical head tilt required to move the cursor from the top to the bottom of the screen." }),
1872
+ // description: t("settings.headTilt.verticalRange.description.alt2", { defaultValue: "How much you need to tilt your head up and down to reach the edges of the screen." }),
1873
+ // description: t("settings.headTilt.verticalRange.description.alt3", { defaultValue: "How much you need to tilt your head up or down to reach the edge of the screen." }),
1874
+ description: t("settings.headTilt.verticalRange.description", { defaultValue: "Controls how much you need to tilt your head up or down to reach the edge of the screen." }),
1797
1875
  },
1798
1876
  {
1799
1877
  // label: "Horizontal tilt offset",
1800
- label: t("Horizontal cursor offset"),
1878
+ label: t("settings.headTilt.horizontalOffset.label", { defaultValue: "Horizontal cursor offset" }),
1801
1879
  className: "tracky-mouse-head-tilt-yaw-offset",
1802
1880
  key: "headTiltYawOffset",
1803
1881
  settingValueToInputValue: (settingValue) => settingValue * 180 / Math.PI,
@@ -1807,8 +1885,8 @@ Helps to stabilize the cursor. However, when using point tracking in combination
1807
1885
  max: 45,
1808
1886
  default: 0,
1809
1887
  labels: {
1810
- min: t("Left"),
1811
- max: t("Right"),
1888
+ min: t("settings.shared.directionLeft", { defaultValue: "Left" }),
1889
+ max: t("settings.shared.directionRight", { defaultValue: "Right" }),
1812
1890
  },
1813
1891
  // TODO: how to describe this??
1814
1892
  // Specifically, how to disambiguate which direction is which / which way to adjust it?
@@ -1816,15 +1894,16 @@ Helps to stabilize the cursor. However, when using point tracking in combination
1816
1894
  // Since it's opposite, even though it's technically yaw (angle units), it's easier to think of as moving the cursor.
1817
1895
  // Hence I've renamed the setting.
1818
1896
  // A later update might change the definitions and include a settings file format upgrade step.
1819
- // description: t("Adjusts the center position of horizontal head tilt. Not recommended. Move the camera instead if possible."),
1820
- // description: t("Adjusts the center position of horizontal head tilt. This horizontal offset is not recommended. Move the camera instead if possible."),
1897
+ // description: t("settings.headTilt.horizontalOffset.description.alt1", { defaultValue: "Adjusts the center position of horizontal head tilt. Not recommended. Move the camera instead if possible." }),
1898
+ // description: t("settings.headTilt.horizontalOffset.description.alt2", { defaultValue: "Adjusts the center position of horizontal head tilt. This horizontal offset is not recommended. Move the camera instead if possible." }),
1821
1899
  // TODO: should this say "horizontal" in the (main part of the) description?
1822
- description: t(`Adjusts the position of the cursor when the camera sees the head facing straight ahead.
1823
- ⚠️ This horizontal offset is not recommended. Move the camera instead if possible. 📷`),
1900
+ description: t("settings.headTilt.horizontalOffset.description", {
1901
+ defaultValue: `Adjusts the position of the cursor when the camera sees the head facing straight ahead.
1902
+ ⚠️ This horizontal offset is not recommended. Move the camera instead if possible. 📷` }),
1824
1903
  },
1825
1904
  {
1826
1905
  // label: "Vertical tilt offset",
1827
- label: t("Vertical cursor offset"),
1906
+ label: t("settings.headTilt.verticalOffset.label", { defaultValue: "Vertical cursor offset" }),
1828
1907
  className: "tracky-mouse-head-tilt-pitch-offset",
1829
1908
  key: "headTiltPitchOffset",
1830
1909
  settingValueToInputValue: (settingValue) => settingValue * 180 / Math.PI,
@@ -1834,11 +1913,11 @@ Helps to stabilize the cursor. However, when using point tracking in combination
1834
1913
  max: 30,
1835
1914
  default: 2.5,
1836
1915
  labels: {
1837
- min: t("Down"),
1838
- max: t("Up"),
1916
+ min: t("settings.shared.directionDown", { defaultValue: "Down" }),
1917
+ max: t("settings.shared.directionUp", { defaultValue: "Up" }),
1839
1918
  },
1840
- // description: t("Adjusts the center position of vertical head tilt."),
1841
- description: t("Adjusts the position of the cursor when the camera sees the head facing straight ahead."),
1919
+ // description: t("settings.headTilt.verticalOffset.description.alt1", { defaultValue: "Adjusts the center position of vertical head tilt." }),
1920
+ description: t("settings.headTilt.verticalOffset.description", { defaultValue: "Adjusts the position of the cursor when the camera sees the head facing straight ahead." }),
1842
1921
  },
1843
1922
  ],
1844
1923
  },
@@ -1858,42 +1937,43 @@ Helps to stabilize the cursor. However, when using point tracking in combination
1858
1937
  // which awkwardly affects what mouse button serenade-driver sends; this doesn't affect the web version.
1859
1938
  {
1860
1939
  type: "group",
1861
- label: t("Clicking"),
1940
+ label: t("settings.sections.clicking.label", { defaultValue: "Clicking" }),
1862
1941
  settings: [
1863
1942
  {
1864
- label: t("Clicking mode:"), // TODO: ":"?
1943
+ label: t("settings.clickingMode.label", { defaultValue: "Clicking mode:" }), // TODO: ":"?
1865
1944
  className: "tracky-mouse-clicking-mode",
1866
1945
  key: "clickingMode",
1867
1946
  type: "dropdown",
1868
1947
  options: [
1869
- { value: "dwell", label: t("Dwell to click"), description: t("Hold the cursor in place for a short time to click.") },
1870
- { value: "blink", label: t("Wink to click"), description: t("Close one eye to click. Left eye for left click, right eye for right click.") },
1948
+ { value: "dwell", label: t("settings.clickingMode.dwell.label", { defaultValue: "Dwell to click" }), description: t("settings.clickingMode.dwell.description", { defaultValue: "Hold the cursor in place for a short time to click." }) },
1949
+ { value: "blink", label: t("settings.clickingMode.wink.label", { defaultValue: "Wink to click" }), description: t("settings.clickingMode.wink.description", { defaultValue: "Close one eye to click. Left eye for left click, right eye for right click." }) },
1871
1950
  // TODO: clarify that ooh works better than ah
1872
1951
  // "open wide" refers to height, but could be misinterpreted as opposite advice - a wide mouth shape when narrow works better
1873
1952
  // "open wide" is also perhaps unnecessary considering detection is improved... but who knows. maybe someone will try opening their mouth only slightly and expect it to work
1874
1953
  // Some people may understand "tall and narrow" better than "ooh rather than ah" and visa-versa
1875
- { value: "open-mouth-simple", label: t("Open mouth to click (simple)"), description: t("Open your mouth wide to click. At least one eye must be open to click.") },
1876
- { value: "open-mouth-ignoring-eyes", label: t("Open mouth to click (ignoring eyes)"), description: t("Open your mouth wide to click. Eye state is ignored.") },
1877
- { value: "open-mouth", label: t("Open mouth to click (with eye modifiers)"), description: t("Open your mouth wide to click. If left eye is closed, it's a right click; if right eye is closed, it's a middle click.") },
1878
- { value: "off", label: t("Off"), description: t("Disable clicking. Use with an external switch or programs that provide their own dwell clicking.") },
1954
+ { value: "open-mouth-simple", label: t("settings.clickingMode.openMouthSimple.label", { defaultValue: "Open mouth to click (simple)" }), description: t("settings.clickingMode.openMouthSimple.description", { defaultValue: "Open your mouth wide to click. At least one eye must be open to click." }) },
1955
+ { value: "open-mouth-ignoring-eyes", label: t("settings.clickingMode.openMouthIgnoringEyes.label", { defaultValue: "Open mouth to click (ignoring eyes)" }), description: t("settings.clickingMode.openMouthIgnoringEyes.description", { defaultValue: "Open your mouth wide to click. Eye state is ignored." }) },
1956
+ { value: "open-mouth", label: t("settings.clickingMode.openMouthWithEyeModifiers.label", { defaultValue: "Open mouth to click (with eye modifiers)" }), description: t("settings.clickingMode.openMouthWithEyeModifiers.description", { defaultValue: "Open your mouth wide to click. If left eye is closed, it's a right click; if right eye is closed, it's a middle click." }) },
1957
+ { value: "off", label: t("settings.clickingMode.off.label", { defaultValue: "Off" }), description: t("settings.clickingMode.off.description", { defaultValue: "Disable clicking. Use with an external switch or programs that provide their own dwell clicking." }) },
1879
1958
  ],
1880
1959
  default: "dwell",
1881
- visible: () => isDesktopApp,
1882
- description: t("Choose how to perform mouse clicks."),
1960
+ visible: () => isDesktopApp || clickingModeSupported,
1961
+ description: t("settings.clickingMode.description", { defaultValue: "Choose how to perform mouse clicks." }),
1883
1962
  },
1884
1963
  {
1885
1964
  // on Windows, currently, when buttons are swapped at the system level, it affects serenade-driver's click()
1886
1965
  // "swap" is purposefully generic language so we don't have to know what system-level setting is
1887
1966
  // (also this may be seen as a weirdly named/designed option for right-clicking with the dwell clicker)
1888
- label: t("Swap mouse buttons"),
1967
+ label: t("settings.swapMouseButtons.label", { defaultValue: "Swap mouse buttons" }),
1889
1968
  className: "tracky-mouse-swap-mouse-buttons",
1890
1969
  key: "swapMouseButtons",
1891
1970
  type: "checkbox",
1892
1971
  default: false,
1893
1972
  visible: () => isDesktopApp,
1894
- description: t(`Switches the left and right mouse buttons.
1973
+ description: t("settings.swapMouseButtons.description", {
1974
+ defaultValue: `Switches the left and right mouse buttons.
1895
1975
  Useful if your system's mouse buttons are swapped.
1896
- Could also be used to right click with the dwell clicker in a pinch.`),
1976
+ Could also be used to right click with the dwell clicker in a pinch.` }),
1897
1977
  },
1898
1978
 
1899
1979
  // This setting could called "click stabilization", "drag delay", "delay before dragging", "click drag delay", "drag prevention", etc.
@@ -1902,33 +1982,37 @@ Could also be used to right click with the dwell clicker in a pinch.`),
1902
1982
  // at the end of the slider, although you shouldn't need to do that to effectively avoid dragging when trying to click,
1903
1983
  // and it might complicate the design of the slider labeling.
1904
1984
  {
1905
- label: t("Delay before dragging&nbsp;&nbsp;&nbsp;"), // TODO: avoid non-breaking space hack
1985
+ label: t("settings.delayBeforeDragging.label", { defaultValue: "Delay before dragging" }),
1906
1986
  className: "tracky-mouse-delay-before-dragging",
1907
1987
  key: "delayBeforeDragging",
1908
1988
  type: "slider",
1909
1989
  min: 0,
1910
1990
  max: 1000,
1911
1991
  labels: {
1912
- min: t("Easy to drag"),
1913
- max: t("Easy to click"),
1992
+ min: t("settings.delayBeforeDragging.sliderMin", { defaultValue: "Easy to drag" }),
1993
+ max: t("settings.delayBeforeDragging.sliderMax", { defaultValue: "Easy to click" }),
1914
1994
  },
1915
1995
  default: 800,
1916
- visible: () => isDesktopApp,
1996
+ visible: () => isDesktopApp || clickingModeSupported,
1917
1997
  disabled: () => s.clickingMode === "off" || s.clickingMode === "dwell",
1918
- // description: t("Locks mouse movement during the start of a click to prevent accidental dragging."),
1919
- // description: t(`Prevents mouse movement for the specified time after a click starts.
1920
- // You may want to turn this off if you're drawing on a canvas, or increase it if you find yourself accidentally dragging when you try to click.`),
1921
- description: t(`Locks mouse movement for the given duration during the start of a click.
1922
- You may want to turn this off if you're drawing on a canvas, or increase it if you find yourself accidentally dragging when you try to click.`),
1998
+ // description: t("settings.delayBeforeDragging.description.alt1", { defaultValue: "Locks mouse movement during the start of a click to prevent accidental dragging." }),
1999
+ // Throwing a // in here so it's not detected by i18next-cli, whereas the others are allowed
2000
+ // simply because it wasn't previously detected and translated
2001
+ // due to being both commented out and multiline (though multiline and commented out t() calls are separately supported)
2002
+ // description: t//("settings.delayBeforeDragging.description.alt2", { defaultValue: `Prevents mouse movement for the specified time after a click starts.
2003
+ // You may want to turn this off if you're drawing on a canvas, or increase it if you find yourself accidentally dragging when you try to click.` }),
2004
+ description: t("settings.delayBeforeDragging.description", {
2005
+ defaultValue: `Locks mouse movement for the given duration during the start of a click.
2006
+ You may want to turn this off if you're drawing on a canvas, or increase it if you find yourself accidentally dragging when you try to click.` }),
1923
2007
  },
1924
2008
  ],
1925
2009
  },
1926
2010
  {
1927
2011
  type: "group",
1928
- label: t("Video"),
2012
+ label: t("settings.sections.video.label", { defaultValue: "Video" }),
1929
2013
  settings: [
1930
2014
  {
1931
- label: t("Camera source"),
2015
+ label: t("settings.cameraSource.label", { defaultValue: "Camera source" }),
1932
2016
  className: "tracky-mouse-camera-select",
1933
2017
  key: "cameraDeviceId",
1934
2018
  handleSettingChange: () => {
@@ -1936,15 +2020,15 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1936
2020
  },
1937
2021
  type: "dropdown",
1938
2022
  options: [
1939
- { value: "", label: t("Default") },
2023
+ { value: "", label: t("settings.cameraSource.defaultCamera", { defaultValue: "Default" }) },
1940
2024
  ],
1941
2025
  default: "",
1942
- // description: t("Select which camera to use for head tracking."),
1943
- description: t("Selects which camera is used for head tracking."),
2026
+ // description: t("settings.cameraSource.description.alt1", { defaultValue: "Select which camera to use for head tracking." }),
2027
+ description: t("settings.cameraSource.description", { defaultValue: "Selects which camera is used for head tracking." }),
1944
2028
  },
1945
2029
  // TODO: move this inline with the camera source dropdown?
1946
2030
  {
1947
- label: t("Open Camera Settings"),
2031
+ label: t("settings.openCameraSettings.label", { defaultValue: "Open Camera Settings" }),
1948
2032
  className: "tracky-mouse-open-camera-settings",
1949
2033
  key: "openCameraSettings",
1950
2034
  type: "button",
@@ -1954,45 +2038,59 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1954
2038
  try {
1955
2039
  knownCameras = JSON.parse(localStorage.getItem("tracky-mouse-known-cameras")) || {};
1956
2040
  } catch (error) {
1957
- alert(t("Failed to open camera settings:\n") + t("Failed to parse known cameras from localStorage:\n") + error.message);
2041
+ 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);
1958
2042
  return;
1959
2043
  }
1960
2044
 
1961
2045
  const activeStream = cameraVideo.srcObject;
1962
2046
  const activeDeviceId = activeStream?.getVideoTracks()[0]?.getSettings()?.deviceId;
1963
- const selectedDeviceName = knownCameras[activeDeviceId]?.name || t("Default");
2047
+ const selectedDeviceName = knownCameras[activeDeviceId]?.name || t("settings.cameraSource.defaultCamera", { defaultValue: "Default" });
1964
2048
 
1965
2049
  try {
1966
2050
  const result = await window.electronAPI.openCameraSettings(selectedDeviceName);
1967
2051
  if (result?.error) {
1968
- alert(t("Failed to open camera settings:\n") + result.error);
2052
+ alert(t("openCameraSettings.errors.sharedHeading", { defaultValue: "Failed to open camera settings:" }) + "\n" + result.error);
1969
2053
  }
1970
2054
  } catch (error) {
1971
- alert(t("Failed to open camera settings:\n") + error.message);
2055
+ alert(t("openCameraSettings.errors.sharedHeading", { defaultValue: "Failed to open camera settings:" }) + "\n" + error.message);
1972
2056
  }
1973
2057
  },
1974
- // description: t("Open your camera's system settings window to adjust properties like brightness and contrast."),
1975
- // description: t("Opens the system settings window for your camera to adjust properties like auto-focus and auto-exposure."),
1976
- description: t("Opens the system settings dialog for the selected camera, to adjust properties like auto-focus and auto-exposure."),
2058
+ // description: t("settings.openCameraSettings.description.alt1", { defaultValue: "Open your camera's system settings window to adjust properties like brightness and contrast." }),
2059
+ // description: t("settings.openCameraSettings.description.alt2", { defaultValue: "Opens the system settings window for your camera to adjust properties like auto-focus and auto-exposure." }),
2060
+ description: t("settings.openCameraSettings.description", { defaultValue: "Opens the system settings dialog for the selected camera, to adjust properties like auto-focus and auto-exposure." }),
1977
2061
  },
1978
2062
  // TODO: try moving this to the corner of the camera view, so it's clearer it applies only to the camera view
1979
2063
  {
1980
- label: t("Mirror"),
2064
+ label: t("settings.mirror.label", { defaultValue: "Mirror" }),
1981
2065
  className: "tracky-mouse-mirror",
1982
2066
  key: "mirror",
1983
2067
  type: "checkbox",
1984
2068
  default: true,
1985
- description: t("Mirrors the camera view horizontally."),
2069
+ description: t("settings.mirror.description", { defaultValue: "Mirrors the camera view horizontally." }),
1986
2070
  },
1987
2071
  ]
1988
2072
  },
1989
2073
  {
1990
2074
  type: "group",
1991
- label: t("General"),
2075
+ label: t("settings.sections.general.label", { defaultValue: "General" }),
1992
2076
  settings: [
2077
+ {
2078
+ label: t("settings.soundEffects.label", { defaultValue: "Sound effects" }),
2079
+ className: "tracky-mouse-sound-effects",
2080
+ key: "soundEffects",
2081
+ type: "checkbox",
2082
+ default: true,
2083
+ afterInitialLoad: () => {
2084
+ setAudioEnabled(s.soundEffects);
2085
+ },
2086
+ handleSettingChange: () => {
2087
+ setAudioEnabled(s.soundEffects);
2088
+ },
2089
+ description: t("settings.soundEffects.description", { defaultValue: "Plays sounds when you click." }),
2090
+ },
1993
2091
  // opposite, "Start paused", might be clearer, especially if I add a "pause" button
1994
2092
  {
1995
- label: t("Start enabled"),
2093
+ label: t("settings.startEnabled.label", { defaultValue: "Start enabled" }),
1996
2094
  className: "tracky-mouse-start-enabled",
1997
2095
  key: "startEnabled",
1998
2096
  afterInitialLoad: () => { // TODO: does this hook make sense? right now it's the only usage. could this code not just be called later?
@@ -2000,10 +2098,10 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2000
2098
  },
2001
2099
  type: "checkbox",
2002
2100
  default: false,
2003
- description: t("If enabled, Tracky Mouse will start controlling the cursor as soon as it's launched."),
2004
- // description: t("Makes Tracky Mouse active when launched. Otherwise, you can start it manually when you're ready."),
2005
- // description: t("Makes Tracky Mouse active as soon as it's launched."),
2006
- // description: t("Automatically starts Tracky Mouse as soon as it's run."),
2101
+ description: t("settings.startEnabled.description", { defaultValue: "If enabled, Tracky Mouse will start controlling the cursor as soon as it's launched." }),
2102
+ // description: t("settings.startEnabled.description.alt1", { defaultValue: "Makes Tracky Mouse active when launched. Otherwise, you can start it manually when you're ready." }),
2103
+ // description: t("settings.startEnabled.description.alt2", { defaultValue: "Makes Tracky Mouse active as soon as it's launched." }),
2104
+ // description: t("settings.startEnabled.description.alt3", { defaultValue: "Automatically starts Tracky Mouse as soon as it's run." }),
2007
2105
  },
2008
2106
  {
2009
2107
  // For "experimental" label:
@@ -2011,36 +2109,37 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2011
2109
  // - I considered adding "⚠︎" but it feels a little too alarming
2012
2110
  // 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>)",
2013
2111
  // 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>)",
2014
- label: t("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>)"),
2112
+ // 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>)" }),
2113
+ label: t("settings.closeEyesToToggle.label", { defaultValue: "Close eyes to start/stop" }),
2015
2114
  className: "tracky-mouse-close-eyes-to-toggle",
2016
2115
  key: "closeEyesToToggle",
2017
2116
  type: "checkbox",
2018
2117
  default: false,
2019
- description: t("If enabled, you can start or stop mouse control by holding both your eyes shut for a few seconds."),
2118
+ description: t("settings.closeEyesToToggle.description", { defaultValue: "If enabled, you can start or stop mouse control by holding both your eyes shut for a few seconds." }),
2020
2119
  },
2021
2120
  {
2022
- label: t("Run at login"),
2121
+ label: t("settings.runAtLogin.label", { defaultValue: "Run at login" }),
2023
2122
  className: "tracky-mouse-run-at-login",
2024
2123
  key: "runAtLogin",
2025
2124
  type: "checkbox",
2026
2125
  default: false,
2027
2126
  visible: () => isDesktopApp,
2028
- description: t("If enabled, Tracky Mouse will automatically start when you log into your computer."),
2029
- // description: t("Makes Tracky Mouse start automatically when you log into your computer."),
2127
+ description: t("settings.runAtLogin.description", { defaultValue: "If enabled, Tracky Mouse will automatically start when you log into your computer." }),
2128
+ // description: t("settings.runAtLogin.description.alt1", { defaultValue: "Makes Tracky Mouse start automatically when you log into your computer." }),
2030
2129
  },
2031
2130
  {
2032
- label: t("Check for updates"),
2131
+ label: t("settings.checkForUpdates.label", { defaultValue: "Check for updates" }),
2033
2132
  className: "tracky-mouse-check-for-updates",
2034
2133
  key: "checkForUpdates",
2035
2134
  type: "checkbox",
2036
2135
  default: true,
2037
2136
  visible: () => isDesktopApp,
2038
- description: t("If enabled, Tracky Mouse will automatically check for updates when it starts."),
2039
- // description: t("Notifies you of new versions of Tracky Mouse."),
2040
- // description: t("Notifies you when a new version of Tracky Mouse is available."),
2137
+ description: t("settings.checkForUpdates.description", { defaultValue: "If enabled, Tracky Mouse will automatically check for updates when it starts." }),
2138
+ // description: t("settings.checkForUpdates.description.alt1", { defaultValue: "Notifies you of new versions of Tracky Mouse." }),
2139
+ // description: t("settings.checkForUpdates.description.alt2", { defaultValue: "Notifies you when a new version of Tracky Mouse is available." }),
2041
2140
  },
2042
2141
  {
2043
- label: t("Language"),
2142
+ label: t("settings.language.label", { defaultValue: "Language" }),
2044
2143
  className: "tracky-mouse-language",
2045
2144
  key: "language",
2046
2145
  type: "dropdown",
@@ -2058,8 +2157,8 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2058
2157
  }
2059
2158
  reinit();
2060
2159
  },
2061
- description: t("Select the language for the Tracky Mouse interface."),
2062
- // description: t("Changes the language Tracky Mouse is displayed in."),
2160
+ description: t("settings.language.description", { defaultValue: "Select the language for the Tracky Mouse interface." }),
2161
+ // description: t("settings.language.description.alt1", { defaultValue: "Changes the language Tracky Mouse is displayed in." }),
2063
2162
  },
2064
2163
  ],
2065
2164
  },
@@ -2181,7 +2280,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2181
2280
  </select>
2182
2281
  `;
2183
2282
  if (setting.options.some(option => option.description)) {
2184
- setting.description += t("\n\nOptions:\n") + setting.options.map(option => `• ${option.label}${option.description ? `: ${option.description}` : ''}`).join("\n");
2283
+ setting.description += "\n\n" + t("settings.dropdownDescriptionOptionsListHeading", { defaultValue: "Options:" }) + "\n" + setting.options.map(option => `• ${option.label}${option.description ? `: ${option.description}` : ''}`).join("\n");
2185
2284
  }
2186
2285
  } else if (setting.type === "button") {
2187
2286
  rowEl.innerHTML = `
@@ -2284,26 +2383,27 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2284
2383
  });
2285
2384
  }
2286
2385
 
2287
- var canvas = uiContainer.querySelector(".tracky-mouse-canvas");
2288
- var ctx = canvas.getContext('2d', { willReadFrequently: true });
2386
+ let canvas = uiContainer.querySelector(".tracky-mouse-canvas");
2387
+ let ctx = canvas.getContext('2d', { willReadFrequently: true });
2289
2388
 
2290
- var debugEyeCanvas = document.createElement("canvas");
2389
+ let debugEyeCanvas = document.createElement("canvas");
2291
2390
  debugEyeCanvas.className = "tracky-mouse-debug-eye-canvas";
2292
2391
  debugEyeCanvas.style.display = "none";
2293
2392
  uiContainer.querySelector(".tracky-mouse-canvas-container-container").appendChild(debugEyeCanvas);
2294
- var debugEyeCtx = debugEyeCanvas.getContext('2d');
2393
+ let debugEyeCtx = debugEyeCanvas.getContext('2d');
2295
2394
 
2296
- var pointerEl = document.createElement('div');
2395
+ let pointerEl = document.createElement('div');
2297
2396
  pointerEl.className = "tracky-mouse-pointer";
2298
2397
  pointerEl.style.display = "none";
2299
2398
  document.body.appendChild(pointerEl);
2300
2399
 
2301
- var cameraVideo = document.createElement('video');
2400
+ let cameraVideo = document.createElement('video');
2302
2401
  // required to work in iOS 11 & up:
2303
2402
  cameraVideo.setAttribute('playsinline', '');
2304
2403
 
2404
+ let stats;
2305
2405
  if (statsJs) {
2306
- var stats = new Stats();
2406
+ stats = new Stats();
2307
2407
  stats.domElement.style.position = 'fixed';
2308
2408
  stats.domElement.style.top = '0px';
2309
2409
  stats.domElement.style.right = '0px';
@@ -2312,72 +2412,74 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2312
2412
  }
2313
2413
 
2314
2414
  // Debug flags (not shown in the UI; could become Advanced Settings in the future)
2315
- var debugAcceleration = false;
2316
- var showDebugText = false;
2317
- var showDebugEyeZoom = false;
2318
- var showDebugHeadTilt = false;
2415
+ let debugAcceleration = false;
2416
+ let showDebugText = false;
2417
+ let showDebugEyeZoom = false;
2418
+ let showDebugHeadTilt = false;
2419
+ let showDebugRegionFilter = false;
2319
2420
 
2320
2421
  // Constants (could become Advanced Settings in the future)
2321
- var defaultWidth = 640;
2322
- var defaultHeight = 480;
2323
- var maxPoints = 1000;
2324
- var faceScoreThreshold = 0.5;
2325
- var facemeshOptions = {
2422
+ let defaultWidth = 640;
2423
+ let defaultHeight = 480;
2424
+ let maxPoints = 1000;
2425
+ let faceScoreThreshold = 0.5;
2426
+ let facemeshOptions = {
2326
2427
  maxContinuousChecks: 5,
2327
2428
  detectionConfidence: 0.9,
2328
2429
  maxFaces: 1,
2329
2430
  iouThreshold: 0.3,
2330
2431
  scoreThreshold: 0.75
2331
2432
  };
2332
- var useFacemesh = true;
2433
+ let useFacemesh = true;
2434
+ let sleepGestureEyesClosedDuration = 2000;
2333
2435
  // maybe should be based on size of head in view?
2334
2436
  const pruningGridSize = 5;
2335
2437
  const minDistanceToAddPoint = pruningGridSize * 1.5;
2336
2438
 
2337
2439
  // Head tracking and facial gesture state
2338
2440
  // ## Clmtrackr state
2339
- var face;
2340
- var faceScore = 0;
2341
- var faceConvergence = 0;
2342
- // var faceConvergenceThreshold = 50;
2343
- var pointsBasedOnFaceScore = 0;
2441
+ let face;
2442
+ let faceScore = 0;
2443
+ let faceConvergence = 0;
2444
+ // let faceConvergenceThreshold = 50;
2445
+ let pointsBasedOnFaceScore = 0;
2344
2446
  // ## Facemesh state
2345
2447
  let detector;
2346
2448
  let currentCameraImageData;
2347
- var facemeshLoaded = false;
2348
- var facemeshFirstEstimation = true;
2349
- var facemeshEstimating = false;
2350
- var facemeshRejectNext = 0;
2351
- var facemeshPrediction;
2352
- var facemeshEstimateFaces;
2353
- var faceInViewConfidenceThreshold = 0.7;
2354
- var pointsBasedOnFaceInViewConfidence = 0;
2355
- var cameraFramesSinceFacemeshUpdate = [];
2356
- var blinkInfo;
2357
- var mouthInfo;
2358
- var headTilt = { pitch: 0, yaw: 0, roll: 0 };
2359
- var headTiltFilters = { pitch: null, yaw: null, roll: null };
2360
- var lastTimeWhenAnEyeWasOpen = Infinity; // far future rather than far past so that sleep gesture doesn't trigger initially, skipping the delay
2449
+ let facemeshLoaded = false;
2450
+ let facemeshFirstEstimation = true;
2451
+ let facemeshEstimating = false;
2452
+ let facemeshRejectNext = 0;
2453
+ let facemeshPrediction;
2454
+ let facemeshEstimateFaces;
2455
+ let faceInViewConfidenceThreshold = 0.7;
2456
+ let pointsBasedOnFaceInViewConfidence = 0;
2457
+ let cameraFramesSinceFacemeshUpdate = [];
2458
+ let blinkInfo;
2459
+ let mouthInfo;
2460
+ let headTilt = { pitch: 0, yaw: 0, roll: 0 };
2461
+ let headTiltFilters = { pitch: null, yaw: null, roll: null };
2462
+ let sleepGestureProgress = 0;
2361
2463
  // ## State related to switching between head trackers
2362
- var useClmTracking = true;
2363
- var showClmTracking = useClmTracking;
2364
- var fallbackTimeoutID;
2464
+ let useClmTracking = true;
2465
+ let showClmTracking = useClmTracking;
2466
+ let fallbackTimeoutID;
2365
2467
 
2366
2468
  // Mouse state
2367
- var mouseX = 0;
2368
- var mouseY = 0;
2369
- var buttonStates = {
2469
+ let mouseX = 0;
2470
+ let mouseY = 0;
2471
+ let buttonStates = {
2370
2472
  left: false,
2371
2473
  right: false,
2372
2474
  middle: false,
2373
2475
  };
2374
- var mouseButtonUntilMouthCloses = -1;
2375
- var lastMouseDownTime = -Infinity;
2376
- var mouseNeedsInitPos = true;
2476
+ let mouseButtonUntilMouthCloses = -1;
2477
+ let lastMouseDownTime = -Infinity;
2478
+ let mouseNeedsInitPos = true;
2377
2479
 
2378
2480
  // Other state
2379
- var paused = true;
2380
- var pointTracker;
2481
+ let paused = true;
2482
+ let pointTracker;
2381
2483
 
2382
2484
  // Named lists of facemesh landmark indices
2383
2485
  const MESH_ANNOTATIONS = {
@@ -2489,12 +2591,15 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2489
2591
  setting._load?.(settings, initialLoad);
2490
2592
  });
2491
2593
  }
2594
+ setAudioEnabled(s.soundEffects);
2492
2595
 
2493
2596
  // Now that all settings are loaded, update disabled states
2494
2597
  for (const func of functionsToUpdateDisabledStates) {
2495
2598
  func();
2496
2599
  }
2497
2600
 
2601
+ // Unstable hook
2602
+ handleSettingsUpdate?.(settings);
2498
2603
  }
2499
2604
  const formatVersion = 1;
2500
2605
  const formatName = "tracky-mouse-settings";
@@ -2523,25 +2628,49 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2523
2628
  console.error(e);
2524
2629
  }
2525
2630
  }
2631
+ // Unstable hook
2632
+ handleSettingsUpdate?.(options);
2526
2633
  };
2527
2634
  const loadOptions = async (initialLoad = false) => {
2635
+ // Desktop app: start from any saved settings in the main process,
2636
+ // then, on first load, push the renderer's canonical defaults back
2637
+ // so the main process has the same effective settings (and can
2638
+ // correctly drive features like dwell clicking on first run).
2639
+ // Web demo: similarly needs canonical defaults pushed to
2640
+ // correctly enable dwell clicking on first run,
2641
+ // now that it supports multiple clicking modes.
2642
+ // General API usage: does not yet support multiple clicking modes
2643
+ // (there's a lot of glue code in the demo)
2644
+ // but we only call handleSettingsUpdate if it exists.
2645
+ let stored;
2528
2646
  if (window.electronAPI) {
2529
- // Desktop app: start from any saved settings in the main process,
2530
- // then, on first load, push the renderer's canonical defaults back
2531
- // so the main process has the same effective settings (and can
2532
- // correctly drive features like dwell clicking on first run).
2533
- const stored = await window.electronAPI.getOptions();
2534
- deserializeSettings(stored, initialLoad);
2535
- if (initialLoad && (!stored || !stored.globalSettings || Object.keys(stored.globalSettings).length === 0)) {
2536
- setOptions(serializeSettings());
2537
- }
2647
+ stored = await window.electronAPI.getOptions();
2538
2648
  } else {
2539
2649
  try {
2540
2650
  if (localStorage.getItem("tracky-mouse-settings")) {
2541
- deserializeSettings(JSON.parse(localStorage.getItem("tracky-mouse-settings")), initialLoad);
2651
+ stored = JSON.parse(localStorage.getItem("tracky-mouse-settings"));
2542
2652
  }
2543
2653
  } catch (e) {
2544
2654
  console.error(e);
2655
+ return;
2656
+ }
2657
+ }
2658
+ if (stored) {
2659
+ deserializeSettings(stored, initialLoad);
2660
+ } else {
2661
+ // HACK: ensure handleInitialLoad is called even for first run
2662
+ // Combined with the below, this feels very redundant, and I'd like to
2663
+ // move to a subscription-based pattern, more of a formal "settings store", something like that.
2664
+ // This is currently necessary for sound effects to work on the first run of the web demo.
2665
+ deserializeSettings(serializeSettings(), initialLoad);
2666
+ }
2667
+ if (initialLoad && (!stored || !stored.globalSettings || Object.keys(stored.globalSettings).length === 0)) {
2668
+ // We could just call setOptions in both cases,
2669
+ // but do we want to save to localStorage initially? Maybe not.
2670
+ if (window.electronAPI) {
2671
+ setOptions(serializeSettings()); // (includes handleSettingsUpdate)
2672
+ } else {
2673
+ handleSettingsUpdate?.(serializeSettings());
2545
2674
  }
2546
2675
  }
2547
2676
  };
@@ -2592,14 +2721,14 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2592
2721
 
2593
2722
  const defaultOption = document.createElement("option");
2594
2723
  defaultOption.value = "";
2595
- defaultOption.text = t("Default");
2724
+ defaultOption.text = t("settings.cameraSource.defaultCamera", { defaultValue: "Default" });
2596
2725
  cameraSelect.appendChild(defaultOption);
2597
2726
 
2598
2727
  let matchingDeviceId = "";
2599
2728
  for (const device of videoDevices) {
2600
2729
  const option = document.createElement('option');
2601
2730
  option.value = device.deviceId;
2602
- option.text = device.label || t("Camera %0").replace("%0", cameraSelect.length);
2731
+ option.text = device.label || t("settings.cameraSource.numberedCamera", { defaultValue: "Camera %0" }).replace("%0", cameraSelect.length);
2603
2732
  cameraSelect.appendChild(option);
2604
2733
  if (device.deviceId === s.cameraDeviceId) {
2605
2734
  matchingDeviceId = device.deviceId;
@@ -2617,7 +2746,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2617
2746
  const option = document.createElement("option");
2618
2747
  option.value = s.cameraDeviceId;
2619
2748
  const knownInfo = knownCameras[s.cameraDeviceId];
2620
- option.text = knownInfo ? `${knownInfo.name} (${t("Unavailable")})` : t("Unavailable camera");
2749
+ option.text = knownInfo ? `${knownInfo.name} (${t("settings.cameraSource.unavailableCameraAdjective", { defaultValue: "Unavailable" })})` : t("settings.cameraSource.unavailableCamera", { defaultValue: "Unavailable camera" });
2621
2750
  cameraSelect.appendChild(option);
2622
2751
  cameraSelect.value = s.cameraDeviceId;
2623
2752
  } else {
@@ -2647,9 +2776,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2647
2776
  const settingsLoadedPromise = loadOptions(true);
2648
2777
 
2649
2778
  // Don't use WebGL because clmTracker is our fallback! It's also not much slower than with WebGL.
2650
- var clmTracker = new clm.tracker({ useWebGL: false });
2779
+ let clmTracker = new clm.tracker({ useWebGL: false });
2651
2780
  clmTracker.init();
2652
- var clmTrackingStarted = false;
2781
+ let clmTrackingStarted = false;
2653
2782
 
2654
2783
  const stopCameraStream = () => {
2655
2784
  if (cameraVideo.srcObject) {
@@ -2675,10 +2804,11 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2675
2804
  pointsBasedOnFaceScore = 0;
2676
2805
  faceScore = 0;
2677
2806
  faceConvergence = 0;
2678
- lastTimeWhenAnEyeWasOpen = Infinity; // far future rather than far past so that sleep gesture doesn't trigger initially, skipping the delay
2807
+ sleepGestureProgress = 0;
2679
2808
  updateStartStopButton();
2680
2809
  };
2681
2810
 
2811
+ let showedCameraError = false;
2682
2812
  useCameraButton.onclick = TrackyMouse.useCamera = async (optionsOrEvent = {}) => {
2683
2813
  // Phases:
2684
2814
  // 1. "tryPreferredCamera"
@@ -2803,7 +2933,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2803
2933
  }
2804
2934
  if (error.name == "NotFoundError" || error.name == "DevicesNotFoundError") {
2805
2935
  // required track is missing
2806
- errorMessage.textContent = t("No camera found. Please make sure you have a camera connected and enabled.");
2936
+ errorMessage.textContent = t("video.errors.noCameraFound", { defaultValue: "No camera found. Please make sure you have a camera connected and enabled." });
2807
2937
  } else if (error.name == "NotReadableError" || error.name == "TrackStartError") {
2808
2938
  // webcam is already in use
2809
2939
  // or: OBS Virtual Camera is present but OBS is not running with Virtual Camera started
@@ -2811,7 +2941,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2811
2941
  // (listing devices and showing only the OBS Virtual Camera would also be a good clue in itself;
2812
2942
  // 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
2813
2943
  // or "1 camera source detected" preceding it)
2814
- errorMessage.textContent = t("Webcam is already in use. Please make sure you have no other programs using the camera.");
2944
+ errorMessage.textContent = t("video.errors.cameraInUse", { defaultValue: "Webcam is already in use. Please make sure you have no other programs using the camera." });
2815
2945
  } else if (error.name === "AbortError") {
2816
2946
  // webcam is likely already in use
2817
2947
  // I observed AbortError in Firefox 132.0.2 but I don't know it's used exclusively for this case.
@@ -2819,7 +2949,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2819
2949
  // Like, it might have to do with permissions being denied outside of a user gesture (distinct from the user denying the permission)
2820
2950
  // I really hope that isn't the problem.
2821
2951
  // errorMessage.textContent = "Webcam may already be in use. Please make sure you have no other programs using the camera.";
2822
- errorMessage.textContent = t("Please make sure no other programs are using the camera and try again.");
2952
+ errorMessage.textContent = t("video.errors.retryAfterClosingOtherPrograms", { defaultValue: "Please make sure no other programs are using the camera and try again." });
2823
2953
  // A more honest/helpful message might be:
2824
2954
  // errorMessage.textContent = "Please try again and then make sure no other programs are using the camera and try again again.";
2825
2955
  // errorMessage.textContent = "Please try again before/after making sure no other programs are using the camera.";
@@ -2837,25 +2967,32 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2837
2967
  // errorMessage.textContent = "The previously selected camera is not available. Please mess around with Video > Camera source.";
2838
2968
  // errorMessage.textContent = "The previously selected camera is not available. Try changing Video > Camera source.";
2839
2969
  // 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.";
2840
- errorMessage.textContent = t("The previously selected camera is not available. Try selecting \"Default\" for Video > Camera source, and then select a specific camera if you need to.");
2970
+ 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." });
2841
2971
  // It's awkward but that's my best attempt at conveying how you may need to proceed
2842
2972
  // without complicated description of how/why the dropdown might be populated with
2843
2973
  // fake information until a camera stream is successfully opened.
2844
2974
  } else {
2845
- errorMessage.textContent = t("Webcam does not support the required resolution. Please change your settings.");
2975
+ errorMessage.textContent = t("video.errors.unsupportedResolution", { defaultValue: "Webcam does not support the required resolution. Please change your settings." });
2846
2976
  }
2847
2977
  } else if (error.name == "NotAllowedError" || error.name == "PermissionDeniedError") {
2848
2978
  // permission denied in browser
2849
- errorMessage.textContent = t("Permission denied. Please enable access to the camera.");
2979
+ errorMessage.textContent = t("video.errors.permissionDenied", { defaultValue: "Permission denied. Please enable access to the camera." });
2850
2980
  } else if (error.name == "TypeError") {
2851
2981
  // empty constraints object
2852
- errorMessage.textContent = `${t("Something went wrong accessing the camera.")} (${error.name}: ${error.message})`;
2982
+ errorMessage.textContent = `${t("video.errors.accessFailed", { defaultValue: "Something went wrong accessing the camera." })} (${error.name}: ${error.message})`;
2853
2983
  } else {
2854
2984
  // other errors
2855
- errorMessage.textContent = `${t("Something went wrong accessing the camera. Please try again.")} (${error.name}: ${error.message})`;
2985
+ errorMessage.textContent = `${t("video.errors.accessFailedRetry", { defaultValue: "Something went wrong accessing the camera. Please try again." })} (${error.name}: ${error.message})`;
2856
2986
  }
2857
- errorMessage.textContent = `${t("⚠️ ")}${errorMessage.textContent}`;
2987
+ errorMessage.textContent = `${t("common.warningIcon", { defaultValue: "⚠️" })} ${errorMessage.textContent}`;
2858
2988
  errorMessage.hidden = false;
2989
+ // Play CSS animation only on retries
2990
+ errorMessage.style.animation = "none";
2991
+ if (showedCameraError) {
2992
+ void errorMessage.offsetWidth; // trigger reflow to allow restarting animation
2993
+ errorMessage.style.animation = "";
2994
+ }
2995
+ showedCameraError = true;
2859
2996
  });
2860
2997
  };
2861
2998
  useDemoFootageButton.onclick = TrackyMouse.useDemoFootage = () => {
@@ -2955,7 +3092,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2955
3092
  }
2956
3093
  addPoint(x, y) {
2957
3094
  if (this.pointCount < maxPoints) {
2958
- var pointIndex = this.pointCount * 2;
3095
+ let pointIndex = this.pointCount * 2;
2959
3096
  this.curXY[pointIndex] = x;
2960
3097
  this.curXY[pointIndex + 1] = y;
2961
3098
  this.prevXY[pointIndex] = x;
@@ -2964,8 +3101,8 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2964
3101
  }
2965
3102
  }
2966
3103
  filterPoints(condition) {
2967
- var outputPointIndex = 0;
2968
- for (var inputPointIndex = 0; inputPointIndex < this.pointCount; inputPointIndex++) {
3104
+ let outputPointIndex = 0;
3105
+ for (let inputPointIndex = 0; inputPointIndex < this.pointCount; inputPointIndex++) {
2969
3106
  if (condition(inputPointIndex)) {
2970
3107
  if (outputPointIndex < inputPointIndex) {
2971
3108
  const inputOffset = inputPointIndex * 2;
@@ -3013,10 +3150,10 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3013
3150
  [this.prevPyramid, this.curPyramid] = [this.curPyramid, this.prevPyramid];
3014
3151
 
3015
3152
  // these are options worth breaking out and exploring
3016
- var winSize = 20;
3017
- var maxIterations = 30;
3018
- var epsilon = 0.01;
3019
- var minEigen = 0.001;
3153
+ let winSize = 20;
3154
+ let maxIterations = 30;
3155
+ let epsilon = 0.01;
3156
+ let minEigen = 0.001;
3020
3157
 
3021
3158
  jsfeat.imgproc.grayscale(imageData.data, imageData.width, imageData.height, this.curPyramid.data[0]);
3022
3159
  this.curPyramid.build(this.curPyramid.data[0], true);
@@ -3030,9 +3167,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3030
3167
  this.prunePoints();
3031
3168
  }
3032
3169
  draw(ctx) {
3033
- for (var i = 0; i < this.pointCount; i++) {
3034
- var pointOffset = i * 2;
3035
- // var distMoved = Math.hypot(
3170
+ for (let i = 0; i < this.pointCount; i++) {
3171
+ let pointOffset = i * 2;
3172
+ // let distMoved = Math.hypot(
3036
3173
  // this.prevXY[pointOffset] - this.curXY[pointOffset],
3037
3174
  // this.prevXY[pointOffset + 1] - this.curXY[pointOffset + 1]
3038
3175
  // );
@@ -3050,11 +3187,11 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3050
3187
  }
3051
3188
  }
3052
3189
  getMovement() {
3053
- var movementX = 0;
3054
- var movementY = 0;
3055
- var numMovements = 0;
3056
- for (var i = 0; i < this.pointCount; i++) {
3057
- var pointOffset = i * 2;
3190
+ let movementX = 0;
3191
+ let movementY = 0;
3192
+ let numMovements = 0;
3193
+ for (let i = 0; i < this.pointCount; i++) {
3194
+ let pointOffset = i * 2;
3058
3195
  movementX += this.curXY[pointOffset] - this.prevXY[pointOffset];
3059
3196
  movementY += this.curXY[pointOffset + 1] - this.prevXY[pointOffset + 1];
3060
3197
  numMovements += 1;
@@ -3091,9 +3228,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3091
3228
  // in order to keep a smooth overall tracking calculation,
3092
3229
  // don't add points if they're close to an existing point.
3093
3230
  // Otherwise, it would not just be redundant, but often remove the older points, in the pruning.
3094
- for (var pointIndex = 0; pointIndex < oops.pointCount; pointIndex++) {
3095
- var pointOffset = pointIndex * 2;
3096
- // var distance = Math.hypot(
3231
+ for (let pointIndex = 0; pointIndex < oops.pointCount; pointIndex++) {
3232
+ let pointOffset = pointIndex * 2;
3233
+ // let distance = Math.hypot(
3097
3234
  // x - oops.curXY[pointOffset],
3098
3235
  // y - oops.curXY[pointOffset + 1]
3099
3236
  // );
@@ -3130,6 +3267,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3130
3267
  return ((px - x1) * nx + (py - y1) * ny) / Math.hypot(nx, ny);
3131
3268
  }
3132
3269
 
3270
+ let lastTimestamp = -Infinity;
3133
3271
  function draw(update = true) {
3134
3272
  ctx.resetTransform(); // in case there is an error, don't flip constantly back and forth due to mirroring
3135
3273
  ctx.clearRect(0, 0, canvas.width, canvas.height); // in case there's no footage
@@ -3144,6 +3282,13 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3144
3282
  ctx.drawImage(cameraVideo, 0, 0, canvas.width, canvas.height);
3145
3283
  }
3146
3284
 
3285
+ const timestamp = performance.now();
3286
+ const deltaTime = Math.min(timestamp - lastTimestamp, 100);
3287
+ lastTimestamp = timestamp;
3288
+
3289
+ sleepSweep?.setEnabled(s.closeEyesToToggle);
3290
+ sleepSweep?.update(sleepGestureProgress);
3291
+
3147
3292
  if (!pointTracker) {
3148
3293
  return;
3149
3294
  }
@@ -3279,39 +3424,77 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3279
3424
 
3280
3425
  // TODO: separate confidence threshold for removing vs adding points?
3281
3426
 
3427
+
3282
3428
  // cull points to those within useful facial region
3283
- pointTracker.filterPoints((pointIndex) => {
3284
- var pointOffset = pointIndex * 2;
3429
+ function regionFilter([x, y]) {
3430
+
3285
3431
  // distance from tip of nose (stretched so make an ellipse taller than wide)
3286
- var distance = Math.hypot(
3287
- (annotations.noseTip[0][0] - pointTracker.curXY[pointOffset]) * 1.4,
3288
- annotations.noseTip[0][1] - pointTracker.curXY[pointOffset + 1]
3432
+ let distance = Math.hypot(
3433
+ (annotations.noseTip[0][0] - x) * 1.4,
3434
+ annotations.noseTip[0][1] - y
3289
3435
  );
3290
- var headSize = Math.hypot(
3436
+ let headSize = Math.hypot(
3291
3437
  annotations.leftCheek[0][0] - annotations.rightCheek[0][0],
3292
3438
  annotations.leftCheek[0][1] - annotations.rightCheek[0][1]
3293
3439
  );
3294
3440
  if (distance > headSize) {
3295
3441
  return false;
3296
3442
  }
3443
+ // Avoid mouth affecting pointer position.
3444
+ distance = annotations.lipsLowerInner.map((lipPoint) =>
3445
+ Math.min(
3446
+ Math.hypot(lipPoint[0] - x, lipPoint[1] - y),
3447
+ Math.hypot(lipPoint[0] - x, lipPoint[1] + headSize * 0.1 - y), // a bit below too
3448
+ Math.hypot(lipPoint[0] - x, lipPoint[1] + headSize * 0.2 - y), // a bit below too
3449
+ Math.hypot(lipPoint[0] - x, lipPoint[1] + headSize * 0.3 - y), // a bit below too
3450
+ Math.hypot(lipPoint[0] - x, lipPoint[1] + headSize * 0.4 - y), // a bit below too (yeah I'm being a little lazy here)
3451
+ )
3452
+ ).reduce((a, b) => Math.min(a, b), Infinity);
3453
+ if (distance < headSize * 0.1) {
3454
+ return false;
3455
+ }
3297
3456
  // Avoid blinking eyes affecting pointer position.
3298
3457
  // distance to outer corners of eyes
3299
3458
  distance = Math.min(
3300
3459
  Math.hypot(
3301
- annotations.leftEyeLower0[0][0] - pointTracker.curXY[pointOffset],
3302
- annotations.leftEyeLower0[0][1] - pointTracker.curXY[pointOffset + 1]
3460
+ annotations.leftEyeLower0[0][0] - x,
3461
+ annotations.leftEyeLower0[0][1] - y
3303
3462
  ),
3304
3463
  Math.hypot(
3305
- annotations.rightEyeLower0[0][0] - pointTracker.curXY[pointOffset],
3306
- annotations.rightEyeLower0[0][1] - pointTracker.curXY[pointOffset + 1]
3464
+ annotations.rightEyeLower0[0][0] - x,
3465
+ annotations.rightEyeLower0[0][1] - y
3307
3466
  ),
3308
3467
  );
3309
3468
  if (distance < headSize * 0.42) {
3310
3469
  return false;
3311
3470
  }
3312
3471
  return true;
3472
+ }
3473
+ pointTracker.filterPoints((pointIndex) => {
3474
+ let pointOffset = pointIndex * 2;
3475
+ const point = [pointTracker.curXY[pointOffset], pointTracker.curXY[pointOffset + 1]];
3476
+ return regionFilter(point);
3313
3477
  });
3314
3478
 
3479
+ // Debug visualization for region filter (a sort of heatmap of where points will be culled)
3480
+ if (showDebugRegionFilter) {
3481
+ ctx.save();
3482
+ if (s.mirror) {
3483
+ ctx.translate(canvas.width, 0);
3484
+ ctx.scale(-1, 1);
3485
+ }
3486
+ ctx.fillStyle = "rgba(255, 0, 0, 0.5)";
3487
+ const vizStep = 4;
3488
+ for (let x = 0; x < canvas.width; x += vizStep) {
3489
+ for (let y = 0; y < canvas.height; y += vizStep) {
3490
+ if (!regionFilter([x, y])) {
3491
+ ctx.fillRect(x - 5, y - 5, vizStep, vizStep);
3492
+ }
3493
+ }
3494
+ }
3495
+ ctx.restore();
3496
+ }
3497
+
3315
3498
  const keypoints = facemeshPrediction.keypoints;
3316
3499
  if (keypoints) {
3317
3500
  const top = keypoints[10]; // Top of forehead
@@ -3493,16 +3676,19 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3493
3676
 
3494
3677
  blinkInfo = detectBlinks();
3495
3678
  mouthInfo = detectMouthOpen();
3496
- if (blinkInfo.rightEye.open || blinkInfo.leftEye.open) {
3497
- lastTimeWhenAnEyeWasOpen = performance.now();
3679
+ if (!blinkInfo.rightEye.open && !blinkInfo.leftEye.open) {
3680
+ sleepGestureProgress += deltaTime / sleepGestureEyesClosedDuration;
3681
+ sleepGestureProgress = Math.min(sleepGestureProgress, 1);
3682
+ } else {
3683
+ sleepGestureProgress -= deltaTime / sleepGestureEyesClosedDuration;
3684
+ sleepGestureProgress = Math.max(sleepGestureProgress, 0);
3498
3685
  }
3499
- if (performance.now() - lastTimeWhenAnEyeWasOpen > 2000) {
3686
+ if (sleepGestureProgress >= 1) {
3687
+ sleepGestureProgress = 0;
3500
3688
  if (s.closeEyesToToggle) {
3501
3689
  paused = !paused;
3502
3690
  updatePaused();
3503
- // TODO: handle edge cases
3504
- // TODO: try to keep variable names meaningful
3505
- lastTimeWhenAnEyeWasOpen = Infinity;
3691
+ sleepSweep?.sleepModeWasToggled(paused);
3506
3692
  }
3507
3693
  }
3508
3694
 
@@ -3562,15 +3748,43 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3562
3748
  }
3563
3749
  }
3564
3750
 
3565
- // TODO: implement these clicking modes for the web library version
3566
- // and unhide the "Clicking mode" setting in the UI
3567
- // https://github.com/1j01/tracky-mouse/issues/72
3568
3751
  const buttonNames = ["left", "middle", "right"];
3569
3752
  for (let buttonIndex = 0; buttonIndex < 3; buttonIndex++) {
3570
- if ((clickButton === buttonIndex) !== buttonStates[buttonNames[buttonIndex]]) {
3571
- window.electronAPI?.setMouseButtonState(buttonIndex, clickButton === buttonIndex);
3572
- buttonStates[buttonNames[buttonIndex]] = clickButton === buttonIndex;
3573
- if ((clickButton === buttonIndex)) {
3753
+ const buttonIsActive = clickButton === buttonIndex;
3754
+ if (buttonIsActive !== buttonStates[buttonNames[buttonIndex]]) {
3755
+ // Wait for confirmation of the button state change before playing SFX
3756
+ // but not before updating buttonStates, since we check that in this loop
3757
+ // to decide whether to call setMouseButtonState.
3758
+ // We don't want to send extraneous mouse button changes to the main process,
3759
+ // even if it does track button states itself. If nothing else it's wasted IPC.
3760
+ // That said, an argument could be made for updating lastMouseDownTime later
3761
+ // if the IPC is slow, to extend the time frame for making a simple click
3762
+ // rather than a drag.
3763
+ if (!setMouseButtonState) {
3764
+ console.warn("setMouseButtonState function not provided");
3765
+ } else {
3766
+ const maybeAPromise = setMouseButtonState(buttonIndex, buttonIsActive);
3767
+ const playSoundForButton = (changedButtonState) => {
3768
+ if (changedButtonState) {
3769
+ if (buttonIndex === 1) {
3770
+ playSound(buttonIsActive ? "middleClickPress" : "middleClickRelease", {
3771
+ volume: 4,
3772
+ });
3773
+ } else {
3774
+ playSound(buttonIsActive ? "clickPress" : "clickRelease", {
3775
+ playbackRate: buttonIndex === 0 ? 1 : buttonIndex === 2 ? 1.2 : 1.5,
3776
+ });
3777
+ }
3778
+ }
3779
+ };
3780
+ if (maybeAPromise instanceof Promise) {
3781
+ maybeAPromise.then(playSoundForButton);
3782
+ } else {
3783
+ playSoundForButton(maybeAPromise);
3784
+ }
3785
+ }
3786
+ buttonStates[buttonNames[buttonIndex]] = buttonIsActive;
3787
+ if (buttonIsActive) {
3574
3788
  lastMouseDownTime = performance.now();
3575
3789
  } else {
3576
3790
  // Limit "Delay Before Dragging" effect to the duration of a click.
@@ -3589,13 +3803,11 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3589
3803
  pointTracker.update(imageData);
3590
3804
  }
3591
3805
 
3592
- if (window.electronAPI) {
3593
- window.electronAPI.updateInputFeedback({
3594
- headNotFound: !face && !facemeshPrediction,
3595
- blinkInfo,
3596
- mouthInfo,
3597
- });
3598
- }
3806
+ updateInputFeedback?.({
3807
+ headNotFound: !face && !facemeshPrediction,
3808
+ blinkInfo,
3809
+ mouthInfo,
3810
+ });
3599
3811
 
3600
3812
  if (facemeshPrediction) {
3601
3813
  ctx.fillStyle = "red";
@@ -3640,17 +3852,20 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3640
3852
  const textYStart = -10;
3641
3853
 
3642
3854
 
3643
- const pitchText = t("Pitch: ") + `${(headTilt.pitch * 180 / Math.PI).toFixed(1)}°`;
3644
- const yawText = t("Yaw: ") + `${(headTilt.yaw * 180 / Math.PI).toFixed(1)}°`;
3645
- const rollText = t("Roll: ") + `${(headTilt.roll * 180 / Math.PI).toFixed(1)}°`;
3646
-
3647
- const boxWidth = Math.max(
3648
- ctx.measureText(pitchText).width,
3649
- ctx.measureText(yawText).width,
3650
- ctx.measureText(rollText).width
3651
- );
3652
- const boxHeight = textLineHeight * 3;
3653
- const padding = 10;
3855
+ const headTiltRows = [
3856
+ { label: t("debug.headTilt.pitch", { defaultValue: "Pitch:" }), value: `${(headTilt.pitch * 180 / Math.PI).toFixed(1)}°` },
3857
+ { label: t("debug.headTilt.yaw", { defaultValue: "Yaw:" }), value: `${(headTilt.yaw * 180 / Math.PI).toFixed(1)}°` },
3858
+ { label: t("debug.headTilt.roll", { defaultValue: "Roll:" }), value: `${(headTilt.roll * 180 / Math.PI).toFixed(1)}°` },
3859
+ ];
3860
+ const labelWidths = headTiltRows.map(row => ctx.measureText(row.label).width);
3861
+ const maxLabelWidth = Math.max(...labelWidths);
3862
+ const valueColumnTemplate = "-180.0°";
3863
+ const maxValueWidth = ctx.measureText(valueColumnTemplate).width;
3864
+ const labelToValueGap = 10;
3865
+ const boxPadding = 10;
3866
+ const boxWidth = boxPadding * 2 + maxLabelWidth + labelToValueGap + maxValueWidth;
3867
+ const boxHeight = textLineHeight * headTiltRows.length;
3868
+ const valueColumnRightOffset = boxPadding + maxLabelWidth + labelToValueGap + maxValueWidth;
3654
3869
 
3655
3870
  // Calculate screen coordinates for the text box
3656
3871
  let screenX = s.mirror ? canvas.width - cx : cx;
@@ -3661,7 +3876,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3661
3876
  let textScreenY = screenY + textYStart;
3662
3877
 
3663
3878
  // Clamp to canvas bounds
3664
- textScreenX = Math.max(padding, Math.min(canvas.width - boxWidth - padding, textScreenX));
3879
+ textScreenX = Math.max(boxPadding, Math.min(canvas.width - boxWidth - boxPadding, textScreenX));
3665
3880
  textScreenY = Math.max(textLineHeight, Math.min(canvas.height - boxHeight + textLineHeight, textScreenY));
3666
3881
 
3667
3882
  ctx.save();
@@ -3675,12 +3890,18 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3675
3890
  const dx = textScreenX - screenNoseX;
3676
3891
  const dy = textScreenY - screenNoseY;
3677
3892
 
3678
- ctx.strokeText(pitchText, dx, dy);
3679
- ctx.fillText(pitchText, dx, dy);
3680
- ctx.strokeText(yawText, dx, dy + textLineHeight);
3681
- ctx.fillText(yawText, dx, dy + textLineHeight);
3682
- ctx.strokeText(rollText, dx, dy + textLineHeight * 2);
3683
- ctx.fillText(rollText, dx, dy + textLineHeight * 2);
3893
+ for (let i = 0; i < headTiltRows.length; i++) {
3894
+ const row = headTiltRows[i];
3895
+ const baselineY = dy + textLineHeight * (i + 1);
3896
+ const labelX = dx + boxPadding;
3897
+ const valueTextWidth = ctx.measureText(row.value).width;
3898
+ const valueRightX = dx + valueColumnRightOffset;
3899
+ const valueX = valueRightX - valueTextWidth;
3900
+ ctx.strokeText(row.label, labelX, baselineY);
3901
+ ctx.fillText(row.label, labelX, baselineY);
3902
+ ctx.strokeText(row.value, valueX, baselineY);
3903
+ ctx.fillText(row.value, valueX, baselineY);
3904
+ }
3684
3905
 
3685
3906
  ctx.restore();
3686
3907
 
@@ -3836,14 +4057,14 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3836
4057
 
3837
4058
  // cull points to those within useful facial region
3838
4059
  pointTracker.filterPoints((pointIndex) => {
3839
- var pointOffset = pointIndex * 2;
4060
+ let pointOffset = pointIndex * 2;
3840
4061
  // distance from tip of nose (stretched so make an ellipse taller than wide)
3841
- var distance = Math.hypot(
4062
+ let distance = Math.hypot(
3842
4063
  (face[62][0] - pointTracker.curXY[pointOffset]) * 1.4,
3843
4064
  face[62][1] - pointTracker.curXY[pointOffset + 1]
3844
4065
  );
3845
4066
  // distance based on outer eye corners
3846
- var headSize = Math.hypot(
4067
+ let headSize = Math.hypot(
3847
4068
  face[23][0] - face[28][0],
3848
4069
  face[23][1] - face[28][1]
3849
4070
  );
@@ -3871,18 +4092,18 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
3871
4092
  const screenWidth = window.electronAPI ? screen.width : innerWidth;
3872
4093
  const screenHeight = window.electronAPI ? screen.height : innerHeight;
3873
4094
 
3874
- var [movementX, movementY] = pointTracker.getMovement();
4095
+ let [movementX, movementY] = pointTracker.getMovement();
3875
4096
 
3876
4097
  // Acceleration curves add a lot of stability,
3877
4098
  // letting you focus on a specific point without jitter, but still move quickly.
3878
4099
 
3879
- // var accelerate = (delta, distance) => (delta / 10) * (distance ** 0.8);
3880
- // var accelerate = (delta, distance) => (delta / 1) * (Math.abs(delta) ** 0.8);
3881
- var accelerate = (delta, _distance) => (delta / 1) * (Math.abs(delta * 5) ** s.headTrackingAcceleration);
4100
+ // let accelerate = (delta, distance) => (delta / 10) * (distance ** 0.8);
4101
+ // let accelerate = (delta, distance) => (delta / 1) * (Math.abs(delta) ** 0.8);
4102
+ let accelerate = (delta, _distance) => (delta / 1) * (Math.abs(delta * 5) ** s.headTrackingAcceleration);
3882
4103
 
3883
- var distance = Math.hypot(movementX, movementY);
3884
- var deltaX = accelerate(movementX * s.headTrackingSensitivityX, distance);
3885
- var deltaY = accelerate(movementY * s.headTrackingSensitivityY, distance);
4104
+ let distance = Math.hypot(movementX, movementY);
4105
+ let deltaX = accelerate(movementX * s.headTrackingSensitivityX, distance);
4106
+ let deltaY = accelerate(movementY * s.headTrackingSensitivityY, distance);
3886
4107
 
3887
4108
  if (s.headTrackingTiltInfluence > 0) {
3888
4109
  const yawRange = [
@@ -4013,8 +4234,8 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
4013
4234
  pointerEl.style.display = "none";
4014
4235
  } else {
4015
4236
  pointerEl.style.display = "";
4016
- pointerEl.style.left = `${mouseX}px`;
4017
- pointerEl.style.top = `${mouseY}px`;
4237
+ pointerEl.style.left = `${Math.floor(mouseX)}px`;
4238
+ pointerEl.style.top = `${Math.floor(mouseY)}px`;
4018
4239
  }
4019
4240
  if (TrackyMouse.onPointerMove) {
4020
4241
  TrackyMouse.onPointerMove(mouseX, mouseY);
@@ -4030,9 +4251,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
4030
4251
  ctx.lineWidth = 3;
4031
4252
  ctx.font = "20px sans-serif";
4032
4253
  ctx.beginPath();
4033
- const text3 = t("Face convergence score: ") + ((useFacemesh && facemeshPrediction) ? t("N/A") : faceConvergence.toFixed(4));
4034
- const text1 = t("Face tracking score: ") + ((useFacemesh && facemeshPrediction) ? facemeshPrediction.faceInViewConfidence : faceScore).toFixed(4);
4035
- const text2 = t("Points based on score: ") + ((useFacemesh && facemeshPrediction) ? pointsBasedOnFaceInViewConfidence : pointsBasedOnFaceScore).toFixed(4);
4254
+ const text3 = `${t("debug.faceConvergenceScore", { defaultValue: "Face convergence score:" })} ${((useFacemesh && facemeshPrediction) ? t("common.notApplicable", { defaultValue: "N/A" }) : faceConvergence.toFixed(4))}`;
4255
+ const text1 = `${t("debug.faceTrackingScore", { defaultValue: "Face tracking score:" })} ${((useFacemesh && facemeshPrediction) ? facemeshPrediction.faceInViewConfidence : faceScore).toFixed(4)}`;
4256
+ const text2 = `${t("debug.pointsBasedOnScore", { defaultValue: "Points based on score:" })} ${((useFacemesh && facemeshPrediction) ? pointsBasedOnFaceInViewConfidence : pointsBasedOnFaceScore).toFixed(4)}`;
4036
4257
  ctx.strokeText(text1, 50, 50);
4037
4258
  ctx.fillText(text1, 50, 50);
4038
4259
  ctx.strokeText(text2, 50, 70);
@@ -4055,7 +4276,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
4055
4276
 
4056
4277
  // 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)
4057
4278
  const iid = setInterval(function animationLoop() {
4058
- draw(!paused || document.visibilityState === "visible");
4279
+ draw(!paused || document.visibilityState === "visible" || isDesktopApp);
4059
4280
  }, 15);
4060
4281
 
4061
4282
  let autoDemo = false;
@@ -4068,14 +4289,26 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
4068
4289
  TrackyMouse.useDemoFootage();
4069
4290
  } else if (window.electronAPI) {
4070
4291
  TrackyMouse.useCamera();
4292
+ } else {
4293
+ // Passively querying the camera permission isn't supported in all browsers,
4294
+ // hence some of the complex logic in useCamera, but when it is,
4295
+ // we can connect to the camera right away if the permission is already granted.
4296
+ // This speeds up the development cycle, at the very least.
4297
+ navigator.permissions?.query?.({ name: "camera" }).then((status) => {
4298
+ if (status.state === "granted") {
4299
+ TrackyMouse.useCamera();
4300
+ }
4301
+ }, (error) => {
4302
+ console.log("Error querying permissions:", error);
4303
+ });
4071
4304
  }
4072
4305
 
4073
4306
  const updateStartStopButton = () => {
4074
4307
  if (paused) {
4075
- startStopButton.textContent = t("Start");
4308
+ startStopButton.textContent = t("ui.startStopButton.start", { defaultValue: "Start" });
4076
4309
  startStopButton.setAttribute("aria-pressed", "false");
4077
4310
  } else {
4078
- startStopButton.textContent = t("Stop");
4311
+ startStopButton.textContent = t("ui.startStopButton.stop", { defaultValue: "Stop" });
4079
4312
  startStopButton.setAttribute("aria-pressed", "true");
4080
4313
  }
4081
4314
  };
@@ -4085,9 +4318,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
4085
4318
  pointerEl.style.display = "none";
4086
4319
  }
4087
4320
  updateStartStopButton();
4088
- if (window.electronAPI) {
4089
- window.electronAPI.notifyToggleState(!paused);
4090
- }
4321
+ notifyToggleState?.(!paused);
4091
4322
  };
4092
4323
  const handleShortcut = (shortcutType) => {
4093
4324
  if (shortcutType === "toggle-tracking") {
@@ -4229,15 +4460,17 @@ TrackyMouse.init = function (div, opts = {}) {
4229
4460
  TrackyMouse.initScreenOverlay = () => {
4230
4461
 
4231
4462
  const template = `
4463
+ <div class="tracky-mouse-hide-near-cursor">
4232
4464
  <div class="tracky-mouse-absolute-center">
4233
4465
  <div class="tracky-mouse-screen-overlay-status-indicator tracky-mouse-manual-takeback-indicator">
4234
- <img src="../images/manual-takeback.svg" alt="hand reaching for mouse" width="128" height="128">
4466
+ <img src="${TrackyMouse.dependenciesRoot}/images/manual-takeback.svg" alt="hand reaching for mouse" width="128" height="128">
4235
4467
  </div>
4236
4468
  <div class="tracky-mouse-screen-overlay-status-indicator tracky-mouse-head-not-found-indicator">
4237
- <img src="../images/head-not-found.svg" alt="head not found" width="128" height="128">
4469
+ <img src="${TrackyMouse.dependenciesRoot}/images/head-not-found.svg" alt="head not found" width="128" height="128">
4238
4470
  </div>
4239
4471
  </div>
4240
4472
  <div id="tracky-mouse-screen-overlay-message"></div>
4473
+ </div>
4241
4474
  `;
4242
4475
  const fragment = document.createRange().createContextualFragment(template);
4243
4476
  document.body.appendChild(fragment);
@@ -4245,8 +4478,11 @@ TrackyMouse.initScreenOverlay = () => {
4245
4478
  const message = document.getElementById("tracky-mouse-screen-overlay-message");
4246
4479
  message.dir = "auto";
4247
4480
 
4481
+ const hideNearCursorEls = document.querySelectorAll(".tracky-mouse-hide-near-cursor");
4482
+
4248
4483
  const inputFeedbackCanvas = document.createElement("canvas");
4249
- inputFeedbackCanvas.style.position = "absolute";
4484
+ inputFeedbackCanvas.style.position = "fixed";
4485
+ inputFeedbackCanvas.style.zIndex = "899990"; // just below .tracky-mouse-pointer
4250
4486
  inputFeedbackCanvas.style.top = "0";
4251
4487
  inputFeedbackCanvas.style.left = "0";
4252
4488
  inputFeedbackCanvas.style.pointerEvents = "none";
@@ -4282,10 +4518,11 @@ TrackyMouse.initScreenOverlay = () => {
4282
4518
  // inputFeedbackCanvas.style.transform = `translate(${x - inputFeedbackCanvas.width / 2}px, ${y - inputFeedbackCanvas.height / 2}px)`;
4283
4519
  // inputFeedbackCanvas.style.transform = `translate(${x}px, ${y}px)`;
4284
4520
  inputFeedbackCanvas.style.transform = `translate(${Math.min(x, window.innerWidth - inputFeedbackCanvas.width)}px, ${Math.min(y, window.innerHeight - inputFeedbackCanvas.height)}px)`;
4521
+
4285
4522
  }
4286
4523
 
4287
4524
  function update(data) {
4288
- const { messageText, isEnabled, isManualTakeback, inputFeedback, bottomOffset } = data;
4525
+ const { messageText, isEnabled, isManualTakeback, inputFeedback, bottomOffset, systemMousePosition } = data;
4289
4526
 
4290
4527
  message.style.bottom = `${bottomOffset}px`;
4291
4528
 
@@ -4294,21 +4531,31 @@ TrackyMouse.initScreenOverlay = () => {
4294
4531
  // - bad lighting conditions
4295
4532
  // see: https://github.com/1j01/tracky-mouse/issues/26
4296
4533
 
4297
- document.body.classList.toggle("tracky-mouse-manual-takeback", isManualTakeback);
4298
- document.body.classList.toggle("tracky-mouse-head-not-found", inputFeedback.headNotFound);
4534
+ document.body.classList.toggle("tracky-mouse-manual-takeback", isManualTakeback ?? false);
4535
+ document.body.classList.toggle("tracky-mouse-head-not-found", inputFeedback.headNotFound ?? false);
4299
4536
 
4300
4537
  message.innerText = messageText;
4301
4538
 
4302
4539
  if (!isEnabled && !isManualTakeback) {
4303
4540
  // Fade out the message after a little while so it doesn't get in the way.
4304
4541
  // TODO: make sure animation isn't interrupted by inputFeedback updates.
4305
- message.style.animation = "tracky-mouse-screen-overlay-message-fade-out 2s ease-in-out forwards 10s";
4542
+ message.style.animation = "tracky-mouse-fade-out 2s ease-in-out forwards 10s";
4306
4543
  } else {
4307
4544
  message.style.animation = "";
4308
4545
  message.style.opacity = "1";
4309
4546
  }
4310
4547
 
4311
4548
  drawInputFeedback(data);
4549
+
4550
+ if (systemMousePosition) {
4551
+ const { x, y } = systemMousePosition;
4552
+ // TODO: optimize CSS parsing by using CSS variables?
4553
+ const maskImage = `radial-gradient(circle at ${x}px ${y}px, transparent 0, transparent 50px, rgba(0, 0, 0, 0.85) 140px, rgba(0, 0, 0, 1) 200px, rgba(0, 0, 0, 1) 100%)`;
4554
+ for (const el of hideNearCursorEls) {
4555
+ el.style.webkitMaskImage = maskImage;
4556
+ el.style.maskImage = maskImage;
4557
+ }
4558
+ }
4312
4559
  }
4313
4560
 
4314
4561
  return {