iobroker.jetframe 1.0.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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +357 -0
  3. package/admin/SF-Pro.ttf +0 -0
  4. package/admin/admin.d.ts +65 -0
  5. package/admin/frame.html +982 -0
  6. package/admin/frame.html.bak-aircraft-card-real-row-20260518-1608 +1236 -0
  7. package/admin/frame.html.bak-aircraft-card-structure-20260518-1517 +1236 -0
  8. package/admin/frame.html.bak-aircraft-logo-id-fix-20260518-1639 +1239 -0
  9. package/admin/frame.html.bak-shortcut-test +1236 -0
  10. package/admin/frame.html.bak-tablet-class-20260518-1729 +1239 -0
  11. package/admin/heatmap.html +216 -0
  12. package/admin/index.html +268 -0
  13. package/admin/index_m.html +1749 -0
  14. package/admin/jetframe.css +1260 -0
  15. package/admin/jetframe.css.bak-airbus-landscape-fix +4630 -0
  16. package/admin/jetframe.css.bak-aircraft-card-clean-equal-20260518-1438 +4899 -0
  17. package/admin/jetframe.css.bak-aircraft-card-real-row-20260518-1608 +4814 -0
  18. package/admin/jetframe.css.bak-aircraft-card-row-left-20260518-1525 +4604 -0
  19. package/admin/jetframe.css.bak-aircraft-card-slim-equal-20260518-1446 +4647 -0
  20. package/admin/jetframe.css.bak-aircraft-card-structure-20260518-1517 +4646 -0
  21. package/admin/jetframe.css.bak-aircraft-inline-final-20260518-1527 +4654 -0
  22. package/admin/jetframe.css.bak-aircraft-row-compact-fix-20260518-1639 +4763 -0
  23. package/admin/jetframe.css.bak-before-aircrafttype-purge +4818 -0
  24. package/admin/jetframe.css.bak-before-cleanup +4670 -0
  25. package/admin/jetframe.css.bak-before-remove-tablet-only-20260518-1711 +4896 -0
  26. package/admin/jetframe.css.bak-before-tablet-layout-rework-20260518-1650 +4914 -0
  27. package/admin/jetframe.css.bak-clean-duplicate-fonts-20260518-1340 +4975 -0
  28. package/admin/jetframe.css.bak-clean-old-index-fix-20260518-1937 +5167 -0
  29. package/admin/jetframe.css.bak-hardleft-airbus +4751 -0
  30. package/admin/jetframe.css.bak-index-iphone-landscape-20260518-1931 +5030 -0
  31. package/admin/jetframe.css.bak-index-landscape-final-20260518-1941 +5167 -0
  32. package/admin/jetframe.css.bak-index-landscape-real-20260518-1936 +5186 -0
  33. package/admin/jetframe.css.bak-landscape-compact-jumbo-bold-20260518-1343 +4802 -0
  34. package/admin/jetframe.css.bak-logo-align-final +4551 -0
  35. package/admin/jetframe.css.bak-logo-final2 +4551 -0
  36. package/admin/jetframe.css.bak-narrowbody-font-fix +4992 -0
  37. package/admin/jetframe.css.bak-nuke-airbus-align +4790 -0
  38. package/admin/jetframe.css.bak-pill-balance-20260518-1603 +4773 -0
  39. package/admin/jetframe.css.bak-pill-balance-fix +4910 -0
  40. package/admin/jetframe.css.bak-radar-fix-fonts +4710 -0
  41. package/admin/jetframe.css.bak-shortcut-test +4899 -0
  42. package/admin/jetframe.css.bak-smaller-aircraft-card-fonts-20260518-1345 +4897 -0
  43. package/admin/jetframe.css.bak-tablet-fix-real-20260518-1748 +4945 -0
  44. package/admin/jetframe.css.bak-tablet-fullscreen-fix-20260518-1804 +4972 -0
  45. package/admin/jetframe.css.bak-tablet-landscape-layout-20260518-1645 +4802 -0
  46. package/admin/jetframe.css.bak-tablet-layout-final-20260518-1839 +4802 -0
  47. package/admin/jetframe.css.bak-tablet-layout-v3-20260518-1729 +4802 -0
  48. package/admin/jetframe.css.bak-tablet-layout-v4-20260518-1801 +4957 -0
  49. package/admin/jetframe.css.bak-tablet-layout-v5-20260518-1843 +4970 -0
  50. package/admin/jetframe.css.bak-tablet-layout-v6-20260518-1848 +4958 -0
  51. package/admin/jetframe.css.bak-tablet-layout-v7-20260518-1909 +4985 -0
  52. package/admin/jetframe.css.bak-tablet-only-landscape-v2-20260518-1707 +4802 -0
  53. package/admin/jetframe.css.bak-tablet-pages-final-20260519-1857 +5188 -0
  54. package/admin/jetframe.css.bak-tablet-pages-final-20260519-1859 +5347 -0
  55. package/admin/jetframe.css.bak-tablet-pages-v2-20260519-190807 +5349 -0
  56. package/admin/jetframe.css.bak-typography-align-final +4818 -0
  57. package/admin/jetframe.png +0 -0
  58. package/admin/manifest.webmanifest +15 -0
  59. package/admin/src/app.tsx +58 -0
  60. package/admin/src/components/settings.tsx +97 -0
  61. package/admin/src/i18n/de.json +11 -0
  62. package/admin/src/i18n/en.json +11 -0
  63. package/admin/src/i18n/es.json +11 -0
  64. package/admin/src/i18n/fr.json +11 -0
  65. package/admin/src/i18n/i18n.d.ts +28 -0
  66. package/admin/src/i18n/it.json +11 -0
  67. package/admin/src/i18n/nl.json +11 -0
  68. package/admin/src/i18n/pl.json +11 -0
  69. package/admin/src/i18n/pt.json +11 -0
  70. package/admin/src/i18n/ru.json +11 -0
  71. package/admin/src/i18n/uk.json +11 -0
  72. package/admin/src/i18n/zh-cn.json +11 -0
  73. package/admin/src/index.tsx +25 -0
  74. package/admin/stats.html +228 -0
  75. package/admin/style.css +32 -0
  76. package/admin/tsconfig.json +11 -0
  77. package/admin/words.js +46 -0
  78. package/build/lib/adsb.js +218 -0
  79. package/build/lib/adsb.js.map +7 -0
  80. package/build/lib/airportNamesDe.js +131 -0
  81. package/build/lib/airportNamesDe.js.map +7 -0
  82. package/build/lib/airports.js +281 -0
  83. package/build/lib/airports.js.map +7 -0
  84. package/build/lib/classify.js +339 -0
  85. package/build/lib/classify.js.map +7 -0
  86. package/build/lib/config.js +103 -0
  87. package/build/lib/config.js.map +7 -0
  88. package/build/lib/flightInfo.js +1409 -0
  89. package/build/lib/flightInfo.js.map +7 -0
  90. package/build/lib/geo.js +84 -0
  91. package/build/lib/geo.js.map +7 -0
  92. package/build/lib/images.js +422 -0
  93. package/build/lib/images.js.map +7 -0
  94. package/build/lib/specialLiveries.js +342 -0
  95. package/build/lib/specialLiveries.js.map +7 -0
  96. package/build/lib/states.js +971 -0
  97. package/build/lib/states.js.map +7 -0
  98. package/build/lib/staticFiles.js +73 -0
  99. package/build/lib/staticFiles.js.map +7 -0
  100. package/build/lib/types.js +17 -0
  101. package/build/lib/types.js.map +7 -0
  102. package/build/lib/visConfig.js +52 -0
  103. package/build/lib/visConfig.js.map +7 -0
  104. package/build/main.js +1454 -0
  105. package/build/main.js.map +7 -0
  106. package/io-package.json +169 -0
  107. package/package.json +82 -0
@@ -0,0 +1,982 @@
1
+ <!doctype html>
2
+ <html lang="de">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
6
+ <title>JetFrame Live</title>
7
+
8
+ <link rel="manifest" href="manifest.webmanifest">
9
+ <link rel="apple-touch-icon" href="jetframe.png">
10
+ <link rel="icon" type="image/png" href="jetframe.png">
11
+
12
+ <meta name="apple-mobile-web-app-capable" content="yes">
13
+ <meta name="mobile-web-app-capable" content="yes">
14
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
15
+ <meta name="theme-color" content="#050709">
16
+ <link rel="stylesheet" href="jetframe.css?v=20260522-fix14">
17
+ </head>
18
+
19
+ <body class="jf-page-frame jf-preload">
20
+ <div id="simpleApiWarning" class="simpleApiWarning">
21
+ ⚠️ Simple-API nicht erreichbar<br>
22
+ <span id="simpleApiWarningText"></span>
23
+ </div>
24
+
25
+ <div id="app">
26
+ <div id="wall" class="jf-shell">
27
+ <div class="header">
28
+ <a class="backBtn headerBack" href="index.html" aria-label="Zurück">‹</a>
29
+
30
+ <div class="headerTitleBlock">
31
+ <div class="title">✈️ JetFrame</div>
32
+ <div id="frameDateText" class="sub">Heute</div>
33
+ </div>
34
+
35
+ <div class="frameHeaderActions">
36
+ <!-- toggleBtn intentionally hidden via CSS (jfHiddenToggle) -->
37
+ <button id="toggleBtn" class="toggleBtn on jfHiddenToggle" type="button" aria-hidden="true">ON</button>
38
+ <button id="speechBtn" class="toggleBtn speechBtn" type="button" aria-label="Sprachausgabe">🔇</button>
39
+ <div class="livePill frameLivePill" aria-label="Live">
40
+ <span class="dot" aria-hidden="true"></span>
41
+ <span>Live</span>
42
+ </div>
43
+ </div>
44
+ </div>
45
+
46
+ <div class="jfFrameMain">
47
+ <div class="photoWrap">
48
+ <img id="planePhoto" src="" alt="Flugzeugfoto" />
49
+
50
+ <div id="placeholder" class="placeholder">
51
+ <div class="radarCircle" aria-hidden="true"></div>
52
+ <div class="radarSweep" aria-hidden="true"></div>
53
+ <div class="idlePlane" aria-hidden="true">✈️</div>
54
+ <div class="idleFlyers" aria-hidden="true">
55
+ <span>✈️</span><span>🛫</span><span>🛬</span>
56
+ </div>
57
+ <div class="idleText">Warte auf Flug</div>
58
+ </div>
59
+ </div>
60
+
61
+ <div class="frameIdleRunwaySlot">
62
+ <div id="idleRunwayText" class="idleRunwayText"></div>
63
+ </div>
64
+
65
+ <section class="info">
66
+ <div id="mode" class="mode">Flight</div>
67
+
68
+ <div class="flightInfoPillRow">
69
+ <div id="windowInfo" class="windowInfo side">
70
+ <span id="windowArrow" class="windowArrow" aria-hidden="true">▲</span>
71
+ <span id="windowInfoText"></span>
72
+ </div>
73
+ <div id="runwayInfo" class="runwayInfo"></div>
74
+ </div>
75
+
76
+ <div class="callsigns">
77
+ <div id="iataCallsign" class="iataCallsign">—</div>
78
+ <div id="operatorCallsign" class="operatorCallsign">—</div>
79
+ </div>
80
+
81
+ <div class="routeBig">
82
+ <div id="routeCities" class="routeCities">— → —</div>
83
+ <div id="routeCodes" class="routeCodes">— → —</div>
84
+ </div>
85
+
86
+ <div id="aircraftCard" class="aircraftCard">
87
+ <div id="airlineMini" class="acRow acRowAirline">
88
+ <span class="acLogo">
89
+ <img id="airlineLogoImg" class="airlineLogoImg" src="" alt="" />
90
+ </span>
91
+ <span id="airline" class="airline">—</span>
92
+ </div>
93
+
94
+ <span class="aircraftSep" aria-hidden="true"></span>
95
+
96
+ <div class="acRow acRowAircraft">
97
+ <span id="manufacturerLogo" class="manufacturerLogo acLogo">
98
+ <img id="manufacturerLogoImg" src="" alt="" />
99
+ <span id="manufacturerLogoText" class="manufacturerLogoText">✈</span>
100
+ </span>
101
+ <span id="aircraftTypeText">—</span>
102
+ <span id="aircraftSize" class="aircraftSize">—</span>
103
+ </div>
104
+ </div>
105
+
106
+ <div id="registration" class="reg">Kennzeichen: —</div>
107
+ <div id="special" class="special"></div>
108
+ </section>
109
+
110
+ <div class="metrics">
111
+ <div class="metric">
112
+ <div id="altitude" class="metricValue">–</div>
113
+ <div class="metricLabel">HÖHE FT</div>
114
+ </div>
115
+ <div class="metric">
116
+ <div id="speed" class="metricValue">–</div>
117
+ <div class="metricLabel">SPEED KT</div>
118
+ </div>
119
+ <div class="metric">
120
+ <div id="verticalRate" class="metricValue">–</div>
121
+ <div class="metricLabel">FT/MIN</div>
122
+ </div>
123
+ <div class="metric">
124
+ <div id="track" class="metricValue">–</div>
125
+ <div class="metricLabel">KURS</div>
126
+ </div>
127
+ </div>
128
+
129
+ <div id="jfFlightSwitch" class="jfFlightSwitch" aria-hidden="true">
130
+ <div class="jfSwitchRadar" aria-hidden="true"></div>
131
+ <div class="jfSwitchPlane jfSwitchPlaneA" aria-hidden="true">✈️</div>
132
+ <div class="jfSwitchPlane jfSwitchPlaneB" aria-hidden="true">🛫</div>
133
+ <div class="jfSwitchPlane jfSwitchPlaneC" aria-hidden="true">🛬</div>
134
+ <div class="jfSwitchPlane jfSwitchPlaneD" aria-hidden="true">✈️</div>
135
+ <div class="jfSwitchText">
136
+ <div class="jfSwitchLabel">Nächster Flug</div>
137
+ <div id="jfSwitchRoute" class="jfSwitchRoute">—</div>
138
+ </div>
139
+ </div>
140
+
141
+ <div class="jfPreloadOverlay" aria-hidden="true">
142
+ <div class="prePhoto"></div>
143
+ <div class="preInfo">
144
+ <div class="sk skMode"></div>
145
+ <div class="prePillRow">
146
+ <div class="sk skPill skPillA"></div>
147
+ <div class="sk skPill skPillB"></div>
148
+ </div>
149
+ <div class="sk skCall"></div>
150
+ <div class="sk skSmall"></div>
151
+ <div class="sk skRoute"></div>
152
+ <div class="sk skCodes"></div>
153
+ <div class="preCard">
154
+ <div class="preCardRow">
155
+ <div class="preLogo"></div>
156
+ <div class="sk skAirline"></div>
157
+ </div>
158
+ <div class="preDivider"></div>
159
+ <div class="preCardRow">
160
+ <div class="preLogo"></div>
161
+ <div class="sk skType"></div>
162
+ <div class="sk skSize"></div>
163
+ </div>
164
+ </div>
165
+ <div class="sk skReg"></div>
166
+ </div>
167
+ <div class="preMetrics">
168
+ <div class="preMetric"><div class="sk"></div></div>
169
+ <div class="preMetric"><div class="sk"></div></div>
170
+ <div class="preMetric"><div class="sk"></div></div>
171
+ <div class="preMetric"><div class="sk"></div></div>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </div>
177
+
178
+ <div id="flyover" class="flyover" aria-hidden="true"></div>
179
+
180
+ <script>
181
+ 'use strict';
182
+
183
+ /* ── Konfiguration ───────────────────────────────────────── */
184
+
185
+ let API_BASE = (() => {
186
+ const proto = window.location.protocol || 'http:';
187
+ const host = window.location.hostname || '127.0.0.1';
188
+ const url = new URL(window.location.href);
189
+ const apiHost = url.searchParams.get('apiHost') || host;
190
+ const apiPort = url.searchParams.get('apiPort') || '8087';
191
+ return proto + '//' + apiHost + ':' + apiPort;
192
+ })();
193
+
194
+ const DP_ROOT = (() => {
195
+ const url = new URL(window.location.href);
196
+ const instance = url.searchParams.get('instance') || '0';
197
+ return 'jetframe.' + String(instance).replace(/[^0-9]/g, '');
198
+ })();
199
+
200
+ let VIS_SOURCE = (() => {
201
+ const url = new URL(window.location.href);
202
+ const src = String(url.searchParams.get('source') || 'current').toLowerCase();
203
+ return ['current', 'airport', 'overflight'].includes(src) ? src : 'current';
204
+ })();
205
+
206
+ let CURRENT = DP_ROOT + '.' + VIS_SOURCE;
207
+ let VIS_CONFIG_LOADED = false;
208
+
209
+ /* IDS werden als Getter-Funktion aufgebaut, damit rebuildIds() sauber ist */
210
+ function makeIds() {
211
+ return {
212
+ enabled: DP_ROOT + '.enabled',
213
+ idleRunwayText: DP_ROOT + '.idleRunwayText',
214
+ speechMode: DP_ROOT + '.speechMode',
215
+ speechEnabled: DP_ROOT + '.speechEnabled',
216
+
217
+ callsign: CURRENT + '.callsign',
218
+ operationalCallsign: CURRENT + '.operationalCallsign',
219
+ routeCallsign: CURRENT + '.routeCallsign',
220
+
221
+ modeVisText: CURRENT + '.modeVisText',
222
+ windowPositionText: CURRENT + '.windowPositionText',
223
+ windowPositionClass: CURRENT + '.windowPositionClass',
224
+
225
+ probableRunwayText: CURRENT + '.probableRunwayText',
226
+
227
+ routeDisplayText: CURRENT + '.routeDisplayText',
228
+ routeCodesText: CURRENT + '.routeCodesText',
229
+
230
+ airlineName: CURRENT + '.airlineName',
231
+ registration: CURRENT + '.registration',
232
+
233
+ specialDisplayText: CURRENT + '.specialDisplayText',
234
+ specialLiveryVisText: CURRENT + '.specialLiveryVisText',
235
+ emergencyText: CURRENT + '.emergencyText',
236
+ squawk: CURRENT + '.squawk',
237
+
238
+ manufacturerLogoUrl: CURRENT + '.manufacturerLogoUrl',
239
+ manufacturerLogoText: CURRENT + '.manufacturerLogoText',
240
+ aircraftTypeText: CURRENT + '.aircraftTypeText',
241
+ aircraftSize: CURRENT + '.aircraftSize',
242
+
243
+ altitudeFt: CURRENT + '.altitudeFt',
244
+ speedKt: CURRENT + '.speedKt',
245
+ verticalRate: CURRENT + '.verticalRate',
246
+ trackDeg: CURRENT + '.trackDeg',
247
+
248
+ localImageUrl: CURRENT + '.localImageUrl',
249
+ jetphotosImageUrl: CURRENT + '.jetphotosImageUrl',
250
+ localLogoUrl: CURRENT + '.localLogoUrl',
251
+ logoUrl: CURRENT + '.logoUrl',
252
+
253
+ speechText: CURRENT + '.speechText',
254
+ speechTrigger: CURRENT + '.speechTrigger',
255
+ };
256
+ }
257
+
258
+ let IDS = makeIds();
259
+
260
+ function rebuildIds() {
261
+ CURRENT = DP_ROOT + '.' + VIS_SOURCE;
262
+ IDS = makeIds();
263
+ }
264
+
265
+ /* ── State ──────────────────────────────────────────────── */
266
+
267
+ let lastFlightKey = '';
268
+ let spokenFlights = new Set();
269
+ let pendingSpeech = null;
270
+ let speechEnabled = true;
271
+ let audioCtx = null;
272
+ let speechUnlocked = false;
273
+
274
+ /* ── DOM Helper ─────────────────────────────────────────── */
275
+
276
+ function $(id) { return document.getElementById(id); }
277
+
278
+ /* ── API Warning ────────────────────────────────────────── */
279
+
280
+ function setSimpleApiWarning(visible, text) {
281
+ const box = $('simpleApiWarning');
282
+ const msg = $('simpleApiWarningText');
283
+ if (!box) return;
284
+ if (msg) msg.textContent = text || API_BASE;
285
+ box.classList.toggle('visible', !!visible);
286
+ }
287
+
288
+ async function checkSimpleApiReachable() {
289
+ try {
290
+ const res = await fetch(API_BASE + '/getPlainValue/' + encodeURIComponent(DP_ROOT + '.status'), { cache: 'no-store' });
291
+ if (!res.ok) throw new Error('HTTP ' + res.status);
292
+ setSimpleApiWarning(false, '');
293
+ return true;
294
+ } catch (e) {
295
+ setSimpleApiWarning(true, API_BASE);
296
+ return false;
297
+ }
298
+ }
299
+
300
+ /* ── Value Helpers ──────────────────────────────────────── */
301
+
302
+ function cleanValue(v) {
303
+ if (v === null || v === undefined) return '';
304
+ v = String(v).trim();
305
+ if (v === 'null' || v === 'undefined') return '';
306
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
307
+ v = v.slice(1, -1);
308
+ }
309
+ return v.replace(/\\"/g, '"').replace(/\\n/g, ' ').trim();
310
+ }
311
+
312
+ function num(v) {
313
+ const n = Number(String(v).replace(',', '.'));
314
+ return Number.isFinite(n) ? n : 0;
315
+ }
316
+
317
+ function formatNumber(v) {
318
+ const n = num(v);
319
+ return n ? Math.round(n).toLocaleString('de-DE') : '–';
320
+ }
321
+
322
+ function formatSigned(v) {
323
+ const n = num(v);
324
+ if (!n) return '–';
325
+ const r = Math.round(n);
326
+ return (r > 0 ? '+' : '') + r.toLocaleString('de-DE');
327
+ }
328
+
329
+ /* ── API Calls ──────────────────────────────────────────── */
330
+
331
+ async function readState(id) {
332
+ if (!VIS_CONFIG_LOADED) return '';
333
+ try {
334
+ const res = await fetch(API_BASE + '/getPlainValue/' + encodeURIComponent(id), { cache: 'no-store' });
335
+ if (!res.ok) return '';
336
+ const text = await res.text();
337
+ const cleaned = cleanValue(text);
338
+ if (!cleaned || cleaned === 'null' || cleaned.toLowerCase().startsWith('error:')) return '';
339
+ return cleaned;
340
+ } catch (e) {
341
+ setSimpleApiWarning(true, API_BASE);
342
+ return '';
343
+ }
344
+ }
345
+
346
+ async function writeState(id, value) {
347
+ try {
348
+ await fetch(API_BASE + '/set/' + encodeURIComponent(id) + '?value=' + encodeURIComponent(value), { cache: 'no-store' });
349
+ } catch (e) {}
350
+ }
351
+
352
+ async function readAll() {
353
+ const entries = await Promise.all(
354
+ Object.entries(IDS).map(async ([k, id]) => [k, await readState(id)])
355
+ );
356
+ return Object.fromEntries(entries);
357
+ }
358
+
359
+ /* ── Visual Config ──────────────────────────────────────── */
360
+
361
+ async function loadVisualConfig() {
362
+ const url = new URL(window.location.href);
363
+ const hasApiOverride = url.searchParams.has('apiHost') || url.searchParams.has('apiPort');
364
+ const hasSourceOverride = url.searchParams.has('source');
365
+
366
+ const res = await fetch('/jetframe.admin/vis-config.json?v=' + Date.now(), { cache: 'no-store' });
367
+ if (!res.ok) throw new Error('vis-config.json nicht gefunden');
368
+
369
+ const cfg = await res.json();
370
+
371
+ if (!hasApiOverride) {
372
+ const proto = window.location.protocol || 'http:';
373
+ const host = String(cfg.simpleApiHost || window.location.hostname || '127.0.0.1').trim();
374
+ const port = String(cfg.simpleApiPort || '8087').trim();
375
+ API_BASE = proto + '//' + host + ':' + port;
376
+ }
377
+
378
+ if (!hasSourceOverride) {
379
+ const src = String(cfg.visualSource || 'current').toLowerCase();
380
+ VIS_SOURCE = ['current', 'airport', 'overflight'].includes(src) ? src : 'current';
381
+ }
382
+
383
+ VIS_CONFIG_LOADED = true;
384
+ rebuildIds();
385
+
386
+ console.log('[JetFrame] vis-config.json geladen:', cfg, API_BASE, CURRENT);
387
+ }
388
+
389
+ /* ── Audio / Speech ─────────────────────────────────────── */
390
+
391
+ function unlockAudio() {
392
+ try {
393
+ if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
394
+ if (audioCtx.state === 'suspended') audioCtx.resume();
395
+ } catch (e) {}
396
+ }
397
+
398
+ function unlockSpeech() {
399
+ if (speechUnlocked) return;
400
+ try {
401
+ if (!('speechSynthesis' in window)) return;
402
+ const u = new SpeechSynthesisUtterance(' ');
403
+ u.lang = 'de-DE'; u.volume = 0.01; u.rate = 1;
404
+ speechSynthesis.cancel();
405
+ speechSynthesis.speak(u);
406
+ speechUnlocked = true;
407
+
408
+ if (pendingSpeech && pendingSpeech.text && !spokenFlights.has(pendingSpeech.key)) {
409
+ spokenFlights.add(pendingSpeech.key);
410
+ setTimeout(() => speakFlight(pendingSpeech.text), 250);
411
+ pendingSpeech = null;
412
+ }
413
+ } catch (e) {}
414
+ }
415
+
416
+ function playPlaneSound() {
417
+ if (!speechEnabled || !audioCtx) return;
418
+ try {
419
+ const now = audioCtx.currentTime;
420
+ const dur = 1.8;
421
+
422
+ const buf = audioCtx.createBuffer(1, audioCtx.sampleRate * dur, audioCtx.sampleRate);
423
+ const data = buf.getChannelData(0);
424
+ for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * 0.45;
425
+
426
+ const src = audioCtx.createBufferSource();
427
+ src.buffer = buf;
428
+
429
+ const lp = audioCtx.createBiquadFilter();
430
+ lp.type = 'lowpass';
431
+ lp.frequency.setValueAtTime(260, now);
432
+ lp.frequency.linearRampToValueAtTime(950, now + 0.8);
433
+ lp.frequency.linearRampToValueAtTime(420, now + dur);
434
+
435
+ const g = audioCtx.createGain();
436
+ g.gain.setValueAtTime(0.0001, now);
437
+ g.gain.exponentialRampToValueAtTime(0.28, now + 0.35);
438
+ g.gain.exponentialRampToValueAtTime(0.08, now + 1.25);
439
+ g.gain.exponentialRampToValueAtTime(0.0001, now + dur);
440
+
441
+ src.connect(lp); lp.connect(g); g.connect(audioCtx.destination);
442
+ src.start(now); src.stop(now + dur);
443
+ } catch (e) {}
444
+ }
445
+
446
+ function speakFlight(text) {
447
+ if (!speechEnabled) return;
448
+ if (!('speechSynthesis' in window)) return;
449
+ text = cleanValue(text);
450
+ if (!text) return;
451
+ try {
452
+ speechSynthesis.cancel();
453
+ const msg = new SpeechSynthesisUtterance(text);
454
+ msg.lang = 'de-DE'; msg.rate = 0.85; msg.pitch = 1.05; msg.volume = 1;
455
+ speechSynthesis.speak(msg);
456
+ } catch (e) {}
457
+ }
458
+
459
+ /* ── Buttons ────────────────────────────────────────────── */
460
+
461
+ function setSpeechButton() {
462
+ const btn = $('speechBtn');
463
+ if (!btn) return;
464
+ btn.textContent = speechEnabled ? '🔊' : '🔇';
465
+ btn.classList.toggle('on', speechEnabled);
466
+ btn.setAttribute('aria-label', speechEnabled ? 'Sprachausgabe an' : 'Sprachausgabe aus');
467
+ }
468
+
469
+ $('toggleBtn').addEventListener('click', async () => {
470
+ unlockAudio(); unlockSpeech();
471
+ const cur = cleanValue(await readState(DP_ROOT + '.enabled')) === 'true';
472
+ await writeState(DP_ROOT + '.enabled', cur ? 'false' : 'true');
473
+ poll();
474
+ });
475
+
476
+ $('speechBtn').addEventListener('click', async () => {
477
+ unlockAudio(); unlockSpeech();
478
+ const cur = cleanValue(await readState(IDS.speechEnabled)) !== 'false';
479
+ const next = !cur;
480
+ await writeState(IDS.speechEnabled, next ? 'true' : 'false');
481
+ if (!next && 'speechSynthesis' in window) speechSynthesis.cancel();
482
+ speechEnabled = next;
483
+ setSpeechButton();
484
+ poll();
485
+ });
486
+
487
+ document.addEventListener('click', () => { unlockAudio(); unlockSpeech(); }, { once: true });
488
+
489
+ /* ── Image Helpers ──────────────────────────────────────── */
490
+
491
+ function setImage(imgEl, url, placeholderEl) {
492
+ url = cleanValue(url);
493
+ if (!imgEl) return;
494
+
495
+ if (!url) {
496
+ imgEl.style.display = 'none';
497
+ imgEl.removeAttribute('src');
498
+ if (placeholderEl) placeholderEl.style.display = 'grid';
499
+ return;
500
+ }
501
+
502
+ imgEl.onload = () => { imgEl.style.display = 'block'; if (placeholderEl) placeholderEl.style.display = 'none'; };
503
+ imgEl.onerror = () => { imgEl.style.display = 'none'; imgEl.removeAttribute('src'); if (placeholderEl) placeholderEl.style.display = 'grid'; };
504
+ imgEl.src = url;
505
+ }
506
+
507
+ function setManufacturerLogo(s) {
508
+ const img = $('manufacturerLogoImg');
509
+ const txt = $('manufacturerLogoText');
510
+ if (!img || !txt) return;
511
+
512
+ const url = cleanValue(s.manufacturerLogoUrl);
513
+ const fallback = cleanValue(s.manufacturerLogoText) || '✈';
514
+
515
+ img.onload = () => { img.style.display = 'block'; txt.classList.remove('isFallback'); txt.style.display = 'none'; };
516
+ img.onerror = () => { img.style.display = 'none'; img.removeAttribute('src'); txt.textContent = fallback; txt.classList.add('isFallback'); txt.style.display = 'grid'; };
517
+
518
+ if (url) {
519
+ txt.classList.remove('isFallback'); txt.style.display = 'none';
520
+ img.src = url;
521
+ } else {
522
+ img.style.display = 'none'; img.removeAttribute('src');
523
+ txt.textContent = fallback; txt.classList.add('isFallback'); txt.style.display = 'grid';
524
+ }
525
+ }
526
+
527
+ function setAirlineLogo(s) {
528
+ const img = $('airlineLogoImg');
529
+ const mini = $('airlineMini');
530
+ if (!img || !mini) return;
531
+
532
+ const url = cleanValue(s.localLogoUrl) || cleanValue(s.logoUrl);
533
+
534
+ if (!url) {
535
+ img.style.display = 'none'; img.removeAttribute('src');
536
+ mini.classList.remove('hasLogo');
537
+ return;
538
+ }
539
+
540
+ img.onload = () => { img.style.display = 'block'; mini.classList.add('hasLogo'); };
541
+ img.onerror = () => { img.style.display = 'none'; img.removeAttribute('src'); mini.classList.remove('hasLogo'); };
542
+
543
+ if (img.getAttribute('src') !== url) img.src = url;
544
+ }
545
+
546
+ /* ── Pill Renders ───────────────────────────────────────── */
547
+
548
+ function setRunwayInfo(text) {
549
+ const el = $('runwayInfo');
550
+ if (!el) return;
551
+ text = cleanValue(text);
552
+
553
+ if (!text) {
554
+ el.textContent = '';
555
+ el.classList.remove('visible');
556
+ el.style.display = 'none';
557
+ return;
558
+ }
559
+
560
+ text = text
561
+ .replace(/vermutlich\s+/i, '')
562
+ .replace(/\s*[·|]\s*(Landung|Start|Traffic).*$/i, '')
563
+ .trim();
564
+
565
+ el.textContent = text;
566
+ el.style.display = 'inline-flex';
567
+ requestAnimationFrame(() => el.classList.add('visible'));
568
+ }
569
+
570
+ function renderWindow(s) {
571
+ const el = $('windowInfo');
572
+ const textEl = $('windowInfoText');
573
+ const arrow = $('windowArrow');
574
+ if (!el) return;
575
+
576
+ const text = cleanValue(s.windowPositionText);
577
+ const cls = cleanValue(s.windowPositionClass) || 'side';
578
+
579
+ el.style.display = text ? 'inline-flex' : 'none';
580
+
581
+ el.classList.remove('center', 'side');
582
+ el.classList.add(cls);
583
+
584
+ const cleanText = text.replace(/^[^a-zA-ZäöüÄÖÜß0-9]+\s*/u, '').trim();
585
+ if (textEl) textEl.textContent = cleanText;
586
+ else el.textContent = cleanText;
587
+
588
+ if (arrow) {
589
+ const match = text.match(/(\d+(?:[.,]\d+)?)°/);
590
+ let deg = match ? (Number(String(match[1]).replace(',', '.')) || 0) : 0;
591
+ if (text.toLowerCase().includes('links')) deg = -Math.abs(deg);
592
+ else if (!text.toLowerCase().includes('rechts')) deg = 0;
593
+ arrow.textContent = '▲';
594
+ arrow.style.transform = 'rotate(' + deg + 'deg)';
595
+ }
596
+ }
597
+
598
+ function setIdleRunwayText(text) {
599
+ const el = $('idleRunwayText');
600
+ if (!el) return;
601
+ text = cleanValue(text);
602
+
603
+ if (!text) {
604
+ el.textContent = '';
605
+ el.classList.remove('visible');
606
+ return;
607
+ }
608
+
609
+ el.textContent = text;
610
+ el.classList.add('visible');
611
+ }
612
+
613
+ /* ── Flyover ────────────────────────────────────────────── */
614
+
615
+ function triggerFlyover() {
616
+ const el = $('flyover');
617
+ if (!el) return;
618
+ el.classList.remove('active');
619
+ void el.offsetWidth;
620
+ el.classList.add('active');
621
+ setTimeout(() => el.classList.remove('active'), 3400);
622
+ }
623
+
624
+ /* ── Main Render ────────────────────────────────────────── */
625
+
626
+ function setEnabledButton(enabled) {
627
+ const btn = $('toggleBtn');
628
+ if (!btn) return;
629
+ btn.textContent = enabled ? 'ON' : 'OFF';
630
+ btn.classList.toggle('on', enabled);
631
+ }
632
+
633
+ function render(s) {
634
+ const enabled = cleanValue(s.enabled) === 'true';
635
+ setEnabledButton(enabled);
636
+
637
+ const hasFlight = !!(
638
+ cleanValue(s.callsign) ||
639
+ cleanValue(s.operationalCallsign) ||
640
+ cleanValue(s.routeCallsign) ||
641
+ cleanValue(s.registration)
642
+ );
643
+
644
+ $('wall').classList.toggle('empty', !hasFlight);
645
+ document.body.classList.toggle('jf-no-flight', !hasFlight);
646
+
647
+ /* ── No Flight ── */
648
+ if (!hasFlight) {
649
+ $('mode').textContent = enabled ? '✈️ Warte auf Flug' : '⏸️ JetFrame aus';
650
+ $('windowInfo').style.display = 'none';
651
+
652
+ $('iataCallsign').textContent = '—';
653
+ $('operatorCallsign').textContent = '—';
654
+ $('routeCities').textContent = '— → —';
655
+ $('routeCodes').textContent = '— → —';
656
+ $('airline').textContent = '—';
657
+ setAirlineLogo({});
658
+ $('aircraftTypeText').textContent = '—';
659
+ $('aircraftSize').textContent = '—';
660
+ $('registration').textContent = 'Kennzeichen: —';
661
+
662
+ const sp = $('special');
663
+ sp.textContent = '';
664
+ sp.style.display = 'none';
665
+ sp.classList.remove('emergency');
666
+
667
+ $('altitude').textContent = '–';
668
+ $('speed').textContent = '–';
669
+ $('verticalRate').textContent = '–';
670
+ $('track').textContent = '–';
671
+
672
+ setManufacturerLogo({});
673
+ setImage($('planePhoto'), '', $('placeholder'));
674
+ setIdleRunwayText(cleanValue(s.probableRunwayText) || cleanValue(s.idleRunwayText));
675
+ setRunwayInfo(cleanValue(s.idleRunwayText));
676
+
677
+ return;
678
+ }
679
+
680
+ /* ── Active Flight ── */
681
+ const visibleCallsign = cleanValue(s.routeCallsign) || cleanValue(s.callsign) || '—';
682
+ const opCallsign = cleanValue(s.operationalCallsign) || cleanValue(s.callsign) || '—';
683
+
684
+ $('mode').textContent = cleanValue(s.modeVisText) || '✈️ Flight';
685
+
686
+ renderWindow(s);
687
+ setRunwayInfo(cleanValue(s.probableRunwayText));
688
+
689
+ $('iataCallsign').textContent = visibleCallsign;
690
+ $('operatorCallsign').textContent = opCallsign;
691
+ $('routeCities').textContent = cleanValue(s.routeDisplayText) || '— → —';
692
+ $('routeCodes').textContent = cleanValue(s.routeCodesText) || '— → —';
693
+ $('airline').textContent = cleanValue(s.airlineName) || '—';
694
+ setAirlineLogo(s);
695
+ setManufacturerLogo(s);
696
+
697
+ $('aircraftTypeText').textContent = cleanValue(s.aircraftTypeText) || '—';
698
+ $('aircraftSize').textContent = cleanValue(s.aircraftSize) || '—';
699
+ $('registration').textContent = 'Kennzeichen: ' + (cleanValue(s.registration) || '—');
700
+
701
+ const emergencyText = cleanValue(s.emergencyText);
702
+ const special = emergencyText || cleanValue(s.specialLiveryVisText) || cleanValue(s.specialDisplayText);
703
+ const sp = $('special');
704
+ sp.textContent = special ? '⭐ ' + special : '';
705
+ sp.style.display = special ? 'block' : 'none';
706
+ sp.classList.toggle('emergency', !!emergencyText);
707
+
708
+ $('altitude').textContent = formatNumber(s.altitudeFt);
709
+ $('speed').textContent = formatNumber(s.speedKt);
710
+ $('verticalRate').textContent = formatSigned(s.verticalRate);
711
+
712
+ const trackVal = num(s.trackDeg);
713
+ $('track').textContent = trackVal ? Math.round(trackVal) + '°' : '–';
714
+
715
+ setImage(
716
+ $('planePhoto'),
717
+ cleanValue(s.localImageUrl) || cleanValue(s.jetphotosImageUrl),
718
+ $('placeholder')
719
+ );
720
+
721
+ setIdleRunwayText('');
722
+
723
+ /* ── Speech Logic ── */
724
+ const key = [cleanValue(s.callsign), cleanValue(s.routeCallsign), cleanValue(s.registration)].join('|');
725
+ const speechTextNow = cleanValue(s.speechText);
726
+ const mode = (cleanValue(s.speechMode) || 'browser').toLowerCase();
727
+ const isNewFlight = key && key !== lastFlightKey;
728
+
729
+ if (isNewFlight) {
730
+ lastFlightKey = key;
731
+ triggerFlyover();
732
+ if (mode !== 'off') playPlaneSound();
733
+ }
734
+
735
+ if (isNewFlight && speechTextNow && !spokenFlights.has(key)) {
736
+ if (mode === 'browser' || mode === 'both') {
737
+ if (speechUnlocked) {
738
+ spokenFlights.add(key);
739
+ speakFlight(speechTextNow);
740
+ } else {
741
+ pendingSpeech = { key, text: speechTextNow };
742
+ }
743
+ }
744
+ if (mode === 'external' || mode === 'both') {
745
+ if (mode === 'external') spokenFlights.add(key);
746
+ writeState(IDS.speechTrigger, 'true');
747
+ setTimeout(() => writeState(IDS.speechTrigger, 'false'), 800);
748
+ }
749
+ }
750
+ }
751
+
752
+ /* ── Poll ───────────────────────────────────────────────── */
753
+
754
+ async function poll() {
755
+ const data = await readAll();
756
+
757
+ speechEnabled = cleanValue(data.speechEnabled) !== 'false';
758
+ setSpeechButton();
759
+
760
+ const didFlightSwitch = window.jfMaybeFlightSwitch ? window.jfMaybeFlightSwitch(data) : false;
761
+
762
+ if (didFlightSwitch) {
763
+ setTimeout(() => {
764
+ render(data);
765
+ if (window.jfMaybeFinishFramePreload) window.jfMaybeFinishFramePreload();
766
+ }, 900);
767
+ } else {
768
+ render(data);
769
+ if (window.jfMaybeFinishFramePreload) window.jfMaybeFinishFramePreload();
770
+ }
771
+ }
772
+
773
+ /* ── Boot ───────────────────────────────────────────────── */
774
+
775
+ setSpeechButton();
776
+
777
+ loadVisualConfig()
778
+ .then(async () => {
779
+ await checkSimpleApiReachable();
780
+ poll();
781
+ setInterval(poll, 1500);
782
+ })
783
+ .catch(e => {
784
+ VIS_CONFIG_LOADED = false;
785
+ console.error('[JetFrame] Start abgebrochen:', e);
786
+ });
787
+ </script>
788
+
789
+
790
+ <!-- ========== Header Subline Clock ========== -->
791
+ <script>
792
+ (function () {
793
+ let _busy = false;
794
+
795
+ function pad(n) { return String(n).padStart(2, '0'); }
796
+
797
+ function wantedText() {
798
+ const d = new Date();
799
+ return 'Heute · ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) +
800
+ ' · ' + pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '.' + d.getFullYear();
801
+ }
802
+
803
+ function applyHeaderSubline() {
804
+ if (_busy) return;
805
+ _busy = true;
806
+ const sub = document.querySelector('.header .sub, .header .frameTimeSub');
807
+ if (sub) {
808
+ sub.className = 'sub';
809
+ const text = wantedText();
810
+ if (sub.textContent !== text) sub.textContent = text;
811
+ }
812
+ _busy = false;
813
+ }
814
+
815
+ applyHeaderSubline();
816
+ setInterval(applyHeaderSubline, 5000); // Reduziert von 1s auf 5s
817
+
818
+ const target = document.querySelector('.header');
819
+ if (target) {
820
+ new MutationObserver(applyHeaderSubline).observe(target, { childList: true, subtree: true, characterData: true });
821
+ }
822
+ })();
823
+ </script>
824
+
825
+
826
+ <!-- ========== Date Format Fix ========== -->
827
+ <script>
828
+ (function () {
829
+ const RE_DATE = /\b(\d{4})-(\d{2})-(\d{2})\b/g;
830
+
831
+ function fmtDateText(text) {
832
+ return String(text || '').replace(RE_DATE, '$3.$2.$1');
833
+ }
834
+
835
+ function applyDateFormat() {
836
+ const root = document.querySelector('.jf-shell, .card, .wall');
837
+ if (!root) return;
838
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
839
+ const nodes = [];
840
+ while (walker.nextNode()) nodes.push(walker.currentNode);
841
+ for (const n of nodes) {
842
+ if (!RE_DATE.test(n.nodeValue)) continue;
843
+ const next = fmtDateText(n.nodeValue);
844
+ if (next !== n.nodeValue) n.nodeValue = next;
845
+ }
846
+ }
847
+
848
+ applyDateFormat();
849
+ setInterval(applyDateFormat, 5000); // Reduziert von 1s auf 5s
850
+
851
+ const root = document.querySelector('.jf-shell, .card, .wall');
852
+ if (root) {
853
+ new MutationObserver(applyDateFormat).observe(root, { childList: true, subtree: true, characterData: true });
854
+ }
855
+ })();
856
+ </script>
857
+
858
+
859
+ <!-- ========== Flight Switch ========== -->
860
+ <script>
861
+ (function () {
862
+ window.jfLastFlightKey = null;
863
+ window.jfFlightSwitchTimer = null;
864
+
865
+ function clean(v) { return String(v || '').trim(); }
866
+
867
+ function pickFlightKey(s) {
868
+ return clean(s.callsign) || clean(s.operationalCallsign) ||
869
+ clean(s.routeCallsign) || clean(s.registration) ||
870
+ clean(s.hex) || clean(s.icao24);
871
+ }
872
+
873
+ function pickRouteText(s) {
874
+ const from = clean(s.fromCity) || clean(s.fromName) || clean(s.originCity) || clean(s.fromIata) || clean(s.origin) || '';
875
+ const to = clean(s.toCity) || clean(s.toName) || clean(s.destinationCity) || clean(s.toIata) || clean(s.destination) || '';
876
+ if (from && to) return from + ' → ' + to;
877
+ return clean(s.callsign) || clean(s.operationalCallsign) || clean(s.routeCallsign) || 'Flugwechsel';
878
+ }
879
+
880
+ window.jfMaybeFlightSwitch = function (s) {
881
+ if (!s) return false;
882
+ if (document.body.classList.contains('jf-preload')) return false;
883
+ if (document.body.classList.contains('jf-no-flight')) return false;
884
+
885
+ const key = pickFlightKey(s);
886
+ if (!key) return false;
887
+
888
+ const last = window.jfLastFlightKey;
889
+ if (!last) { window.jfLastFlightKey = key; return false; }
890
+ if (last === key) return false;
891
+
892
+ window.jfLastFlightKey = key;
893
+
894
+ const routeEl = document.getElementById('jfSwitchRoute');
895
+ if (routeEl) routeEl.textContent = pickRouteText(s);
896
+
897
+ clearTimeout(window.jfFlightSwitchTimer);
898
+ document.body.classList.remove('jf-flight-switch');
899
+ void document.body.offsetWidth;
900
+ document.body.classList.add('jf-flight-switch');
901
+
902
+ window.jfFlightSwitchTimer = setTimeout(() => {
903
+ document.body.classList.remove('jf-flight-switch');
904
+ }, 1750);
905
+
906
+ return true;
907
+ };
908
+ })();
909
+ </script>
910
+
911
+
912
+ <!-- ========== Frame Preload ========== -->
913
+ <script>
914
+ (function () {
915
+ const startedAt = Date.now();
916
+ let finishTimer = null;
917
+
918
+ function txt(id) {
919
+ const el = document.getElementById(id);
920
+ return el ? String(el.textContent || '').trim() : '';
921
+ }
922
+
923
+ function meaningful(v) {
924
+ v = String(v || '').trim();
925
+ return !!v && v !== '—' && v !== '–' && v !== '— → —' && v !== 'Kennzeichen: —';
926
+ }
927
+
928
+ function imageReady() {
929
+ const img = document.getElementById('planePhoto');
930
+ if (document.body.classList.contains('jf-no-flight')) return true;
931
+ if (!img) return true;
932
+ return img.complete && img.naturalWidth > 0;
933
+ }
934
+
935
+ function dataReady() {
936
+ if (document.body.classList.contains('jf-no-flight')) {
937
+ return meaningful(txt('mode'));
938
+ }
939
+ return (
940
+ meaningful(txt('iataCallsign')) &&
941
+ meaningful(txt('routeCities')) &&
942
+ (meaningful(txt('airline')) || meaningful(txt('aircraftTypeText')) || meaningful(txt('registration')))
943
+ );
944
+ }
945
+
946
+ function finish() {
947
+ if (!document.body.classList.contains('jf-preload')) return;
948
+ document.body.classList.remove('jf-preload');
949
+ document.body.classList.add('jf-loaded');
950
+ }
951
+
952
+ window.jfMaybeFinishFramePreload = function () {
953
+ clearTimeout(finishTimer);
954
+ finishTimer = setTimeout(() => {
955
+ if (!dataReady() || !imageReady()) return;
956
+ const rest = Math.max(0, 1500 - (Date.now() - startedAt));
957
+ setTimeout(() => requestAnimationFrame(() => requestAnimationFrame(finish)), rest);
958
+ }, 250);
959
+ };
960
+ })();
961
+ </script>
962
+
963
+
964
+ <!-- ========== Tablet / Landscape Detector ========== -->
965
+ <script>
966
+ (function () {
967
+ function applyTabletClass() {
968
+ const w = window.innerWidth || 0;
969
+ const h = window.innerHeight || 0;
970
+ const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
971
+ const landscape = w > h;
972
+ const isTabletLandscape = landscape && coarse && w >= 900 && h >= 600;
973
+ document.body.classList.toggle('jf-tablet-landscape', isTabletLandscape);
974
+ }
975
+
976
+ applyTabletClass();
977
+ window.addEventListener('resize', applyTabletClass);
978
+ window.addEventListener('orientationchange', () => setTimeout(applyTabletClass, 250));
979
+ })();
980
+ </script>
981
+ </body>
982
+ </html>