tracky-mouse 2.3.0 → 2.5.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 (65) hide show
  1. package/README.md +15 -5
  2. package/locales/ar/translation.json +204 -0
  3. package/locales/ar-EG/translation.json +204 -0
  4. package/locales/bg/translation.json +204 -0
  5. package/locales/bn/translation.json +204 -0
  6. package/locales/ca/translation.json +204 -0
  7. package/locales/ce/translation.json +204 -0
  8. package/locales/ceb/translation.json +204 -0
  9. package/locales/cs/translation.json +204 -0
  10. package/locales/da/translation.json +204 -0
  11. package/locales/de/translation.json +204 -0
  12. package/locales/el/translation.json +204 -0
  13. package/locales/emoji/emoji-translation-notes.md +147 -0
  14. package/locales/emoji/translation.json +204 -0
  15. package/locales/en/translation.json +204 -0
  16. package/locales/eo/translation.json +204 -0
  17. package/locales/es/translation.json +204 -0
  18. package/locales/eu/translation.json +204 -0
  19. package/locales/fa/translation.json +204 -0
  20. package/locales/fi/translation.json +204 -0
  21. package/locales/fr/translation.json +204 -0
  22. package/locales/gu/translation.json +204 -0
  23. package/locales/ha/translation.json +204 -0
  24. package/locales/he/translation.json +204 -0
  25. package/locales/hi/translation.json +204 -0
  26. package/locales/hr/translation.json +204 -0
  27. package/locales/hu/translation.json +204 -0
  28. package/locales/hy/translation.json +204 -0
  29. package/locales/id/translation.json +204 -0
  30. package/locales/it/translation.json +204 -0
  31. package/locales/ja/translation.json +204 -0
  32. package/locales/jv/translation.json +204 -0
  33. package/locales/ko/translation.json +204 -0
  34. package/locales/mr/translation.json +204 -0
  35. package/locales/ms/translation.json +204 -0
  36. package/locales/nan/translation.json +204 -0
  37. package/locales/nb/translation.json +204 -0
  38. package/locales/nl/translation.json +204 -0
  39. package/locales/pa/translation.json +204 -0
  40. package/locales/pl/translation.json +204 -0
  41. package/locales/pt/translation.json +204 -0
  42. package/locales/pt-BR/translation.json +204 -0
  43. package/locales/ro/translation.json +204 -0
  44. package/locales/ru/translation.json +204 -0
  45. package/locales/sk/translation.json +204 -0
  46. package/locales/sl/translation.json +204 -0
  47. package/locales/sr/translation.json +204 -0
  48. package/locales/sv/translation.json +204 -0
  49. package/locales/sw/translation.json +204 -0
  50. package/locales/ta/translation.json +204 -0
  51. package/locales/te/translation.json +204 -0
  52. package/locales/th/translation.json +204 -0
  53. package/locales/tl/translation.json +204 -0
  54. package/locales/tr/translation.json +204 -0
  55. package/locales/tt/translation.json +204 -0
  56. package/locales/uk/translation.json +204 -0
  57. package/locales/ur/translation.json +204 -0
  58. package/locales/uz/translation.json +204 -0
  59. package/locales/vi/translation.json +204 -0
  60. package/locales/war/translation.json +204 -0
  61. package/locales/zh/translation.json +204 -0
  62. package/locales/zh-simplified/translation.json +204 -0
  63. package/package.json +2 -1
  64. package/tracky-mouse.css +7 -6
  65. package/tracky-mouse.js +1591 -217
package/tracky-mouse.js CHANGED
@@ -1,4 +1,5 @@
1
1
  /* global jsfeat, Stats, clm, faceLandmarksDetection, OneEuroFilter */
2
+
2
3
  const TrackyMouse = {
3
4
  dependenciesRoot: "./tracky-mouse",
4
5
  };
@@ -26,13 +27,20 @@ TrackyMouse.loadDependencies = function ({ statsJs = false } = {}) {
26
27
  `${TrackyMouse.dependenciesRoot}/lib/no-eval.js`, // generated with eval-is-evil.html, this instruments clmtrackr.js so I don't need unsafe-eval in the CSP
27
28
  `${TrackyMouse.dependenciesRoot}/lib/clmtrackr.js`,
28
29
  `${TrackyMouse.dependenciesRoot}/lib/face_mesh/face_mesh.js`,
29
- `${TrackyMouse.dependenciesRoot}/lib/face-landmarks-detection.min.js`,
30
30
  `${TrackyMouse.dependenciesRoot}/lib/OneEuroFilter.js`,
31
31
  ];
32
+ // face-landmarks-detection.min.js depends on face_mesh.js
33
+ // avoid sporadic "TypeError: o.Facemesh is not a constructor" by loading face-landmarks-detection after face_mesh.js
34
+ // TODO: preload in parallel?
35
+ const moreScriptFiles = [
36
+ `${TrackyMouse.dependenciesRoot}/lib/face-landmarks-detection.min.js`,
37
+ ];
32
38
  if (statsJs) {
33
39
  scriptFiles.push(`${TrackyMouse.dependenciesRoot}/lib/stats.js`);
34
40
  }
35
- return Promise.all(scriptFiles.map(loadScript));
41
+ return Promise.all(scriptFiles.map(loadScript)).then(() => {
42
+ return Promise.all(moreScriptFiles.map(loadScript));
43
+ });
36
44
  };
37
45
 
38
46
  const isSelectorValid = ((dummyElement) =>
@@ -62,77 +70,81 @@ const initDwellClicking = (config) => {
62
70
  - `config.beforePointerDownDispatch()` (optional): a function to call before a `pointerdown` event is dispatched. Likely to be merged with `config.beforeDispatch()` in the future.
63
71
  - `config.isHeld()` (optional): a function that returns true if the next dwell should be a release (triggering `pointerup`).
64
72
  */
73
+
74
+ /** translation placeholder */
75
+ const t = (s) => s;
76
+
65
77
  if (typeof config !== "object") {
66
- throw new Error("configuration object required for initDwellClicking");
78
+ throw new Error(t("configuration object required for initDwellClicking"));
67
79
  }
68
80
  if (config.targets === undefined) {
69
- throw new Error("config.targets is required (must be a CSS selector)");
81
+ throw new Error(t("config.targets is required (must be a CSS selector)"));
70
82
  }
71
83
  if (typeof config.targets !== "string") {
72
- throw new Error("config.targets must be a string (a CSS selector)");
84
+ throw new Error(t("config.targets must be a string (a CSS selector)"));
73
85
  }
74
86
  if (!isSelectorValid(config.targets)) {
75
- throw new Error("config.targets is not a valid CSS selector");
87
+ throw new Error(t("config.targets is not a valid CSS selector"));
76
88
  }
77
89
  if (config.click === undefined) {
78
- throw new Error("config.click is required");
90
+ throw new Error(t("config.click is required"));
79
91
  }
80
92
  if (typeof config.click !== "function") {
81
- throw new Error("config.click must be a function");
93
+ throw new Error(t("config.click must be a function"));
82
94
  }
83
95
  if (config.shouldDrag !== undefined && typeof config.shouldDrag !== "function") {
84
- throw new Error("config.shouldDrag must be a function");
96
+ throw new Error(t("config.shouldDrag must be a function"));
85
97
  }
86
98
  if (config.noCenter !== undefined && typeof config.noCenter !== "function") {
87
- throw new Error("config.noCenter must be a function");
99
+ throw new Error(t("config.noCenter must be a function"));
88
100
  }
89
101
  if (config.isEquivalentTarget !== undefined && typeof config.isEquivalentTarget !== "function") {
90
- throw new Error("config.isEquivalentTarget must be a function");
102
+ throw new Error(t("config.isEquivalentTarget must be a function"));
91
103
  }
92
104
  if (config.dwellClickEvenIfPaused !== undefined && typeof config.dwellClickEvenIfPaused !== "function") {
93
- throw new Error("config.dwellClickEvenIfPaused must be a function");
105
+ throw new Error(t("config.dwellClickEvenIfPaused must be a function"));
94
106
  }
95
107
  if (config.beforeDispatch !== undefined && typeof config.beforeDispatch !== "function") {
96
- throw new Error("config.beforeDispatch must be a function");
108
+ throw new Error(t("config.beforeDispatch must be a function"));
97
109
  }
98
110
  if (config.afterDispatch !== undefined && typeof config.afterDispatch !== "function") {
99
- throw new Error("config.afterDispatch must be a function");
111
+ throw new Error(t("config.afterDispatch must be a function"));
100
112
  }
101
113
  if (config.beforePointerDownDispatch !== undefined && typeof config.beforePointerDownDispatch !== "function") {
102
- throw new Error("config.beforePointerDownDispatch must be a function");
114
+ throw new Error(t("config.beforePointerDownDispatch must be a function"));
103
115
  }
104
116
  if (config.isHeld !== undefined && typeof config.isHeld !== "function") {
105
- throw new Error("config.isHeld must be a function");
117
+ throw new Error(t("config.isHeld must be a function"));
106
118
  }
107
119
  if (config.retarget !== undefined) {
108
120
  if (!Array.isArray(config.retarget)) {
109
- throw new Error("config.retarget must be an array of objects");
121
+ throw new Error(t("config.retarget must be an array of objects"));
110
122
  }
111
123
  for (let i = 0; i < config.retarget.length; i++) {
112
124
  const rule = config.retarget[i];
113
125
  if (typeof rule !== "object") {
114
- throw new Error("config.retarget must be an array of objects");
126
+ throw new Error(t("config.retarget must be an array of objects"));
115
127
  }
116
128
  if (rule.from === undefined) {
117
- throw new Error(`config.retarget[${i}].from is required`);
129
+ throw new Error(t("config.retarget[%0].from is required").replace("%0", i));
118
130
  }
119
131
  if (rule.to === undefined) {
120
- throw new Error(`config.retarget[${i}].to is required (although can be null to ignore the element)`);
132
+ throw new Error(t("config.retarget[%0].to is required (although can be null to ignore the element)").replace("%0", i));
121
133
  }
122
134
  if (rule.withinMargin !== undefined && typeof rule.withinMargin !== "number") {
123
- throw new Error(`config.retarget[${i}].withinMargin must be a number`);
135
+ throw new Error(t("config.retarget[%0].withinMargin must be a number").replace("%0", i));
124
136
  }
125
137
  if (typeof rule.from !== "string" && typeof rule.from !== "function" && !(rule.from instanceof Element)) {
126
- throw new Error(`config.retarget[${i}].from must be a CSS selector string, an Element, or a function`);
138
+ throw new Error(t("config.retarget[%0].from must be a CSS selector string, an Element, or a function").replace("%0", i));
127
139
  }
128
140
  if (typeof rule.to !== "string" && typeof rule.to !== "function" && !(rule.to instanceof Element) && rule.to !== null) {
129
- throw new Error(`config.retarget[${i}].to must be a CSS selector string, an Element, a function, or 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));
130
142
  }
131
143
  if (typeof rule.from === "string" && !isSelectorValid(rule.from)) {
132
- throw new Error(`config.retarget[${i}].from is not a valid CSS selector`);
144
+ throw new Error(t("config.retarget[%0].from is not a valid CSS selector").replace("%0", i));
133
145
  }
134
146
  if (typeof rule.to === "string" && !isSelectorValid(rule.to)) {
135
- throw new Error(`config.retarget[${i}].to is not a valid CSS selector`);
147
+ throw new Error(t("config.retarget[%0].to is not a valid CSS selector").replace("%0", i));
136
148
  }
137
149
  }
138
150
  }
@@ -559,27 +571,1044 @@ TrackyMouse.cleanupDwellClicking = function () {
559
571
  }
560
572
  };
561
573
 
562
- TrackyMouse.init = function (div, { statsJs = false } = {}) {
574
+ TrackyMouse._initInner = function (div, { statsJs = false }, reinit) {
575
+
576
+ const isDesktopApp = !!window.electronAPI;
577
+
578
+ let translations = {};
579
+ let locale = navigator.language || "en";
580
+ // Transform en-US to en, etc.
581
+ // We don't support variants yet
582
+ if (locale.includes("-")) {
583
+ locale = locale.split("-")[0];
584
+ }
585
+ const availableLanguages = [
586
+ // GENERATED by scripts/update-locales.js
587
+ "ar", "ar-EG", "bg", "bn", "ca", "ce", "ceb", "cs", "da", "de", "el", "emoji", "en", "eo", "es", "eu", "fa", "fi", "fr", "gu", "ha", "he", "hi", "hr", "hu", "hy", "id", "it", "ja", "jv", "ko", "mr", "ms", "nan", "nb", "nl", "pa", "pl", "pt", "pt-BR", "ro", "ru", "sk", "sl", "sr", "sv", "sw", "ta", "te", "th", "tl", "tr", "tt", "uk", "ur", "uz", "vi", "war", "zh", "zh-simplified"
588
+ // END GENERATED
589
+ ];
590
+ // Fallback to a valid dropdown value for unsupported locales
591
+ if (!availableLanguages.includes(locale)) {
592
+ locale = "en";
593
+ }
594
+ try {
595
+ // Load settings early so that they can be used to define settings (among other things)
596
+ // It's a bit hacky to load them twice but yeah
597
+ // (Actually in the desktop app it's even more hacky because I
598
+ // added code in electron-app.html to load the settings via the electron API
599
+ // and populate localStorage so that this code will work)
600
+ const settingsJSON = localStorage.getItem("tracky-mouse-settings");
601
+ if (settingsJSON) {
602
+ locale = JSON.parse(settingsJSON)?.globalSettings?.language || locale;
603
+ }
604
+ if (locale !== "en") {
605
+ // synchronous XHR baby!
606
+ const request = new XMLHttpRequest();
607
+ request.open("GET", `${TrackyMouse.dependenciesRoot}/locales/${locale}/translation.json`, false);
608
+ request.send(null);
609
+ if (request.status === 200) {
610
+ translations = JSON.parse(request.responseText);
611
+ } else {
612
+ console.warn(`Could not load translations for locale ${locale} (status ${request.status})`);
613
+ }
614
+ }
615
+ } catch (e) {
616
+ console.warn("Could not load translations for TrackyMouse UI:", e);
617
+ }
618
+ const rtlLanguages = ["ar", "he", "fa", "ur"]; // Right-to-left languages (current and future)
619
+ const isRTL = rtlLanguages.includes(locale.split("-")[0]);
620
+ const t = (s) => translations[s] ?? s;
621
+ // console.trace("Initializing UI with locale", locale);
622
+
623
+ // language name mappings marked with * may not be ISO 639-1
624
+ // they may be ISO 639-3 or bespoke
625
+ // spell-checker:disable
626
+ const languageNames = {
627
+ // "639-1": [["ISO language name"], ["Native name (endonym)"]],
628
+ ab: [["Abkhazian"], ["Аҧсуа Бызшәа", "Аҧсшәа"]],
629
+ aa: [["Afar"], ["Afaraf"]],
630
+ af: [["Afrikaans"], ["Afrikaans"]],
631
+ ak: [["Akan"], ["Akan"]],
632
+ sq: [["Albanian"], ["Shqip"]],
633
+ am: [["Amharic"], ["አማርኛ"]],
634
+ ar: [["Arabic"], ["العربية"]],
635
+ "ar-EG": [["Egyptian Arabic"], ["العربية المصرية"]],//*
636
+ an: [["Aragonese"], ["Aragonés"]],
637
+ hy: [["Armenian"], ["Հայերեն"]],
638
+ as: [["Assamese"], ["অসমীয়া"]],
639
+ av: [["Avaric"], ["Авар МацӀ", "МагӀарул МацӀ"]],
640
+ ae: [["Avestan"], ["Avesta"]],
641
+ ay: [["Aymara"], ["Aymar Aru"]],
642
+ az: [["Azerbaijani"], ["Azərbaycan Dili"]],
643
+ bm: [["Bambara"], ["Bamanankan"]],
644
+ ba: [["Bashkir"], ["Башҡорт Теле"]],
645
+ emoji: [["Emoji"], ["😃📝"]],//*
646
+ eu: [["Basque"], ["Euskara", "Euskera"]],
647
+ be: [["Belarusian"], ["Беларуская Мова"]],
648
+ bn: [["Bengali"], ["বাংলা"]],
649
+ bh: [["Bihari Languages"], ["भोजपुरी"]],
650
+ bi: [["Bislama"], ["Bislama"]],
651
+ bs: [["Bosnian"], ["Bosanski Jezik"]],
652
+ br: [["Breton"], ["Brezhoneg"]],
653
+ bg: [["Bulgarian"], ["Български Език"]],
654
+ my: [["Burmese"], ["ဗမာစာ"]],
655
+ ca: [["Catalan", "Valencian"], ["Català", "Valencià"]],
656
+ ch: [["Chamorro"], ["Chamoru"]],
657
+ ce: [["Chechen"], ["Нохчийн Мотт"]],
658
+ ceb: [["Cebuano"], ["Bisayâ", "Binisayâ"]],//*
659
+ ny: [["Chichewa", "Chewa", "Nyanja"], ["ChiCheŵa", "Chinyanja"]],
660
+ // zh: [["Chinese"], ["中文", "Zhōngwén", "汉语", "漢語"]],
661
+ // The ISO 639-1 code "zh" doesn't refer to Traditional Chinese specifically,
662
+ // but we want to show the distinction between Chinese varieties in the Language menu,
663
+ // so this is overly specific for now.
664
+ // @TODO: do this cleaner by establishing a mapping between ISO codes (such as "zh") and default language IDs (such as "zh-traditional")
665
+ zh: [["Traditional Chinese"], ["繁體中文", "傳統中文", "正體中文", "繁体中文"]],
666
+ "zh-traditional": [["Traditional Chinese"], ["繁體中文", "傳統中文", "正體中文", "繁体中文"]], //*
667
+ "zh-simplified": [["Simplified Chinese"], ["简体中文"]], //*
668
+ cv: [["Chuvash"], ["Чӑваш Чӗлхи"]],
669
+ kw: [["Cornish"], ["Kernewek"]],
670
+ co: [["Corsican"], ["Corsu", "Lingua Corsa"]],
671
+ cr: [["Cree"], ["ᓀᐦᐃᔭᐍᐏᐣ"]],
672
+ hr: [["Croatian"], ["Hrvatski Jezik"]],
673
+ cs: [["Czech"], ["Čeština", "Český Jazyk"]],
674
+ da: [["Danish"], ["Dansk"]],
675
+ dv: [["Divehi", "Dhivehi", "Maldivian"], ["ދިވެހި"]],
676
+ nl: [["Dutch", "Flemish"], ["Nederlands", "Vlaams"]],
677
+ dz: [["Dzongkha"], ["རྫོང་ཁ"]],
678
+ en: [["English"], ["English"]],
679
+ eo: [["Esperanto"], ["Esperanto"]],
680
+ et: [["Estonian"], ["Eesti", "Eesti Keel"]],
681
+ ee: [["Ewe"], ["Eʋegbe"]],
682
+ fo: [["Faroese"], ["Føroyskt"]],
683
+ fj: [["Fijian"], ["Vosa Vakaviti"]],
684
+ fi: [["Finnish"], ["Suomi", "Suomen Kieli"]],
685
+ fr: [["French"], ["Français", "Langue Française"]],
686
+ ff: [["Fulah"], ["Fulfulde", "Pulaar", "Pular"]],
687
+ gl: [["Galician"], ["Galego"]],
688
+ ka: [["Georgian"], ["ქართული"]],
689
+ de: [["German"], ["Deutsch"]],
690
+ el: [["Greek"], ["Ελληνικά"]],
691
+ gn: [["Guarani"], ["Avañe'ẽ"]],
692
+ gu: [["Gujarati"], ["ગુજરાતી"]],
693
+ ht: [["Haitian", "Haitian Creole"], ["Kreyòl Ayisyen"]],
694
+ ha: [["Hausa"], ["هَوُسَ"]],
695
+ he: [["Hebrew"], ["עברית"]],
696
+ hz: [["Herero"], ["Otjiherero"]],
697
+ hi: [["Hindi"], ["हिन्दी", "हिंदी"]],
698
+ ho: [["Hiri Motu"], ["Hiri Motu"]],
699
+ hu: [["Hungarian"], ["Magyar"]],
700
+ ia: [["Interlingua"], ["Interlingua"]],
701
+ id: [["Indonesian"], ["Bahasa Indonesia"]],
702
+ ie: [["Interlingue", "Occidental"], ["Interlingue", "Occidental"]],
703
+ ga: [["Irish"], ["Gaeilge"]],
704
+ ig: [["Igbo"], ["Asụsụ Igbo"]],
705
+ ik: [["Inupiaq"], ["Iñupiaq", "Iñupiatun"]],
706
+ io: [["Ido"], ["Ido"]],
707
+ is: [["Icelandic"], ["Íslenska"]],
708
+ it: [["Italian"], ["Italiano"]],
709
+ iu: [["Inuktitut"], ["ᐃᓄᒃᑎᑐᑦ"]],
710
+ ja: [["Japanese"], ["日本語", "にほんご"]],
711
+ jv: [["Javanese"], ["ꦧꦱꦗꦮ", "Basa Jawa"]],
712
+ kl: [["Kalaallisut", "Greenlandic"], ["Kalaallisut", "Kalaallit Oqaasii"]],
713
+ kn: [["Kannada"], ["ಕನ್ನಡ"]],
714
+ kr: [["Kanuri"], ["Kanuri"]],
715
+ ks: [["Kashmiri"], ["कश्मीरी", "كشميري‎"]],
716
+ kk: [["Kazakh"], ["Қазақ Тілі"]],
717
+ km: [["Central Khmer"], ["ខ្មែរ", "ខេមរភាសា", "ភាសាខ្មែរ"]],
718
+ ki: [["Kikuyu", "Gikuyu"], ["Gĩkũyũ"]],
719
+ rw: [["Kinyarwanda"], ["Ikinyarwanda"]],
720
+ ky: [["Kirghiz", "Kyrgyz"], ["Кыргызча", "Кыргыз Тили"]],
721
+ kv: [["Komi"], ["Коми Кыв"]],
722
+ kg: [["Kongo"], ["Kikongo"]],
723
+ ko: [["Korean"], ["한국어"]],
724
+ ku: [["Kurdish"], ["Kurdî", "کوردی‎"]],
725
+ kj: [["Kuanyama", "Kwanyama"], ["Kuanyama"]],
726
+ la: [["Latin"], ["Latine", "Lingua Latina"]],
727
+ lb: [["Luxembourgish", "Letzeburgesch"], ["Lëtzebuergesch"]],
728
+ lg: [["Ganda"], ["Luganda"]],
729
+ li: [["Limburgan", "Limburger", "Limburgish"], ["Limburgs"]],
730
+ ln: [["Lingala"], ["Lingála"]],
731
+ lo: [["Lao"], ["ພາສາລາວ"]],
732
+ lt: [["Lithuanian"], ["Lietuvių Kalba"]],
733
+ lu: [["Luba-Katanga"], ["Kiluba"]],
734
+ lv: [["Latvian"], ["Latviešu Valoda"]],
735
+ gv: [["Manx"], ["Gaelg", "Gailck"]],
736
+ mk: [["Macedonian"], ["Македонски Јазик"]],
737
+ mg: [["Malagasy"], ["Fiteny Malagasy"]],
738
+ ms: [["Malay"], ["Bahasa Melayu", "بهاس ملايو‎"]],
739
+ ml: [["Malayalam"], ["മലയാളം"]],
740
+ mt: [["Maltese"], ["Malti"]],
741
+ mi: [["Maori"], ["Te Reo Māori"]],
742
+ mr: [["Marathi"], ["मराठी"]],
743
+ mh: [["Marshallese"], ["Kajin M̧ajeļ"]],
744
+ mn: [["Mongolian"], ["Монгол Хэл"]],
745
+ na: [["Nauru"], ["Dorerin Naoero"]],
746
+ nan: [["Minnan", "Taiwanese Hokkien"], ["閩南語", "闽南语", "Bàn-lâm-gú", "Bân-lâm-gí"]],//* (technically Hokkien is a branch of Minnan; also idk what names are preferred)
747
+ nv: [["Navajo", "Navaho"], ["Diné Bizaad"]],
748
+ nd: [["North Ndebele"], ["IsiNdebele"]],
749
+ ne: [["Nepali"], ["नेपाली"]],
750
+ ng: [["Ndonga"], ["Owambo"]],
751
+ nb: [["Norwegian Bokmål"], ["Norsk Bokmål"]],
752
+ nn: [["Norwegian Nynorsk"], ["Norsk Nynorsk"]],
753
+ no: [["Norwegian"], ["Norsk"]],
754
+ ii: [["Sichuan Yi", "Nuosu"], ["ꆈꌠ꒿", "Nuosuhxop"]],
755
+ nr: [["South Ndebele"], ["IsiNdebele"]],
756
+ oc: [["Occitan"], ["Occitan", "Lenga d'Òc"]],
757
+ oj: [["Ojibwa"], ["ᐊᓂᔑᓈᐯᒧᐎᓐ"]],
758
+ cu: [["Church Slavic", "Old Slavonic", "Church Slavonic", "Old Bulgarian", "Old Church Slavonic"], ["Ѩзыкъ Словѣньскъ"]],
759
+ om: [["Oromo"], ["Afaan Oromoo"]],
760
+ or: [["Oriya"], ["ଓଡ଼ିଆ"]],
761
+ os: [["Ossetian", "Ossetic"], ["Ирон Æвзаг"]],
762
+ pa: [["Punjabi", "Panjabi"], ["ਪੰਜਾਬੀ", "پنجابی‎"]],
763
+ pi: [["Pali"], ["पालि", "पाळि"]],
764
+ fa: [["Persian"], ["فارسی"]],
765
+ pl: [["Polish"], ["Język Polski", "Polszczyzna"]],
766
+ ps: [["Pashto", "Pushto"], ["پښتو"]],
767
+ pt: [["Portuguese"], ["Português"]],
768
+ "pt-BR": [["Brazilian Portuguese"], ["Português Brasileiro"]],
769
+ "pt-PT": [["Portuguese (Portugal)"], ["Português De Portugal"]],
770
+ qu: [["Quechua"], ["Runa Simi", "Kichwa"]],
771
+ rm: [["Romansh"], ["Rumantsch Grischun"]],
772
+ rn: [["Rundi"], ["Ikirundi"]],
773
+ ro: [["Romanian", "Moldavian", "Moldovan"], ["Română"]],
774
+ ru: [["Russian"], ["Русский"]],
775
+ sa: [["Sanskrit"], ["संस्कृतम्"]],
776
+ sc: [["Sardinian"], ["Sardu"]],
777
+ sd: [["Sindhi"], ["सिन्धी", "سنڌي، سندھی‎"]],
778
+ se: [["Northern Sami"], ["Davvisámegiella"]],
779
+ sm: [["Samoan"], ["Gagana Fa'a Samoa"]],
780
+ sg: [["Sango"], ["Yângâ Tî Sängö"]],
781
+ sr: [["Serbian"], ["Српски Језик"]],
782
+ gd: [["Gaelic", "Scottish Gaelic"], ["Gàidhlig"]],
783
+ sn: [["Shona"], ["ChiShona"]],
784
+ si: [["Sinhala", "Sinhalese"], ["සිංහල"]],
785
+ sk: [["Slovak"], ["Slovenčina", "Slovenský Jazyk"]],
786
+ sl: [["Slovenian"], ["Slovenski Jezik", "Slovenščina"]],
787
+ so: [["Somali"], ["Soomaaliga", "Af Soomaali"]],
788
+ st: [["Southern Sotho"], ["Sesotho"]],
789
+ es: [["Spanish", "Castilian"], ["Español"]],
790
+ su: [["Sundanese"], ["Basa Sunda"]],
791
+ sw: [["Swahili"], ["Kiswahili"]],
792
+ ss: [["Swati"], ["SiSwati"]],
793
+ sv: [["Swedish"], ["Svenska"]],
794
+ ta: [["Tamil"], ["தமிழ்"]],
795
+ te: [["Telugu"], ["తెలుగు"]],
796
+ tg: [["Tajik"], ["Тоҷикӣ", "Toçikī", "تاجیکی‎"]],
797
+ th: [["Thai"], ["ไทย"]],
798
+ ti: [["Tigrinya"], ["ትግርኛ"]],
799
+ bo: [["Tibetan"], ["བོད་ཡིག"]],
800
+ tk: [["Turkmen"], ["Türkmen", "Түркмен"]],
801
+ tl: [["Tagalog"], ["Wikang Tagalog"]],
802
+ tn: [["Tswana"], ["Setswana"]],
803
+ to: [["Tonga"], ["Faka Tonga"]],
804
+ tr: [["Turkish"], ["Türkçe"]],
805
+ ts: [["Tsonga"], ["Xitsonga"]],
806
+ tt: [["Tatar"], ["Татар Теле", "Tatar Tele"]],
807
+ tw: [["Twi"], ["Twi"]],
808
+ ty: [["Tahitian"], ["Reo Tahiti"]],
809
+ ug: [["Uighur", "Uyghur"], ["ئۇيغۇرچە‎", "Uyghurche"]],
810
+ uk: [["Ukrainian"], ["Українська"]],
811
+ ur: [["Urdu"], ["اردو"]],
812
+ uz: [["Uzbek"], ["Oʻzbek", "Ўзбек", "أۇزبېك‎"]],
813
+ ve: [["Venda"], ["Tshivenḓa"]],
814
+ vi: [["Vietnamese"], ["Tiếng Việt"]],
815
+ vo: [["Volapük"], ["Volapük"]],
816
+ wa: [["Walloon"], ["Walon"]],
817
+ war: [["Waray"], ["Winaray"]],//*
818
+ cy: [["Welsh"], ["Cymraeg"]],
819
+ wo: [["Wolof"], ["Wollof"]],
820
+ fy: [["Western Frisian"], ["Frysk"]],
821
+ xh: [["Xhosa"], ["IsiXhosa"]],
822
+ yi: [["Yiddish"], ["ייִדיש"]],
823
+ yo: [["Yoruba"], ["Yorùbá"]],
824
+ za: [["Zhuang", "Chuang"], ["Saɯ Cueŋƅ", "Saw Cuengh"]],
825
+ zu: [["Zulu"], ["IsiZulu"]],
826
+ };
827
+
828
+
829
+ var languageToDefaultRegion = {
830
+ aa: "ET",
831
+ ab: "GE",
832
+ abr: "GH",
833
+ ace: "ID",
834
+ ach: "UG",
835
+ ada: "GH",
836
+ ady: "RU",
837
+ ae: "IR",
838
+ aeb: "TN",
839
+ af: "ZA",
840
+ agq: "CM",
841
+ aho: "IN",
842
+ ak: "GH",
843
+ akk: "IQ",
844
+ aln: "XK",
845
+ alt: "RU",
846
+ am: "ET",
847
+ amo: "NG",
848
+ aoz: "ID",
849
+ apd: "TG",
850
+ ar: "EG",
851
+ arc: "IR",
852
+ "arc-Nbat": "JO",
853
+ "arc-Palm": "SY",
854
+ arn: "CL",
855
+ aro: "BO",
856
+ arq: "DZ",
857
+ ary: "MA",
858
+ arz: "EG",
859
+ as: "IN",
860
+ asa: "TZ",
861
+ ase: "US",
862
+ ast: "ES",
863
+ atj: "CA",
864
+ av: "RU",
865
+ awa: "IN",
866
+ ay: "BO",
867
+ az: "AZ",
868
+ "az-Arab": "IR",
869
+ ba: "RU",
870
+ bal: "PK",
871
+ ban: "ID",
872
+ bap: "NP",
873
+ bar: "AT",
874
+ bas: "CM",
875
+ bax: "CM",
876
+ bbc: "ID",
877
+ bbj: "CM",
878
+ bci: "CI",
879
+ be: "BY",
880
+ bej: "SD",
881
+ bem: "ZM",
882
+ bew: "ID",
883
+ bez: "TZ",
884
+ bfd: "CM",
885
+ bfq: "IN",
886
+ bft: "PK",
887
+ bfy: "IN",
888
+ bg: "BG",
889
+ bgc: "IN",
890
+ bgn: "PK",
891
+ bgx: "TR",
892
+ bhb: "IN",
893
+ bhi: "IN",
894
+ bhk: "PH",
895
+ bho: "IN",
896
+ bi: "VU",
897
+ bik: "PH",
898
+ bin: "NG",
899
+ bjj: "IN",
900
+ bjn: "ID",
901
+ bjt: "SN",
902
+ bkm: "CM",
903
+ bku: "PH",
904
+ blt: "VN",
905
+ bm: "ML",
906
+ bmq: "ML",
907
+ bn: "BD",
908
+ bo: "CN",
909
+ bpy: "IN",
910
+ bqi: "IR",
911
+ bqv: "CI",
912
+ br: "FR",
913
+ bra: "IN",
914
+ brh: "PK",
915
+ brx: "IN",
916
+ bs: "BA",
917
+ bsq: "LR",
918
+ bss: "CM",
919
+ bto: "PH",
920
+ btv: "PK",
921
+ bua: "RU",
922
+ buc: "YT",
923
+ bug: "ID",
924
+ bum: "CM",
925
+ bvb: "GQ",
926
+ byn: "ER",
927
+ byv: "CM",
928
+ bze: "ML",
929
+ ca: "ES",
930
+ cch: "NG",
931
+ ccp: "BD",
932
+ ce: "RU",
933
+ ceb: "PH",
934
+ cgg: "UG",
935
+ ch: "GU",
936
+ chk: "FM",
937
+ chm: "RU",
938
+ cho: "US",
939
+ chp: "CA",
940
+ chr: "US",
941
+ cja: "KH",
942
+ cjm: "VN",
943
+ ckb: "IQ",
944
+ co: "FR",
945
+ cop: "EG",
946
+ cps: "PH",
947
+ cr: "CA",
948
+ crh: "UA",
949
+ crj: "CA",
950
+ crk: "CA",
951
+ crl: "CA",
952
+ crm: "CA",
953
+ crs: "SC",
954
+ cs: "CZ",
955
+ csb: "PL",
956
+ csw: "CA",
957
+ ctd: "MM",
958
+ cu: "RU",
959
+ "cu-Glag": "BG",
960
+ cv: "RU",
961
+ cy: "GB",
962
+ da: "DK",
963
+ dak: "US",
964
+ dar: "RU",
965
+ dav: "KE",
966
+ dcc: "IN",
967
+ de: "DE",
968
+ den: "CA",
969
+ dgr: "CA",
970
+ dje: "NE",
971
+ dnj: "CI",
972
+ doi: "IN",
973
+ dsb: "DE",
974
+ dtm: "ML",
975
+ dtp: "MY",
976
+ dty: "NP",
977
+ dua: "CM",
978
+ dv: "MV",
979
+ dyo: "SN",
980
+ dyu: "BF",
981
+ dz: "BT",
982
+ ebu: "KE",
983
+ ee: "GH",
984
+ efi: "NG",
985
+ egl: "IT",
986
+ egy: "EG",
987
+ eky: "MM",
988
+ el: "GR",
989
+ en: "US",
990
+ "en-Shaw": "GB",
991
+ es: "ES",
992
+ esu: "US",
993
+ et: "EE",
994
+ ett: "IT",
995
+ eu: "ES",
996
+ ewo: "CM",
997
+ ext: "ES",
998
+ fa: "IR",
999
+ fan: "GQ",
1000
+ ff: "SN",
1001
+ "ff-Adlm": "GN",
1002
+ ffm: "ML",
1003
+ fi: "FI",
1004
+ fia: "SD",
1005
+ fil: "PH",
1006
+ fit: "SE",
1007
+ fj: "FJ",
1008
+ fo: "FO",
1009
+ fon: "BJ",
1010
+ fr: "FR",
1011
+ frc: "US",
1012
+ frp: "FR",
1013
+ frr: "DE",
1014
+ frs: "DE",
1015
+ fub: "CM",
1016
+ fud: "WF",
1017
+ fuf: "GN",
1018
+ fuq: "NE",
1019
+ fur: "IT",
1020
+ fuv: "NG",
1021
+ fvr: "SD",
1022
+ fy: "NL",
1023
+ ga: "IE",
1024
+ gaa: "GH",
1025
+ gag: "MD",
1026
+ gan: "CN",
1027
+ gay: "ID",
1028
+ gbm: "IN",
1029
+ gbz: "IR",
1030
+ gcr: "GF",
1031
+ gd: "GB",
1032
+ gez: "ET",
1033
+ ggn: "NP",
1034
+ gil: "KI",
1035
+ gjk: "PK",
1036
+ gju: "PK",
1037
+ gl: "ES",
1038
+ glk: "IR",
1039
+ gn: "PY",
1040
+ gom: "IN",
1041
+ gon: "IN",
1042
+ gor: "ID",
1043
+ gos: "NL",
1044
+ got: "UA",
1045
+ grc: "CY",
1046
+ "grc-Linb": "GR",
1047
+ grt: "IN",
1048
+ gsw: "CH",
1049
+ gu: "IN",
1050
+ gub: "BR",
1051
+ guc: "CO",
1052
+ gur: "GH",
1053
+ guz: "KE",
1054
+ gv: "IM",
1055
+ gvr: "NP",
1056
+ gwi: "CA",
1057
+ ha: "NG",
1058
+ hak: "CN",
1059
+ haw: "US",
1060
+ haz: "AF",
1061
+ he: "IL",
1062
+ hi: "IN",
1063
+ hif: "FJ",
1064
+ hil: "PH",
1065
+ hlu: "TR",
1066
+ hmd: "CN",
1067
+ hnd: "PK",
1068
+ hne: "IN",
1069
+ hnj: "LA",
1070
+ hnn: "PH",
1071
+ hno: "PK",
1072
+ ho: "PG",
1073
+ hoc: "IN",
1074
+ hoj: "IN",
1075
+ hr: "HR",
1076
+ hsb: "DE",
1077
+ hsn: "CN",
1078
+ ht: "HT",
1079
+ hu: "HU",
1080
+ hy: "AM",
1081
+ hz: "NA",
1082
+ ia: "FR",
1083
+ iba: "MY",
1084
+ ibb: "NG",
1085
+ id: "ID",
1086
+ ife: "TG",
1087
+ ig: "NG",
1088
+ ii: "CN",
1089
+ ik: "US",
1090
+ ikt: "CA",
1091
+ ilo: "PH",
1092
+ in: "ID",
1093
+ inh: "RU",
1094
+ is: "IS",
1095
+ it: "IT",
1096
+ iu: "CA",
1097
+ iw: "IL",
1098
+ izh: "RU",
1099
+ ja: "JP",
1100
+ jam: "JM",
1101
+ jgo: "CM",
1102
+ ji: "UA",
1103
+ jmc: "TZ",
1104
+ jml: "NP",
1105
+ jut: "DK",
1106
+ jv: "ID",
1107
+ jw: "ID",
1108
+ ka: "GE",
1109
+ kaa: "UZ",
1110
+ kab: "DZ",
1111
+ kac: "MM",
1112
+ kaj: "NG",
1113
+ kam: "KE",
1114
+ kao: "ML",
1115
+ kbd: "RU",
1116
+ kby: "NE",
1117
+ kcg: "NG",
1118
+ kck: "ZW",
1119
+ kde: "TZ",
1120
+ kdh: "TG",
1121
+ kdt: "TH",
1122
+ kea: "CV",
1123
+ ken: "CM",
1124
+ kfo: "CI",
1125
+ kfr: "IN",
1126
+ kfy: "IN",
1127
+ kg: "CD",
1128
+ kge: "ID",
1129
+ kgp: "BR",
1130
+ kha: "IN",
1131
+ khb: "CN",
1132
+ khn: "IN",
1133
+ khq: "ML",
1134
+ kht: "IN",
1135
+ khw: "PK",
1136
+ ki: "KE",
1137
+ kiu: "TR",
1138
+ kj: "NA",
1139
+ kjg: "LA",
1140
+ kk: "KZ",
1141
+ "kk-Arab": "CN",
1142
+ kkj: "CM",
1143
+ kl: "GL",
1144
+ kln: "KE",
1145
+ km: "KH",
1146
+ kmb: "AO",
1147
+ kn: "IN",
1148
+ knf: "SN",
1149
+ ko: "KR",
1150
+ koi: "RU",
1151
+ kok: "IN",
1152
+ kos: "FM",
1153
+ kpe: "LR",
1154
+ krc: "RU",
1155
+ kri: "SL",
1156
+ krj: "PH",
1157
+ krl: "RU",
1158
+ kru: "IN",
1159
+ ks: "IN",
1160
+ ksb: "TZ",
1161
+ ksf: "CM",
1162
+ ksh: "DE",
1163
+ ku: "TR",
1164
+ "ku-Arab": "IQ",
1165
+ kum: "RU",
1166
+ kv: "RU",
1167
+ kvr: "ID",
1168
+ kvx: "PK",
1169
+ kw: "GB",
1170
+ kxm: "TH",
1171
+ kxp: "PK",
1172
+ ky: "KG",
1173
+ "ky-Arab": "CN",
1174
+ "ky-Latn": "TR",
1175
+ la: "VA",
1176
+ lab: "GR",
1177
+ lad: "IL",
1178
+ lag: "TZ",
1179
+ lah: "PK",
1180
+ laj: "UG",
1181
+ lb: "LU",
1182
+ lbe: "RU",
1183
+ lbw: "ID",
1184
+ lcp: "CN",
1185
+ lep: "IN",
1186
+ lez: "RU",
1187
+ lg: "UG",
1188
+ li: "NL",
1189
+ lif: "NP",
1190
+ "lif-Limb": "IN",
1191
+ lij: "IT",
1192
+ lis: "CN",
1193
+ ljp: "ID",
1194
+ lki: "IR",
1195
+ lkt: "US",
1196
+ lmn: "IN",
1197
+ lmo: "IT",
1198
+ ln: "CD",
1199
+ lo: "LA",
1200
+ lol: "CD",
1201
+ loz: "ZM",
1202
+ lrc: "IR",
1203
+ lt: "LT",
1204
+ ltg: "LV",
1205
+ lu: "CD",
1206
+ lua: "CD",
1207
+ luo: "KE",
1208
+ luy: "KE",
1209
+ luz: "IR",
1210
+ lv: "LV",
1211
+ lwl: "TH",
1212
+ lzh: "CN",
1213
+ lzz: "TR",
1214
+ mad: "ID",
1215
+ maf: "CM",
1216
+ mag: "IN",
1217
+ mai: "IN",
1218
+ mak: "ID",
1219
+ man: "GM",
1220
+ "man-Nkoo": "GN",
1221
+ mas: "KE",
1222
+ maz: "MX",
1223
+ mdf: "RU",
1224
+ mdh: "PH",
1225
+ mdr: "ID",
1226
+ men: "SL",
1227
+ mer: "KE",
1228
+ mfa: "TH",
1229
+ mfe: "MU",
1230
+ mg: "MG",
1231
+ mgh: "MZ",
1232
+ mgo: "CM",
1233
+ mgp: "NP",
1234
+ mgy: "TZ",
1235
+ mh: "MH",
1236
+ mi: "NZ",
1237
+ min: "ID",
1238
+ mis: "IQ",
1239
+ mk: "MK",
1240
+ ml: "IN",
1241
+ mls: "SD",
1242
+ mn: "MN",
1243
+ "mn-Mong": "CN",
1244
+ mni: "IN",
1245
+ mnw: "MM",
1246
+ moe: "CA",
1247
+ moh: "CA",
1248
+ mos: "BF",
1249
+ mr: "IN",
1250
+ mrd: "NP",
1251
+ mrj: "RU",
1252
+ mro: "BD",
1253
+ ms: "MY",
1254
+ mt: "MT",
1255
+ mtr: "IN",
1256
+ mua: "CM",
1257
+ mus: "US",
1258
+ mvy: "PK",
1259
+ mwk: "ML",
1260
+ mwr: "IN",
1261
+ mwv: "ID",
1262
+ mxc: "ZW",
1263
+ my: "MM",
1264
+ myv: "RU",
1265
+ myx: "UG",
1266
+ myz: "IR",
1267
+ mzn: "IR",
1268
+ na: "NR",
1269
+ nan: "CN",
1270
+ nap: "IT",
1271
+ naq: "NA",
1272
+ nb: "NO",
1273
+ nch: "MX",
1274
+ nd: "ZW",
1275
+ ndc: "MZ",
1276
+ nds: "DE",
1277
+ ne: "NP",
1278
+ new: "NP",
1279
+ ng: "NA",
1280
+ ngl: "MZ",
1281
+ nhe: "MX",
1282
+ nhw: "MX",
1283
+ nij: "ID",
1284
+ niu: "NU",
1285
+ njo: "IN",
1286
+ nl: "NL",
1287
+ nmg: "CM",
1288
+ nn: "NO",
1289
+ nnh: "CM",
1290
+ no: "NO",
1291
+ nod: "TH",
1292
+ noe: "IN",
1293
+ non: "SE",
1294
+ nqo: "GN",
1295
+ nr: "ZA",
1296
+ nsk: "CA",
1297
+ nso: "ZA",
1298
+ nus: "SS",
1299
+ nv: "US",
1300
+ nxq: "CN",
1301
+ ny: "MW",
1302
+ nym: "TZ",
1303
+ nyn: "UG",
1304
+ nzi: "GH",
1305
+ oc: "FR",
1306
+ om: "ET",
1307
+ or: "IN",
1308
+ os: "GE",
1309
+ osa: "US",
1310
+ otk: "MN",
1311
+ pa: "IN",
1312
+ "pa-Arab": "PK",
1313
+ pag: "PH",
1314
+ pal: "IR",
1315
+ "pal-Phlp": "CN",
1316
+ pam: "PH",
1317
+ pap: "AW",
1318
+ pau: "PW",
1319
+ pcd: "FR",
1320
+ pcm: "NG",
1321
+ pdc: "US",
1322
+ pdt: "CA",
1323
+ peo: "IR",
1324
+ pfl: "DE",
1325
+ phn: "LB",
1326
+ pka: "IN",
1327
+ pko: "KE",
1328
+ pl: "PL",
1329
+ pms: "IT",
1330
+ pnt: "GR",
1331
+ pon: "FM",
1332
+ pra: "PK",
1333
+ prd: "IR",
1334
+ ps: "AF",
1335
+ pt: "PT", //"BR",
1336
+ puu: "GA",
1337
+ qu: "PE",
1338
+ quc: "GT",
1339
+ qug: "EC",
1340
+ raj: "IN",
1341
+ rcf: "RE",
1342
+ rej: "ID",
1343
+ rgn: "IT",
1344
+ ria: "IN",
1345
+ rif: "MA",
1346
+ rjs: "NP",
1347
+ rkt: "BD",
1348
+ rm: "CH",
1349
+ rmf: "FI",
1350
+ rmo: "CH",
1351
+ rmt: "IR",
1352
+ rmu: "SE",
1353
+ rn: "BI",
1354
+ rng: "MZ",
1355
+ ro: "RO",
1356
+ rob: "ID",
1357
+ rof: "TZ",
1358
+ rtm: "FJ",
1359
+ ru: "RU",
1360
+ rue: "UA",
1361
+ rug: "SB",
1362
+ rw: "RW",
1363
+ rwk: "TZ",
1364
+ ryu: "JP",
1365
+ sa: "IN",
1366
+ saf: "GH",
1367
+ sah: "RU",
1368
+ saq: "KE",
1369
+ sas: "ID",
1370
+ sat: "IN",
1371
+ sav: "SN",
1372
+ saz: "IN",
1373
+ sbp: "TZ",
1374
+ sc: "IT",
1375
+ sck: "IN",
1376
+ scn: "IT",
1377
+ sco: "GB",
1378
+ scs: "CA",
1379
+ sd: "PK",
1380
+ "sd-Deva": "IN",
1381
+ "sd-Khoj": "IN",
1382
+ "sd-Sind": "IN",
1383
+ sdc: "IT",
1384
+ sdh: "IR",
1385
+ se: "NO",
1386
+ sef: "CI",
1387
+ seh: "MZ",
1388
+ sei: "MX",
1389
+ ses: "ML",
1390
+ sg: "CF",
1391
+ sga: "IE",
1392
+ sgs: "LT",
1393
+ shi: "MA",
1394
+ shn: "MM",
1395
+ si: "LK",
1396
+ sid: "ET",
1397
+ sk: "SK",
1398
+ skr: "PK",
1399
+ sl: "SI",
1400
+ sli: "PL",
1401
+ sly: "ID",
1402
+ sm: "WS",
1403
+ sma: "SE",
1404
+ smj: "SE",
1405
+ smn: "FI",
1406
+ smp: "IL",
1407
+ sms: "FI",
1408
+ sn: "ZW",
1409
+ snk: "ML",
1410
+ so: "SO",
1411
+ sou: "TH",
1412
+ sq: "AL",
1413
+ sr: "RS",
1414
+ srb: "IN",
1415
+ srn: "SR",
1416
+ srr: "SN",
1417
+ srx: "IN",
1418
+ ss: "ZA",
1419
+ ssy: "ER",
1420
+ st: "ZA",
1421
+ stq: "DE",
1422
+ su: "ID",
1423
+ suk: "TZ",
1424
+ sus: "GN",
1425
+ sv: "SE",
1426
+ sw: "TZ",
1427
+ swb: "YT",
1428
+ swc: "CD",
1429
+ swg: "DE",
1430
+ swv: "IN",
1431
+ sxn: "ID",
1432
+ syl: "BD",
1433
+ syr: "IQ",
1434
+ szl: "PL",
1435
+ ta: "IN",
1436
+ taj: "NP",
1437
+ tbw: "PH",
1438
+ tcy: "IN",
1439
+ tdd: "CN",
1440
+ tdg: "NP",
1441
+ tdh: "NP",
1442
+ te: "IN",
1443
+ tem: "SL",
1444
+ teo: "UG",
1445
+ tet: "TL",
1446
+ tg: "TJ",
1447
+ "tg-Arab": "PK",
1448
+ th: "TH",
1449
+ thl: "NP",
1450
+ thq: "NP",
1451
+ thr: "NP",
1452
+ ti: "ET",
1453
+ tig: "ER",
1454
+ tiv: "NG",
1455
+ tk: "TM",
1456
+ tkl: "TK",
1457
+ tkr: "AZ",
1458
+ tkt: "NP",
1459
+ tl: "PH",
1460
+ tly: "AZ",
1461
+ tmh: "NE",
1462
+ tn: "ZA",
1463
+ to: "TO",
1464
+ tog: "MW",
1465
+ tpi: "PG",
1466
+ tr: "TR",
1467
+ tru: "TR",
1468
+ trv: "TW",
1469
+ ts: "ZA",
1470
+ tsd: "GR",
1471
+ tsf: "NP",
1472
+ tsg: "PH",
1473
+ tsj: "BT",
1474
+ tt: "RU",
1475
+ ttj: "UG",
1476
+ tts: "TH",
1477
+ ttt: "AZ",
1478
+ tum: "MW",
1479
+ tvl: "TV",
1480
+ twq: "NE",
1481
+ txg: "CN",
1482
+ ty: "PF",
1483
+ tyv: "RU",
1484
+ tzm: "MA",
1485
+ udm: "RU",
1486
+ ug: "CN",
1487
+ "ug-Cyrl": "KZ",
1488
+ uga: "SY",
1489
+ uk: "UA",
1490
+ uli: "FM",
1491
+ umb: "AO",
1492
+ und: "US",
1493
+ unr: "IN",
1494
+ "unr-Deva": "NP",
1495
+ unx: "IN",
1496
+ ur: "PK",
1497
+ uz: "UZ",
1498
+ "uz-Arab": "AF",
1499
+ vai: "LR",
1500
+ ve: "ZA",
1501
+ vec: "IT",
1502
+ vep: "RU",
1503
+ vi: "VN",
1504
+ vic: "SX",
1505
+ vls: "BE",
1506
+ vmf: "DE",
1507
+ vmw: "MZ",
1508
+ vot: "RU",
1509
+ vro: "EE",
1510
+ vun: "TZ",
1511
+ wa: "BE",
1512
+ wae: "CH",
1513
+ wal: "ET",
1514
+ war: "PH",
1515
+ wbp: "AU",
1516
+ wbq: "IN",
1517
+ wbr: "IN",
1518
+ wls: "WF",
1519
+ wni: "KM",
1520
+ wo: "SN",
1521
+ wtm: "IN",
1522
+ wuu: "CN",
1523
+ xav: "BR",
1524
+ xcr: "TR",
1525
+ xh: "ZA",
1526
+ xlc: "TR",
1527
+ xld: "TR",
1528
+ xmf: "GE",
1529
+ xmn: "CN",
1530
+ xmr: "SD",
1531
+ xna: "SA",
1532
+ xnr: "IN",
1533
+ xog: "UG",
1534
+ xpr: "IR",
1535
+ xsa: "YE",
1536
+ xsr: "NP",
1537
+ yao: "MZ",
1538
+ yap: "FM",
1539
+ yav: "CM",
1540
+ ybb: "CM",
1541
+ yo: "NG",
1542
+ yrl: "BR",
1543
+ yua: "MX",
1544
+ yue: "HK",
1545
+ "yue-Hans": "CN",
1546
+ za: "CN",
1547
+ zag: "SD",
1548
+ zdj: "KM",
1549
+ zea: "NL",
1550
+ zgh: "MA",
1551
+ zh: "CN",
1552
+ "zh-Bopo": "TW",
1553
+ "zh-Hanb": "TW",
1554
+ "zh-Hant": "TW",
1555
+ zlm: "TG",
1556
+ zmi: "MY",
1557
+ zu: "ZA",
1558
+ zza: "TR",
1559
+ };
1560
+
1561
+ function getLanguageFlagEmoji(locale) {
1562
+
1563
+ if (locale === "emoji") {
1564
+ return "🏳️‍🌈";
1565
+ } else if (locale === "eo") {
1566
+ // return "🏴🟩";
1567
+ return "🟩";
1568
+ // return `<svg viewBox="0 0 600 400" height="20">
1569
+ // <path fill="#FFF" d="m0,0h202v202H0"/>
1570
+ // <path fill="#090" d="m0,200H200V0H600V400H0m58-243 41-126 41,126-107-78h133"/>
1571
+ // </svg>`;
1572
+ }
1573
+
1574
+ var split = locale.toUpperCase().split(/-|_/);
1575
+ var lang = split.shift();
1576
+ var code = split.pop();
1577
+
1578
+ if (!/^[A-Z]{2}$/.test(code)) {
1579
+ code = languageToDefaultRegion[lang.toLowerCase()];
1580
+ }
1581
+
1582
+ if (!code) {
1583
+ return "";
1584
+ }
1585
+
1586
+ const a = String.fromCodePoint(code.codePointAt(0) - 0x41 + 0x1F1E6);
1587
+ const b = String.fromCodePoint(code.codePointAt(1) - 0x41 + 0x1F1E6);
1588
+ return a + b;
1589
+ }
563
1590
 
564
1591
  var uiContainer = div || document.createElement("div");
565
1592
  uiContainer.classList.add("tracky-mouse-ui");
1593
+ uiContainer.classList.toggle("tracky-mouse-rtl", isRTL);
1594
+ uiContainer.dir = isRTL ? "rtl" : "ltr";
566
1595
  uiContainer.innerHTML = `
567
1596
  <div class="tracky-mouse-controls">
568
- <button class="tracky-mouse-start-stop-button" aria-pressed="false" aria-keyshortcuts="F9">Start</button>
1597
+ <button class="tracky-mouse-start-stop-button" aria-pressed="false" aria-keyshortcuts="F9">${t("Start")}</button>
569
1598
  </div>
570
1599
  <div class="tracky-mouse-canvas-container-container">
571
1600
  <div class="tracky-mouse-canvas-container">
572
1601
  <div class="tracky-mouse-canvas-overlay">
573
- <button class="tracky-mouse-use-camera-button">Allow Camera Access</button>
574
- <!--<button class="tracky-mouse-use-camera-button">Use my camera</button>-->
575
- <button class="tracky-mouse-use-demo-footage-button" hidden>Use demo footage</button>
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>
576
1605
  <div class="tracky-mouse-error-message" role="alert" hidden></div>
577
1606
  </div>
578
1607
  <canvas class="tracky-mouse-canvas"></canvas>
579
1608
  </div>
580
1609
  </div>
581
1610
  <p class="tracky-mouse-desktop-app-download-message">
582
- You can control your entire computer with the <a href="https://trackymouse.js.org/">TrackyMouse</a> desktop app.
1611
+ ${t('You can control your entire computer with the <a href="https://trackymouse.js.org/">TrackyMouse</a> desktop app.')}
583
1612
  </p>
584
1613
  `;
585
1614
  if (!div) {
@@ -602,10 +1631,10 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
602
1631
  const settingsCategories = [
603
1632
  {
604
1633
  type: "group",
605
- label: "Cursor Movement",
1634
+ label: t("Cursor Movement"),
606
1635
  settings: [
607
1636
  {
608
- label: "Tilt influence",
1637
+ label: t("Tilt influence"),
609
1638
  className: "tracky-mouse-tilt-influence",
610
1639
  key: "headTrackingTiltInfluence",
611
1640
  settingValueToInputValue: (settingValue) => settingValue * 100,
@@ -615,20 +1644,20 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
615
1644
  max: 100,
616
1645
  default: 0,
617
1646
  labels: {
618
- // min: "Optical flow", // too technical
619
- // min: "Point tracking", // still technical but at least it's terminology we're already using
620
- min: "Point tracking (2D)",
621
- // max: "Head tilt",
622
- max: "Head tilt (3D)",
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)"),
623
1652
  },
624
- // description: "Determines whether cursor movement is based on 3D head tilt, or 2D motion of the face in the camera feed.",
625
- description: `Blends between using point tracking (2D) and detected head tilt (3D).
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).
626
1655
  - 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.
627
1656
  - 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.
628
- - 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.`,
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.`),
629
1658
  },
630
1659
  {
631
- label: "Motion threshold",
1660
+ label: t("Motion threshold"),
632
1661
  className: "tracky-mouse-min-distance",
633
1662
  key: "headTrackingMinDistance",
634
1663
  type: "slider",
@@ -636,20 +1665,20 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
636
1665
  max: 10,
637
1666
  default: 0,
638
1667
  labels: {
639
- min: "Free",
640
- max: "Steady",
1668
+ min: t("Free"),
1669
+ max: t("Steady"),
641
1670
  },
642
- description: "Minimum distance to move the cursor in one frame, in pixels. Helps to fully stop the cursor.",
643
- // description: "Movement less than this distance in pixels will be ignored.",
644
- // description: "Speed in pixels/frame required to move the cursor.",
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."),
645
1674
  },
646
1675
  {
647
1676
  type: "group",
648
- label: "Point tracking",
1677
+ label: t("Point tracking"),
649
1678
  disabled: () => s.headTrackingTiltInfluence === 1,
650
1679
  settings: [
651
1680
  {
652
- label: "Horizontal sensitivity",
1681
+ label: t("Horizontal sensitivity"),
653
1682
  className: "tracky-mouse-sensitivity-x",
654
1683
  key: "headTrackingSensitivityX",
655
1684
  settingValueToInputValue: (settingValue) => settingValue * 1000,
@@ -659,13 +1688,13 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
659
1688
  max: 100,
660
1689
  default: 25,
661
1690
  labels: {
662
- min: "Slow",
663
- max: "Fast",
1691
+ min: t("Slow"),
1692
+ max: t("Fast"),
664
1693
  },
665
- description: "Speed of cursor movement in response to horizontal head movement.",
1694
+ description: t("Speed of cursor movement in response to horizontal head movement."),
666
1695
  },
667
1696
  {
668
- label: "Vertical sensitivity",
1697
+ label: t("Vertical sensitivity"),
669
1698
  className: "tracky-mouse-sensitivity-y",
670
1699
  key: "headTrackingSensitivityY",
671
1700
  settingValueToInputValue: (settingValue) => settingValue * 1000,
@@ -675,13 +1704,13 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
675
1704
  max: 100,
676
1705
  default: 50,
677
1706
  labels: {
678
- min: "Slow",
679
- max: "Fast",
1707
+ min: t("Slow"),
1708
+ max: t("Fast"),
680
1709
  },
681
- description: "Speed of cursor movement in response to vertical head movement.",
1710
+ description: t("Speed of cursor movement in response to vertical head movement."),
682
1711
  },
683
1712
  // {
684
- // label: "Smoothing",
1713
+ // label: t("Smoothing"),
685
1714
  // className: "tracky-mouse-smoothing",
686
1715
  // key: "headTrackingSmoothing",
687
1716
  // type: "slider",
@@ -689,8 +1718,8 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
689
1718
  // max: 100,
690
1719
  // default: 50,
691
1720
  // labels: {
692
- // min: "Linear", // or "Direct", "Raw", "None"
693
- // max: "Smooth", // or "Smoothed"
1721
+ // min: t("Linear"), // or "Direct", "Raw", "None"
1722
+ // max: t("Smooth"), // or "Smoothed"
694
1723
  // },
695
1724
  // },
696
1725
 
@@ -703,7 +1732,7 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
703
1732
  // Should it be swapped? What does other software with acceleration control look like?
704
1733
  // In Windows it's just a checkbox apparently, but it could go as far as a custom curve editor.
705
1734
  {
706
- label: "Acceleration",
1735
+ label: t("Acceleration"),
707
1736
  className: "tracky-mouse-acceleration",
708
1737
  key: "headTrackingAcceleration",
709
1738
  settingValueToInputValue: (settingValue) => settingValue * 100,
@@ -713,23 +1742,23 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
713
1742
  max: 100,
714
1743
  default: 50,
715
1744
  labels: {
716
- min: "Linear", // or "Direct", "Raw"
717
- max: "Smooth",
1745
+ min: t("Linear"), // or "Direct", "Raw"
1746
+ max: t("Smooth"),
718
1747
  },
719
- // description: "Higher acceleration makes the cursor move faster when the head moves quickly, and slower when the head moves slowly.",
720
- // description: "Makes the cursor move extra fast for quick head movements, and extra slow for slow head movements. Helps to stabilize the cursor.",
721
- description: `Makes the cursor move relatively fast for quick head movements, and relatively slow for slow head movements.
722
- 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.`,
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.`),
723
1752
  },
724
1753
  ],
725
1754
  },
726
1755
  {
727
1756
  type: "group",
728
- label: "Head tilt calibration",
1757
+ label: t("Head tilt calibration"),
729
1758
  disabled: () => s.headTrackingTiltInfluence === 0,
730
1759
  settings: [
731
1760
  {
732
- label: "Horizontal tilt range",
1761
+ label: t("Horizontal tilt range"),
733
1762
  className: "tracky-mouse-head-tilt-yaw-range",
734
1763
  key: "headTiltYawRange",
735
1764
  settingValueToInputValue: (settingValue) => settingValue * 180 / Math.PI,
@@ -739,16 +1768,16 @@ Helps to stabilize the cursor. However, when using point tracking in combination
739
1768
  max: 90,
740
1769
  default: 60,
741
1770
  labels: {
742
- min: "Little neck movement",
743
- max: "Large neck movement",
1771
+ min: t("Little neck movement"),
1772
+ max: t("Large neck movement"),
744
1773
  },
745
- // description: "Range of horizontal head tilt that moves the cursor from one side of the screen to the other.",
746
- // description: "How much you need to tilt your head left and right to reach the edges of the screen.",
747
- // description: "How much you need to tilt your head left or right to reach the edge of the screen.",
748
- description: "Controls how much you need to tilt your head left or right to reach the edge of the screen.",
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."),
749
1778
  },
750
1779
  {
751
- label: "Vertical tilt range",
1780
+ label: t("Vertical tilt range"),
752
1781
  className: "tracky-mouse-head-tilt-pitch-range",
753
1782
  key: "headTiltPitchRange",
754
1783
  settingValueToInputValue: (settingValue) => settingValue * 180 / Math.PI,
@@ -758,17 +1787,17 @@ Helps to stabilize the cursor. However, when using point tracking in combination
758
1787
  max: 60,
759
1788
  default: 25,
760
1789
  labels: {
761
- min: "Little neck movement",
762
- max: "Large neck movement",
1790
+ min: t("Little neck movement"),
1791
+ max: t("Large neck movement"),
763
1792
  },
764
- // description: "Range of vertical head tilt required to move the cursor from the top to the bottom of the screen.",
765
- // description: "How much you need to tilt your head up and down to reach the edges of the screen.",
766
- // description: "How much you need to tilt your head up or down to reach the edge of the screen.",
767
- description: "Controls how much you need to tilt your head up or down to reach the edge of the screen.",
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."),
768
1797
  },
769
1798
  {
770
1799
  // label: "Horizontal tilt offset",
771
- label: "Horizontal cursor offset",
1800
+ label: t("Horizontal cursor offset"),
772
1801
  className: "tracky-mouse-head-tilt-yaw-offset",
773
1802
  key: "headTiltYawOffset",
774
1803
  settingValueToInputValue: (settingValue) => settingValue * 180 / Math.PI,
@@ -778,8 +1807,8 @@ Helps to stabilize the cursor. However, when using point tracking in combination
778
1807
  max: 45,
779
1808
  default: 0,
780
1809
  labels: {
781
- min: "Left",
782
- max: "Right",
1810
+ min: t("Left"),
1811
+ max: t("Right"),
783
1812
  },
784
1813
  // TODO: how to describe this??
785
1814
  // Specifically, how to disambiguate which direction is which / which way to adjust it?
@@ -787,15 +1816,15 @@ Helps to stabilize the cursor. However, when using point tracking in combination
787
1816
  // Since it's opposite, even though it's technically yaw (angle units), it's easier to think of as moving the cursor.
788
1817
  // Hence I've renamed the setting.
789
1818
  // A later update might change the definitions and include a settings file format upgrade step.
790
- // description: "Adjusts the center position of horizontal head tilt. Not recommended. Move the camera instead if possible.",
791
- // description: "Adjusts the center position of horizontal head tilt. This horizontal offset is not recommended. Move the camera instead if possible.",
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."),
792
1821
  // TODO: should this say "horizontal" in the (main part of the) description?
793
- description: `Adjusts the position of the cursor when the camera sees the head facing straight ahead.
794
- ⚠️ This horizontal offset is not recommended. Move the camera instead if possible. 📷`,
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. 📷`),
795
1824
  },
796
1825
  {
797
1826
  // label: "Vertical tilt offset",
798
- label: "Vertical cursor offset",
1827
+ label: t("Vertical cursor offset"),
799
1828
  className: "tracky-mouse-head-tilt-pitch-offset",
800
1829
  key: "headTiltPitchOffset",
801
1830
  settingValueToInputValue: (settingValue) => settingValue * 180 / Math.PI,
@@ -805,11 +1834,11 @@ Helps to stabilize the cursor. However, when using point tracking in combination
805
1834
  max: 30,
806
1835
  default: 2.5,
807
1836
  labels: {
808
- min: "Down",
809
- max: "Up",
1837
+ min: t("Down"),
1838
+ max: t("Up"),
810
1839
  },
811
- // description: "Adjusts the center position of vertical head tilt.",
812
- description: `Adjusts the position of the cursor when the camera sees the head facing straight ahead.`,
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."),
813
1842
  },
814
1843
  ],
815
1844
  },
@@ -829,40 +1858,42 @@ Helps to stabilize the cursor. However, when using point tracking in combination
829
1858
  // which awkwardly affects what mouse button serenade-driver sends; this doesn't affect the web version.
830
1859
  {
831
1860
  type: "group",
832
- label: "Clicking",
1861
+ label: t("Clicking"),
833
1862
  settings: [
834
1863
  {
835
- label: "Clicking mode:", // TODO: ":"?
1864
+ label: t("Clicking mode:"), // TODO: ":"?
836
1865
  className: "tracky-mouse-clicking-mode",
837
1866
  key: "clickingMode",
838
1867
  type: "dropdown",
839
1868
  options: [
840
- { value: "dwell", label: "Dwell to click" },
841
- { value: "blink", label: "Wink to click" },
842
- { value: "open-mouth", label: "Open mouth to click" },
843
- { value: "off", label: "Off" },
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.") },
1871
+ // TODO: clarify that ooh works better than ah
1872
+ // "open wide" refers to height, but could be misinterpreted as opposite advice - a wide mouth shape when narrow works better
1873
+ // "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
+ // 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.") },
844
1879
  ],
845
1880
  default: "dwell",
846
- platform: "desktop",
847
- description: `Choose how to perform mouse clicks.
848
- - Dwell to click: Hold the cursor in place for a short time to click.
849
- - Wink to click: Close one eye to click. Left eye for left click, right eye for right click.
850
- - Open mouth to click: 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.
851
- - Off: Disable clicking. Use with an external switch or programs that provide their own dwell clicking.`,
1881
+ visible: () => isDesktopApp,
1882
+ description: t("Choose how to perform mouse clicks."),
852
1883
  },
853
1884
  {
854
1885
  // on Windows, currently, when buttons are swapped at the system level, it affects serenade-driver's click()
855
1886
  // "swap" is purposefully generic language so we don't have to know what system-level setting is
856
1887
  // (also this may be seen as a weirdly named/designed option for right-clicking with the dwell clicker)
857
- label: "Swap mouse buttons",
1888
+ label: t("Swap mouse buttons"),
858
1889
  className: "tracky-mouse-swap-mouse-buttons",
859
1890
  key: "swapMouseButtons",
860
1891
  type: "checkbox",
861
1892
  default: false,
862
- platform: "desktop",
863
- description: `Switches the left and right mouse buttons.
1893
+ visible: () => isDesktopApp,
1894
+ description: t(`Switches the left and right mouse buttons.
864
1895
  Useful if your system's mouse buttons are swapped.
865
- Could also be used to right click with the dwell clicker in a pinch.`,
1896
+ Could also be used to right click with the dwell clicker in a pinch.`),
866
1897
  },
867
1898
 
868
1899
  // This setting could called "click stabilization", "drag delay", "delay before dragging", "click drag delay", "drag prevention", etc.
@@ -871,33 +1902,33 @@ Could also be used to right click with the dwell clicker in a pinch.`,
871
1902
  // at the end of the slider, although you shouldn't need to do that to effectively avoid dragging when trying to click,
872
1903
  // and it might complicate the design of the slider labeling.
873
1904
  {
874
- label: "Delay before dragging&nbsp;&nbsp;&nbsp;", // TODO: avoid non-breaking space hack
1905
+ label: t("Delay before dragging&nbsp;&nbsp;&nbsp;"), // TODO: avoid non-breaking space hack
875
1906
  className: "tracky-mouse-delay-before-dragging",
876
1907
  key: "delayBeforeDragging",
877
1908
  type: "slider",
878
1909
  min: 0,
879
1910
  max: 1000,
880
1911
  labels: {
881
- min: "Easy to drag",
882
- max: "Easy to click",
1912
+ min: t("Easy to drag"),
1913
+ max: t("Easy to click"),
883
1914
  },
884
- default: 0, // TODO: increase default
885
- platform: "desktop",
1915
+ default: 800,
1916
+ visible: () => isDesktopApp,
886
1917
  disabled: () => s.clickingMode === "off" || s.clickingMode === "dwell",
887
- // description: "Locks mouse movement during the start of a click to prevent accidental dragging.",
888
- // description: `Prevents mouse movement for the specified time after a click starts.
889
- // 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.`,
890
- description: `Locks mouse movement for the given duration during the start of a click.
891
- 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.`,
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.`),
892
1923
  },
893
1924
  ],
894
1925
  },
895
1926
  {
896
1927
  type: "group",
897
- label: "Video",
1928
+ label: t("Video"),
898
1929
  settings: [
899
1930
  {
900
- label: "Camera source",
1931
+ label: t("Camera source"),
901
1932
  className: "tracky-mouse-camera-select",
902
1933
  key: "cameraDeviceId",
903
1934
  handleSettingChange: () => {
@@ -905,63 +1936,63 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
905
1936
  },
906
1937
  type: "dropdown",
907
1938
  options: [
908
- { value: "", label: "Default" },
1939
+ { value: "", label: t("Default") },
909
1940
  ],
910
1941
  default: "",
911
- // description: "Select which camera to use for head tracking.",
912
- description: "Selects which camera is used for head tracking.",
1942
+ // description: t("Select which camera to use for head tracking."),
1943
+ description: t("Selects which camera is used for head tracking."),
913
1944
  },
914
1945
  // TODO: move this inline with the camera source dropdown?
915
1946
  {
916
- label: "Open Camera Settings",
1947
+ label: t("Open Camera Settings"),
917
1948
  className: "tracky-mouse-open-camera-settings",
918
1949
  key: "openCameraSettings",
919
1950
  type: "button",
920
- platform: "desktop",
1951
+ visible: () => isDesktopApp,
921
1952
  onClick: async () => {
922
1953
  let knownCameras = {};
923
1954
  try {
924
1955
  knownCameras = JSON.parse(localStorage.getItem("tracky-mouse-known-cameras")) || {};
925
1956
  } catch (error) {
926
- alert("Failed to open camera settings:\n" + "Failed to parse known cameras from localStorage:\n" + error.message);
1957
+ alert(t("Failed to open camera settings:\n") + t("Failed to parse known cameras from localStorage:\n") + error.message);
927
1958
  return;
928
1959
  }
929
1960
 
930
1961
  const activeStream = cameraVideo.srcObject;
931
1962
  const activeDeviceId = activeStream?.getVideoTracks()[0]?.getSettings()?.deviceId;
932
- const selectedDeviceName = knownCameras[activeDeviceId]?.name || "Default";
1963
+ const selectedDeviceName = knownCameras[activeDeviceId]?.name || t("Default");
933
1964
 
934
1965
  try {
935
1966
  const result = await window.electronAPI.openCameraSettings(selectedDeviceName);
936
1967
  if (result?.error) {
937
- alert("Failed to open camera settings:\n" + result.error);
1968
+ alert(t("Failed to open camera settings:\n") + result.error);
938
1969
  }
939
1970
  } catch (error) {
940
- alert("Failed to open camera settings:\n" + error.message);
1971
+ alert(t("Failed to open camera settings:\n") + error.message);
941
1972
  }
942
1973
  },
943
- // description: "Open your camera's system settings window to adjust properties like brightness and contrast.",
944
- // description: "Opens the system settings window for your camera to adjust properties like auto-focus and auto-exposure.",
945
- description: "Opens the system settings dialog for the selected camera, to adjust properties like auto-focus and auto-exposure.",
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."),
946
1977
  },
947
1978
  // TODO: try moving this to the corner of the camera view, so it's clearer it applies only to the camera view
948
1979
  {
949
- label: "Mirror",
1980
+ label: t("Mirror"),
950
1981
  className: "tracky-mouse-mirror",
951
1982
  key: "mirror",
952
1983
  type: "checkbox",
953
1984
  default: true,
954
- description: "Mirrors the camera view horizontally.",
1985
+ description: t("Mirrors the camera view horizontally."),
955
1986
  },
956
1987
  ]
957
1988
  },
958
1989
  {
959
1990
  type: "group",
960
- label: "General",
1991
+ label: t("General"),
961
1992
  settings: [
962
1993
  // opposite, "Start paused", might be clearer, especially if I add a "pause" button
963
1994
  {
964
- label: "Start enabled",
1995
+ label: t("Start enabled"),
965
1996
  className: "tracky-mouse-start-enabled",
966
1997
  key: "startEnabled",
967
1998
  afterInitialLoad: () => { // TODO: does this hook make sense? right now it's the only usage. could this code not just be called later?
@@ -969,10 +2000,10 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
969
2000
  },
970
2001
  type: "checkbox",
971
2002
  default: false,
972
- description: "If enabled, Tracky Mouse will start controlling the cursor as soon as it's launched.",
973
- // description: "Makes Tracky Mouse active when launched. Otherwise, you can start it manually when you're ready.",
974
- // description: "Makes Tracky Mouse active as soon as it's launched.",
975
- // description: "Automatically starts Tracky Mouse as soon as it's run.",
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."),
976
2007
  },
977
2008
  {
978
2009
  // For "experimental" label:
@@ -980,33 +2011,55 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
980
2011
  // - I considered adding "⚠︎" but it feels a little too alarming
981
2012
  // 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>)",
982
2013
  // 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>)",
983
- label: "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>)",
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>)"),
984
2015
  className: "tracky-mouse-close-eyes-to-toggle",
985
2016
  key: "closeEyesToToggle",
986
2017
  type: "checkbox",
987
2018
  default: false,
988
- description: "If enabled, you can start or stop mouse control by holding both your eyes shut for a few seconds.",
2019
+ description: t("If enabled, you can start or stop mouse control by holding both your eyes shut for a few seconds."),
989
2020
  },
990
2021
  {
991
- label: "Run at login",
2022
+ label: t("Run at login"),
992
2023
  className: "tracky-mouse-run-at-login",
993
2024
  key: "runAtLogin",
994
2025
  type: "checkbox",
995
2026
  default: false,
996
- platform: "desktop",
997
- description: "If enabled, Tracky Mouse will automatically start when you log into your computer.",
998
- // description: "Makes Tracky Mouse start automatically when you log into your computer.",
2027
+ 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."),
999
2030
  },
1000
2031
  {
1001
- label: "Check for updates",
2032
+ label: t("Check for updates"),
1002
2033
  className: "tracky-mouse-check-for-updates",
1003
2034
  key: "checkForUpdates",
1004
2035
  type: "checkbox",
1005
2036
  default: true,
1006
- platform: "desktop",
1007
- description: "If enabled, Tracky Mouse will automatically check for updates when it starts.",
1008
- // description: "Notifies you of new versions of Tracky Mouse.",
1009
- // description: "Notifies you when a new version of Tracky Mouse is available.",
2037
+ 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."),
2041
+ },
2042
+ {
2043
+ label: t("Language"),
2044
+ className: "tracky-mouse-language",
2045
+ key: "language",
2046
+ type: "dropdown",
2047
+ options: availableLanguages.map(lang => ({ value: lang, label: `${getLanguageFlagEmoji(lang)} ${languageNames[lang]?.[1]?.[0] || lang} (${languageNames[lang]?.[0]?.[0] || "?"})` })),
2048
+ default: locale,
2049
+ handleSettingChange: () => {
2050
+ // console.trace("handleSettingChange for language setting");
2051
+ // HACK: update localStorage because it's what's used to determine the language
2052
+ // This is needed for the desktop app which otherwise saves to a file not localStorage
2053
+ try {
2054
+ localStorage.setItem("tracky-mouse-settings", JSON.stringify(serializeSettings()));
2055
+ } catch (error) {
2056
+ console.error("Error saving options to localStorage:", error);
2057
+ return;
2058
+ }
2059
+ reinit();
2060
+ },
2061
+ description: t("Select the language for the Tracky Mouse interface."),
2062
+ // description: t("Changes the language Tracky Mouse is displayed in."),
1010
2063
  },
1011
2064
  ],
1012
2065
  },
@@ -1065,9 +2118,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1065
2118
  function buildSettingGroupUI(group) {
1066
2119
  const detailsEl = document.createElement("details");
1067
2120
  // detailsEl.className = "tracky-mouse-settings-group";
1068
- // TODO: recursive check for platform - or just define platform on groups
1069
- if (group.settings.every(setting => setting.platform === "desktop")) {
1070
- detailsEl.classList.add("tracky-mouse-desktop-only");
2121
+ // TODO: recursive check for visibility - or just define visible() on groups
2122
+ if (group.settings.every(setting => setting.visible?.() === false)) {
2123
+ detailsEl.hidden = true;
1071
2124
  }
1072
2125
  const summaryEl = document.createElement("summary");
1073
2126
  summaryEl.textContent = group.label;
@@ -1127,13 +2180,16 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1127
2180
  ${optionsHtml}
1128
2181
  </select>
1129
2182
  `;
2183
+ 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");
2185
+ }
1130
2186
  } else if (setting.type === "button") {
1131
2187
  rowEl.innerHTML = `
1132
2188
  <button class="${setting.className}">${setting.label}</button>
1133
2189
  `;
1134
2190
  }
1135
- if (setting.platform === "desktop") {
1136
- rowEl.classList.add("tracky-mouse-desktop-only");
2191
+ if (setting.visible?.() === false) {
2192
+ rowEl.hidden = true;
1137
2193
  }
1138
2194
 
1139
2195
  if (setting.description) {
@@ -1226,14 +2282,10 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1226
2282
  window.electronAPI.getIsPackaged().then((isPackaged) => {
1227
2283
  runAtLoginCheckbox.disabled = !isPackaged;
1228
2284
  });
1229
- } else {
1230
- for (const elementToHide of uiContainer.querySelectorAll('.tracky-mouse-desktop-only')) {
1231
- elementToHide.hidden = true;
1232
- }
1233
2285
  }
1234
2286
 
1235
2287
  var canvas = uiContainer.querySelector(".tracky-mouse-canvas");
1236
- var ctx = canvas.getContext('2d');
2288
+ var ctx = canvas.getContext('2d', { willReadFrequently: true });
1237
2289
 
1238
2290
  var debugEyeCanvas = document.createElement("canvas");
1239
2291
  debugEyeCanvas.className = "tracky-mouse-debug-eye-canvas";
@@ -1397,6 +2449,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1397
2449
  } catch (error) {
1398
2450
  detector = null;
1399
2451
  // TODO: avoid alert
2452
+ console.error("Failed to create facemesh detector:", error);
1400
2453
  alert(error);
1401
2454
  }
1402
2455
 
@@ -1417,6 +2470,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1417
2470
  detector.dispose();
1418
2471
  detector = null;
1419
2472
  // TODO: avoid alert
2473
+ console.error("Facemesh estimation failed:", error);
1420
2474
  alert(error);
1421
2475
  }
1422
2476
  return [];
@@ -1472,7 +2526,15 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1472
2526
  };
1473
2527
  const loadOptions = async (initialLoad = false) => {
1474
2528
  if (window.electronAPI) {
1475
- deserializeSettings(await window.electronAPI.getOptions(), initialLoad);
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
+ }
1476
2538
  } else {
1477
2539
  try {
1478
2540
  if (localStorage.getItem("tracky-mouse-settings")) {
@@ -1486,9 +2548,20 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1486
2548
 
1487
2549
  paused = !s.startEnabled;
1488
2550
 
1489
- let populateCameraList = () => { };
2551
+ // Basically Promise.withResolvers (but I'm not sure browser support is good enough)
2552
+ function createDeferred() {
2553
+ let resolve, reject;
2554
+ const promise = new Promise((res, rej) => {
2555
+ resolve = res;
2556
+ reject = rej;
2557
+ });
2558
+ return { promise, resolve, reject };
2559
+ }
2560
+
2561
+ let populateCameraList = () => { return Promise.resolve(); };
1490
2562
  if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
1491
2563
  populateCameraList = () => {
2564
+ let matchedCameraIdDeferred = createDeferred();
1492
2565
  navigator.mediaDevices.enumerateDevices().then((devices) => {
1493
2566
  const videoDevices = devices.filter(device => device.kind === 'videoinput');
1494
2567
 
@@ -1519,31 +2592,40 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1519
2592
 
1520
2593
  const defaultOption = document.createElement("option");
1521
2594
  defaultOption.value = "";
1522
- defaultOption.text = "Default";
2595
+ defaultOption.text = t("Default");
1523
2596
  cameraSelect.appendChild(defaultOption);
1524
2597
 
1525
- let found = false;
2598
+ let matchingDeviceId = "";
1526
2599
  for (const device of videoDevices) {
1527
2600
  const option = document.createElement('option');
1528
2601
  option.value = device.deviceId;
1529
- option.text = device.label || `Camera ${cameraSelect.length}`;
2602
+ option.text = device.label || t("Camera %0").replace("%0", cameraSelect.length);
1530
2603
  cameraSelect.appendChild(option);
1531
2604
  if (device.deviceId === s.cameraDeviceId) {
1532
- found = true;
2605
+ matchingDeviceId = device.deviceId;
2606
+ } else if (device.label === knownCameras[s.cameraDeviceId]?.name) {
2607
+ matchingDeviceId ||= device.deviceId;
1533
2608
  }
1534
2609
  }
1535
- // Defaulting to "Default" would imply a preference isn't stored.
2610
+
2611
+ // Defaulting to "Default" would imply a preference isn't stored...
2612
+ // but would it be more friendly anyways?
1536
2613
  // cameraSelect.value = found ? s.cameraDeviceId : "";
2614
+
1537
2615
  // Show a placeholder for the selected camera
1538
- if (s.cameraDeviceId && !found) {
2616
+ if (s.cameraDeviceId && !matchingDeviceId) {
1539
2617
  const option = document.createElement("option");
1540
2618
  option.value = s.cameraDeviceId;
1541
2619
  const knownInfo = knownCameras[s.cameraDeviceId];
1542
- option.text = knownInfo ? `${knownInfo.name} (Unavailable)` : "Unavailable camera";
2620
+ option.text = knownInfo ? `${knownInfo.name} (${t("Unavailable")})` : t("Unavailable camera");
1543
2621
  cameraSelect.appendChild(option);
2622
+ cameraSelect.value = s.cameraDeviceId;
2623
+ } else {
2624
+ cameraSelect.value = matchingDeviceId;
1544
2625
  }
1545
- cameraSelect.value = s.cameraDeviceId;
2626
+ matchedCameraIdDeferred.resolve(matchingDeviceId);
1546
2627
  });
2628
+ return matchedCameraIdDeferred.promise;
1547
2629
  };
1548
2630
  populateCameraList();
1549
2631
  navigator.mediaDevices.addEventListener('devicechange', populateCameraList);
@@ -1594,13 +2676,81 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1594
2676
  faceScore = 0;
1595
2677
  faceConvergence = 0;
1596
2678
  lastTimeWhenAnEyeWasOpen = Infinity; // far future rather than far past so that sleep gesture doesn't trigger initially, skipping the delay
1597
-
1598
- startStopButton.textContent = "Start";
1599
- startStopButton.setAttribute("aria-pressed", "false");
2679
+ updateStartStopButton();
1600
2680
  };
1601
2681
 
1602
- useCameraButton.onclick = TrackyMouse.useCamera = async () => {
2682
+ useCameraButton.onclick = TrackyMouse.useCamera = async (optionsOrEvent = {}) => {
2683
+ // Phases:
2684
+ // 1. "tryPreferredCamera"
2685
+ // Use the configured device ID to try to access the preferred camera.
2686
+ // If the permission has been revoked, the browser may
2687
+ // switch to a mode where `enumerateDevices` gives FAKE data
2688
+ // and `getUserMedia` will fail with OverconstrainedError
2689
+ // when trying to access a real device
2690
+ // (without even triggering a permission prompt that might
2691
+ // lead to getting the real list of devices.)
2692
+ // 2. "justGetPermission"
2693
+ // Request any camera in order to get camera permission
2694
+ // in general and get real data from `enumerateDevices`
2695
+ // in phase 3.
2696
+ // Close the stream immediately, as it may not be the
2697
+ // stream we want, and we can't tell, as far I know.
2698
+ // Then populate the camera list with real data.
2699
+ // 3. "retryPreferredCamera"
2700
+ // Now that we have a real list of devices,
2701
+ // and are allowed to access real devices,
2702
+ // try again with a specific device ID.
2703
+ // If there's a match by name and not ID, we use that.
2704
+ //
2705
+ // Q: Why not get rid of phase 1? Shouldn't 2+3 handle it?
2706
+ // In Electron, closing the stream and re-requesting access
2707
+ // often gives a "camera in use" error.
2708
+ // Plus, _ideally_ phase 1 means it can connect faster in browsers.
2709
+ // However, phase 1 may only get OverconstrainedError in browsers
2710
+ // as it's implemented.
2711
+ // We could get rid of phase 1 in browsers, basically separating the flows.
2712
+ // But...
2713
+ // I wonder if revoking camera access is what changes device IDs,
2714
+ // and if device IDs changing is what gives OverconstrainedError,
2715
+ // not the "fake device list" behavior. Is it perhaps only hiding labels in that mode,
2716
+ // but separately permanently scrambling IDs as a single event?
2717
+ // If device IDs are changed, storing the new device ID to try in phase 1
2718
+ // might make phase 1 work as an optimization in browsers.
2719
+ //
2720
+ // Q: If Electron has such a problem, would it not occur in the later phases?
2721
+ // Phase 2+3 should never occur in Electron.
2722
+ // In fact, we can guard against this.
2723
+ // Although, if phase 2+3 are only enterred on failure,
2724
+ // it can't really be a problem, can it?
2725
+ //
2726
+ // Q: Will this cause unnecessary prompts?
2727
+ // In the case of one existing camera, no.
2728
+ // In the case that there are multiple existing cameras,
2729
+ // and the user grants access to a different one than is configured,
2730
+ // it may cause an extra prompt.
2731
+ // In Firefox, you can choose to allow all cameras with a checkbox.
2732
+ // If you check that box, or select the matching camera before clicking Allow,
2733
+ // there should be only one prompt.
2734
+ //
2735
+ // Q: Why not use a library for this?
2736
+ // The mic-check package uses a similar approach, but seems to
2737
+ // encourage a pattern where nice error handling is applied
2738
+ // only to a "just get permission" equivalent phase,
2739
+ // whereas by using recursion or separating out the error handling
2740
+ // into a function, one can handle errors nicely always.
2741
+ // mic-check provides only the one phase, and presumably
2742
+ // is meant for a two-phase solution.
2743
+ //
2744
+ // Q: What happens if there are multiple overlapping calls to `useCamera`?
2745
+ // I don't know. TODO: test this.
2746
+ //
2747
+ // P.S. I gave a talk about this at Rubber Duck Conf 2026
2748
+ // You can view the slides here: https://websim.com/@1j01/ughaaaaaa
2749
+
1603
2750
  await settingsLoadedPromise;
2751
+
2752
+ const phase = optionsOrEvent.phase ?? "tryPreferredCamera";
2753
+
1604
2754
  const constraints = {
1605
2755
  audio: false,
1606
2756
  video: {
@@ -1609,26 +2759,51 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1609
2759
  facingMode: "user",
1610
2760
  }
1611
2761
  };
1612
- if (s.cameraDeviceId) {
2762
+ const deviceIdToTry = phase === "retryPreferredCamera" ?
2763
+ optionsOrEvent.retryWithCameraDeviceId :
2764
+ phase === "tryPreferredCamera" ?
2765
+ s.cameraDeviceId :
2766
+ "";
2767
+ if (deviceIdToTry) {
1613
2768
  delete constraints.video.facingMode;
1614
- constraints.video.deviceId = { exact: s.cameraDeviceId };
2769
+ constraints.video.deviceId = { exact: deviceIdToTry };
1615
2770
  }
1616
- navigator.mediaDevices.getUserMedia(constraints).then((stream) => {
2771
+ console.log("TrackyMouse.useCamera phase", phase, "constraints", constraints);
2772
+ navigator.mediaDevices.getUserMedia(constraints).then(async (stream) => {
2773
+ if (phase === "justGetPermission") {
2774
+ for (const track of stream.getTracks()) {
2775
+ track.stop();
2776
+ }
2777
+ // This is giving me User Gesture Hinged Access and Async Authorization Asking Attempt Absorption Anxiety,
2778
+ // or "UGHAaAAAAAA" (I'm coining that term)
2779
+ // (Look I made a presentation about it: https://websim.com/@1j01/ughaaaaaa)
2780
+ const matchedCameraId = await populateCameraList();
2781
+ if (matchedCameraId) {
2782
+ TrackyMouse.useCamera({ retryWithCameraDeviceId: matchedCameraId, phase: "retryPreferredCamera" });
2783
+ } else {
2784
+ TrackyMouse.useCamera({ retryWithCameraDeviceId: "", phase: "retryPreferredCamera" });
2785
+ }
2786
+ return;
2787
+ }
1617
2788
  populateCameraList();
1618
2789
  reset();
1619
2790
 
1620
2791
  cameraVideo.srcObject = stream;
1621
2792
  useCameraButton.hidden = true;
1622
2793
  errorMessage.hidden = true;
1623
- if (!paused) {
1624
- startStopButton.textContent = "Stop";
1625
- startStopButton.setAttribute("aria-pressed", "true");
2794
+ }, async (error) => {
2795
+ console.log("TrackyMouse.useCamera phase", phase, "error", error);
2796
+ if (
2797
+ phase === "tryPreferredCamera" &&
2798
+ (error.name === "OverconstrainedError" || error.name == "ConstraintNotSatisfiedError") &&
2799
+ !window.electronAPI
2800
+ ) {
2801
+ TrackyMouse.useCamera({ phase: "justGetPermission" });
2802
+ return;
1626
2803
  }
1627
- }, (error) => {
1628
- console.log(error);
1629
2804
  if (error.name == "NotFoundError" || error.name == "DevicesNotFoundError") {
1630
2805
  // required track is missing
1631
- errorMessage.textContent = "No camera found. Please make sure you have a camera connected and enabled.";
2806
+ errorMessage.textContent = t("No camera found. Please make sure you have a camera connected and enabled.");
1632
2807
  } else if (error.name == "NotReadableError" || error.name == "TrackStartError") {
1633
2808
  // webcam is already in use
1634
2809
  // or: OBS Virtual Camera is present but OBS is not running with Virtual Camera started
@@ -1636,25 +2811,50 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1636
2811
  // (listing devices and showing only the OBS Virtual Camera would also be a good clue in itself;
1637
2812
  // 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
1638
2813
  // or "1 camera source detected" preceding it)
1639
- errorMessage.textContent = "Webcam is already in use. Please make sure you have no other programs using the camera.";
2814
+ errorMessage.textContent = t("Webcam is already in use. Please make sure you have no other programs using the camera.");
1640
2815
  } else if (error.name === "AbortError") {
1641
2816
  // webcam is likely already in use
1642
2817
  // I observed AbortError in Firefox 132.0.2 but I don't know it's used exclusively for this case.
1643
- errorMessage.textContent = "Webcam may already be in use. Please make sure you have no other programs using the camera.";
2818
+ // Update: it definitely isn't, but I can't say exactly what it means in other cases.
2819
+ // Like, it might have to do with permissions being denied outside of a user gesture (distinct from the user denying the permission)
2820
+ // I really hope that isn't the problem.
2821
+ // 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.");
2823
+ // A more honest/helpful message might be:
2824
+ // errorMessage.textContent = "Please try again and then make sure no other programs are using the camera and try again again.";
2825
+ // errorMessage.textContent = "Please try again before/after making sure no other programs are using the camera.";
2826
+ // if it were not to be confusing.
2827
+ // That is, one could save some time by just hitting the button to try again before trying to figure out of another program is using the camera,
2828
+ // because sometimes that's enough.
1644
2829
  } else if (error.name == "OverconstrainedError" || error.name == "ConstraintNotSatisfiedError") {
1645
- // constraints can not be satisfied by avb. devices
1646
- errorMessage.textContent = "Webcam does not support the required resolution. Please change your settings.";
2830
+ // constraints cannot be satisfied by available devices
2831
+
2832
+ // OverconstrainedError can be caused by `deviceId` not matching,
2833
+ // either due to the device not being present, or the ID having changed (don't ask me why that can happen but it can)
2834
+ // Note: OverconstrainedError has a `constraint` property but not in Firefox so it's not very helpful.
2835
+ if (constraints.video.deviceId?.exact) {
2836
+ // errorMessage.textContent = "The previously selected camera is not available. Please select a different camera from the dropdown and try again.";
2837
+ // errorMessage.textContent = "The previously selected camera is not available. Please mess around with Video > Camera source.";
2838
+ // errorMessage.textContent = "The previously selected camera is not available. Try changing Video > Camera source.";
2839
+ // 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.");
2841
+ // It's awkward but that's my best attempt at conveying how you may need to proceed
2842
+ // without complicated description of how/why the dropdown might be populated with
2843
+ // fake information until a camera stream is successfully opened.
2844
+ } else {
2845
+ errorMessage.textContent = t("Webcam does not support the required resolution. Please change your settings.");
2846
+ }
1647
2847
  } else if (error.name == "NotAllowedError" || error.name == "PermissionDeniedError") {
1648
2848
  // permission denied in browser
1649
- errorMessage.textContent = "Permission denied. Please enable access to the camera.";
2849
+ errorMessage.textContent = t("Permission denied. Please enable access to the camera.");
1650
2850
  } else if (error.name == "TypeError") {
1651
2851
  // empty constraints object
1652
- errorMessage.textContent = `Something went wrong accessing the camera. (${error.name}: ${error.message})`;
2852
+ errorMessage.textContent = `${t("Something went wrong accessing the camera.")} (${error.name}: ${error.message})`;
1653
2853
  } else {
1654
2854
  // other errors
1655
- errorMessage.textContent = `Something went wrong accessing the camera. Please try again. (${error.name}: ${error.message})`;
2855
+ errorMessage.textContent = `${t("Something went wrong accessing the camera. Please try again.")} (${error.name}: ${error.message})`;
1656
2856
  }
1657
- errorMessage.textContent = `⚠️ ${errorMessage.textContent}`;
2857
+ errorMessage.textContent = `${t("⚠️ ")}${errorMessage.textContent}`;
1658
2858
  errorMessage.hidden = false;
1659
2859
  });
1660
2860
  };
@@ -1667,9 +2867,6 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
1667
2867
  startStopButton.onclick = () => {
1668
2868
  if (!useCameraButton.hidden) {
1669
2869
  TrackyMouse.useCamera();
1670
- if (!paused) {
1671
- return;
1672
- }
1673
2870
  }
1674
2871
  handleShortcut("toggle-tracking");
1675
2872
  };
@@ -2320,11 +3517,16 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2320
3517
  clickButton = 2;
2321
3518
  }
2322
3519
  }
2323
- // TODO: maybe split into a "simple"/mouth-only mode vs "with eye modifiers" mode?
2324
- // (or just hold out for a full I/O binding system)
2325
- if (s.clickingMode === "open-mouth") {
3520
+ if (s.clickingMode === "open-mouth-ignoring-eyes") {
3521
+ mouthInfo.used = true;
3522
+ if (mouthInfo.thresholdMet) {
3523
+ clickButton = 0;
3524
+ }
3525
+ }
3526
+ if (s.clickingMode === "open-mouth" || s.clickingMode === "open-mouth-simple") {
2326
3527
  mouthInfo.used = true;
2327
3528
  blinkInfo.used = true;
3529
+ const allowModifiers = s.clickingMode !== "open-mouth-simple";
2328
3530
  // Modifiers with eye closing trigger different buttons,
2329
3531
  // making this a three-button mouse.
2330
3532
  // (Eyebrow raising could be another alternative modifier.)
@@ -2332,9 +3534,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2332
3534
  // so you can continue to scroll a webpage without trying to
2333
3535
  // read with one eye closed (for example).
2334
3536
  if (mouthInfo.thresholdMet && !prevMouthOpen) {
2335
- if (blinkInfo.rightEye.active) {
3537
+ if (blinkInfo.rightEye.active && allowModifiers) {
2336
3538
  mouseButtonUntilMouthCloses = 1;
2337
- } else if (blinkInfo.leftEye.active) {
3539
+ } else if (blinkInfo.leftEye.active && allowModifiers) {
2338
3540
  mouseButtonUntilMouthCloses = 2;
2339
3541
  } else if (!blinkInfo.rightEye.open && !blinkInfo.leftEye.open) {
2340
3542
  mouseButtonUntilMouthCloses = -1;
@@ -2349,7 +3551,12 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2349
3551
  mouthInfo.active = false;
2350
3552
  // TODO: show eyes as yellow too regardless of eye state?
2351
3553
  }
2352
- // TODO: DRY mapping
3554
+ }
3555
+ // In the mode with modifiers, it's helpful to preview a click's modifiers,
3556
+ // but in simple mode, it may be confusing to show any active state
3557
+ // when you're not clicking.
3558
+ if (mouthInfo.thresholdMet || s.clickingMode === "open-mouth-simple") {
3559
+ // TODO: DRY mapping (deduplicate the association of eyes to buttons)
2353
3560
  blinkInfo.rightEye.active = clickButton === 1;
2354
3561
  blinkInfo.leftEye.active = clickButton === 2;
2355
3562
  }
@@ -2433,9 +3640,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2433
3640
  const textYStart = -10;
2434
3641
 
2435
3642
 
2436
- const pitchText = `Pitch: ${(headTilt.pitch * 180 / Math.PI).toFixed(1)}°`;
2437
- const yawText = `Yaw: ${(headTilt.yaw * 180 / Math.PI).toFixed(1)}°`;
2438
- const rollText = `Roll: ${(headTilt.roll * 180 / Math.PI).toFixed(1)}°`;
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)}°`;
2439
3646
 
2440
3647
  const boxWidth = Math.max(
2441
3648
  ctx.measureText(pitchText).width,
@@ -2823,9 +4030,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2823
4030
  ctx.lineWidth = 3;
2824
4031
  ctx.font = "20px sans-serif";
2825
4032
  ctx.beginPath();
2826
- const text3 = "Face convergence score: " + ((useFacemesh && facemeshPrediction) ? "N/A" : faceConvergence.toFixed(4));
2827
- const text1 = "Face tracking score: " + ((useFacemesh && facemeshPrediction) ? facemeshPrediction.faceInViewConfidence : faceScore).toFixed(4);
2828
- const text2 = "Points based on score: " + ((useFacemesh && facemeshPrediction) ? pointsBasedOnFaceInViewConfidence : pointsBasedOnFaceScore).toFixed(4);
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);
2829
4036
  ctx.strokeText(text1, 50, 50);
2830
4037
  ctx.fillText(text1, 50, 50);
2831
4038
  ctx.strokeText(text2, 50, 70);
@@ -2847,7 +4054,7 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2847
4054
  }
2848
4055
 
2849
4056
  // 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)
2850
- setInterval(function animationLoop() {
4057
+ const iid = setInterval(function animationLoop() {
2851
4058
  draw(!paused || document.visibilityState === "visible");
2852
4059
  }, 15);
2853
4060
 
@@ -2863,18 +4070,21 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2863
4070
  TrackyMouse.useCamera();
2864
4071
  }
2865
4072
 
2866
- const updatePaused = () => {
2867
- mouseNeedsInitPos = true;
4073
+ const updateStartStopButton = () => {
2868
4074
  if (paused) {
2869
- pointerEl.style.display = "none";
2870
- }
2871
- if (paused) {
2872
- startStopButton.textContent = "Start";
4075
+ startStopButton.textContent = t("Start");
2873
4076
  startStopButton.setAttribute("aria-pressed", "false");
2874
4077
  } else {
2875
- startStopButton.textContent = "Stop";
4078
+ startStopButton.textContent = t("Stop");
2876
4079
  startStopButton.setAttribute("aria-pressed", "true");
2877
4080
  }
4081
+ };
4082
+ const updatePaused = () => {
4083
+ mouseNeedsInitPos = true;
4084
+ if (paused) {
4085
+ pointerEl.style.display = "none";
4086
+ }
4087
+ updateStartStopButton();
2878
4088
  if (window.electronAPI) {
2879
4089
  window.electronAPI.notifyToggleState(!paused);
2880
4090
  }
@@ -2890,8 +4100,9 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2890
4100
  // Try to handle both the global and local shortcuts
2891
4101
  // If the global shortcut successfully registered, keydown shouldn't occur for the shortcut, right?
2892
4102
  // I hope there's no cross-platform issue with this.
4103
+ let removeShortcutListener = null;
2893
4104
  if (window.electronAPI) {
2894
- window.electronAPI.onShortcut(handleShortcut);
4105
+ removeShortcutListener = window.electronAPI.onShortcut(handleShortcut);
2895
4106
  }
2896
4107
  const handleKeydown = (event) => {
2897
4108
  // Same shortcut as the global shortcut in the electron app
@@ -2902,6 +4113,17 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2902
4113
  addEventListener("keydown", handleKeydown);
2903
4114
 
2904
4115
  return {
4116
+ _element: uiContainer,
4117
+ _setPaused(value) {
4118
+ paused = value;
4119
+ updatePaused();
4120
+ },
4121
+ _getPaused() {
4122
+ return paused;
4123
+ },
4124
+ _waitForSettingsLoaded() {
4125
+ return settingsLoadedPromise;
4126
+ },
2905
4127
  dispose() {
2906
4128
  // TODO: re-structure so that cleanup can succeed even if initialization fails
2907
4129
  // OOP would help with this, by storing references in an object, but it doesn't necessarily
@@ -2910,6 +4132,8 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2910
4132
  // (Would also be easy to maintain backwards compatibility while switching to using a class,
2911
4133
  // returning an instance of the class from `TrackyMouse.init` but deprecating it in favor of constructing the class.)
2912
4134
 
4135
+ clearInterval(iid);
4136
+
2913
4137
  // stopping camera stream is important, not sure about other resetting
2914
4138
  reset();
2915
4139
 
@@ -2931,6 +4155,8 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2931
4155
 
2932
4156
  removeEventListener("keydown", handleKeydown);
2933
4157
 
4158
+ removeShortcutListener?.();
4159
+
2934
4160
  // This is a little awkward, reversing the initialization based on a possibly-preexisting element
2935
4161
  // Could save and restore innerHTML but that won't restore event listeners, references, etc.
2936
4162
  // and may not even be desired if the HTML was placeholder text mentioning it not yet being initialized for example.
@@ -2943,6 +4169,154 @@ You may want to turn this off if you're drawing on a canvas, or increase it if y
2943
4169
  };
2944
4170
  };
2945
4171
 
4172
+ // Wrapper that manages an inner instance and recreates it when the language is changed.
4173
+ TrackyMouse.init = function (div, opts = {}) {
4174
+ let inner = null;
4175
+
4176
+ // UI state saving could be cleaner as part of the inner instance idk
4177
+ // Or, you know, ideally we update the UI text reactively without
4178
+ // stopping/starting the camera stream etc. when switching languages.
4179
+ const saveUIState = () => {
4180
+ const paused = inner._getPaused();
4181
+ const collapsibles = inner._element.querySelectorAll("details");
4182
+ const openStates = Array.from(collapsibles).map(c => c.open);
4183
+ const scrollables = inner._element.querySelectorAll("*");
4184
+ const scrollPositions = Array.from(scrollables).map(s => [s.scrollLeft, s.scrollTop]);
4185
+ const focusedElementSelector = Array.from(document.activeElement?.classList || []).map(c => `.${c}`).join("");
4186
+ return { paused, openStates, scrollPositions, focusedElementSelector };
4187
+ };
4188
+ const restoreUIState = ({ paused, openStates, scrollPositions, focusedElementSelector }) => {
4189
+ inner._waitForSettingsLoaded().then(() => {
4190
+ inner._setPaused(paused);
4191
+ });
4192
+ // assuming DOM structure doesn't change
4193
+ const collapsibles = inner._element.querySelectorAll("details");
4194
+ for (let i = 0; i < collapsibles.length; i++) {
4195
+ collapsibles[i].open = openStates[i];
4196
+ }
4197
+ const scrollables = inner._element.querySelectorAll("*");
4198
+ for (let i = 0; i < scrollables.length; i++) {
4199
+ const [scrollLeft, scrollTop] = scrollPositions[i];
4200
+ scrollables[i].scrollLeft = scrollLeft;
4201
+ scrollables[i].scrollTop = scrollTop;
4202
+ }
4203
+ if (focusedElementSelector) {
4204
+ const elementToFocus = inner._element.querySelector(focusedElementSelector);
4205
+ elementToFocus?.focus();
4206
+ }
4207
+ };
4208
+ const reinit = () => {
4209
+ const uiState = saveUIState();
4210
+ inner.dispose();
4211
+ createInner();
4212
+ restoreUIState(uiState);
4213
+ };
4214
+
4215
+ const createInner = () => {
4216
+ inner = TrackyMouse._initInner(div, opts, reinit);
4217
+ };
4218
+
4219
+ createInner();
4220
+
4221
+ return {
4222
+ dispose() {
4223
+ inner.dispose();
4224
+ },
4225
+ };
4226
+
4227
+ };
4228
+
4229
+ TrackyMouse.initScreenOverlay = () => {
4230
+
4231
+ const template = `
4232
+ <div class="tracky-mouse-absolute-center">
4233
+ <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">
4235
+ </div>
4236
+ <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">
4238
+ </div>
4239
+ </div>
4240
+ <div id="tracky-mouse-screen-overlay-message"></div>
4241
+ `;
4242
+ const fragment = document.createRange().createContextualFragment(template);
4243
+ document.body.appendChild(fragment);
4244
+
4245
+ const message = document.getElementById("tracky-mouse-screen-overlay-message");
4246
+ message.dir = "auto";
4247
+
4248
+ const inputFeedbackCanvas = document.createElement("canvas");
4249
+ inputFeedbackCanvas.style.position = "absolute";
4250
+ inputFeedbackCanvas.style.top = "0";
4251
+ inputFeedbackCanvas.style.left = "0";
4252
+ inputFeedbackCanvas.style.pointerEvents = "none";
4253
+ inputFeedbackCanvas.width = 32;
4254
+ inputFeedbackCanvas.height = 32;
4255
+ document.body.appendChild(inputFeedbackCanvas);
4256
+ const inputFeedbackCtx = inputFeedbackCanvas.getContext("2d");
4257
+ function drawInputFeedback({ inputFeedback, isEnabled }) {
4258
+ const { blinkInfo, mouthInfo } = inputFeedback;
4259
+ inputFeedbackCtx.clearRect(0, 0, inputFeedbackCanvas.width, inputFeedbackCanvas.height);
4260
+ if (!isEnabled) {
4261
+ return;
4262
+ }
4263
+ // draw meters for blink and mouth openness
4264
+ // TODO: draw meter backings to disambiguate showing zero vs being occluded by taskbar
4265
+ // (Ideally it should stay on top of the taskbar and context menus all the time
4266
+ // but that's another issue: https://github.com/1j01/tracky-mouse/issues/14)
4267
+ const drawMeter = (x, yCenter, width, height, { active, thresholdMet }) => {
4268
+ inputFeedbackCtx.fillStyle = active ? "red" : thresholdMet ? "yellow" : "cyan";
4269
+ inputFeedbackCtx.fillRect(x, yCenter - height / 2, width, height);
4270
+ };
4271
+ if (blinkInfo?.used) {
4272
+ for (const eye of [blinkInfo.leftEye, blinkInfo.rightEye]) {
4273
+ drawMeter(eye === blinkInfo.leftEye ? 5 : 20, 5, 10, Math.max(2, 20 * eye.heightRatio), eye);
4274
+ }
4275
+ }
4276
+ if (mouthInfo?.used) {
4277
+ drawMeter(0, 20, 23, Math.max(2, 40 * mouthInfo.heightRatio), mouthInfo);
4278
+ }
4279
+ }
4280
+
4281
+ function updateMousePos(x, y) {
4282
+ // inputFeedbackCanvas.style.transform = `translate(${x - inputFeedbackCanvas.width / 2}px, ${y - inputFeedbackCanvas.height / 2}px)`;
4283
+ // inputFeedbackCanvas.style.transform = `translate(${x}px, ${y}px)`;
4284
+ inputFeedbackCanvas.style.transform = `translate(${Math.min(x, window.innerWidth - inputFeedbackCanvas.width)}px, ${Math.min(y, window.innerHeight - inputFeedbackCanvas.height)}px)`;
4285
+ }
4286
+
4287
+ function update(data) {
4288
+ const { messageText, isEnabled, isManualTakeback, inputFeedback, bottomOffset } = data;
4289
+
4290
+ message.style.bottom = `${bottomOffset}px`;
4291
+
4292
+ // Other diagnostics in the future would be stuff like:
4293
+ // - head too far away (smaller than a certain size) https://github.com/1j01/tracky-mouse/issues/49
4294
+ // - bad lighting conditions
4295
+ // see: https://github.com/1j01/tracky-mouse/issues/26
4296
+
4297
+ document.body.classList.toggle("tracky-mouse-manual-takeback", isManualTakeback);
4298
+ document.body.classList.toggle("tracky-mouse-head-not-found", inputFeedback.headNotFound);
4299
+
4300
+ message.innerText = messageText;
4301
+
4302
+ if (!isEnabled && !isManualTakeback) {
4303
+ // Fade out the message after a little while so it doesn't get in the way.
4304
+ // 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";
4306
+ } else {
4307
+ message.style.animation = "";
4308
+ message.style.opacity = "1";
4309
+ }
4310
+
4311
+ drawInputFeedback(data);
4312
+ }
4313
+
4314
+ return {
4315
+ update,
4316
+ updateMousePos,
4317
+ };
4318
+ };
4319
+
2946
4320
  // CommonJS export is untested. Script tag usage recommended.
2947
4321
  // Just including this in case it is somehow useful.
2948
4322
  // eslint-disable-next-line no-undef