phimpal-tv 0.2.0 → 0.2.1

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 (3) hide show
  1. package/README.md +14 -0
  2. package/mod.js +101 -26
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -100,6 +100,20 @@ Three TV-specific behaviours live in the engine itself:
100
100
  site fades it in only while the element really matches `:hover`, which never happens
101
101
  with a remote — so the module reveals it itself.
102
102
 
103
+ ## If it freezes on the TV
104
+
105
+ Every key is intercepted before it is handled, so an exception inside the module used to
106
+ swallow all input — that looked like a frozen cursor with a dead Return key. Since 0.2.1:
107
+
108
+ - **Return ×3 within 1.5 s** always resets the module (mode, menus, seek cursor) — the
109
+ universal escape hatch.
110
+ - any exception is caught, the state is reset automatically, and a **red box bottom-left**
111
+ shows the error for 10 s. Photograph it: that text is the bug report. The last one is also
112
+ kept in `localStorage['tv-lasterror']`.
113
+ - page scans are memoised (a TV CPU was re-computing the same styles hundreds of times per
114
+ key), held keys are coalesced, and carousels are recognised structurally so paging also
115
+ works on Tizen ≤ 6.5, whose Chromium ignores `overflow-x: clip`.
116
+
103
117
  ## Known limits
104
118
 
105
119
  - The per-track timing button (clock icon) in the subtitle panel is not reachable with the
package/mod.js CHANGED
@@ -1,4 +1,4 @@
1
- // PhimPal TV v0.2.0 — generated by build.js from phimpal-tv.user.js, do not edit.
1
+ // PhimPal TV v0.2.1 — generated by build.js from phimpal-tv.user.js, do not edit.
2
2
  // TizenBrew site-modification module for https://phimpal.com/
3
3
  (function registerRemoteKeys() {
4
4
  try {
@@ -50,6 +50,23 @@
50
50
  // ------------------------------------------------------------------ dom helpers
51
51
  const $ = (s, r = document) => r.querySelector(s);
52
52
  const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
53
+
54
+ // ------------------------------------------------------------------ error reporting
55
+ // On the TV there is no console: any exception in our code is shown on screen (even with
56
+ // the debug badge off) and kept in localStorage['tv-lasterror'] for the bug report.
57
+ let errTimer = null;
58
+ function report(where, e) {
59
+ const msg = where + ': ' + ((e && e.message) || e) + '\n' + (((e && e.stack) || '').split('\n').slice(1, 3).join('\n'));
60
+ try { localStorage.setItem('tv-lasterror', new Date().toISOString() + ' ' + msg); } catch (x) { /* ignore */ }
61
+ try {
62
+ let b = $('#tv-error');
63
+ if (!b) { b = document.createElement('pre'); b.id = 'tv-error'; document.body.appendChild(b); }
64
+ b.textContent = 'phimpal-tv error\n' + msg;
65
+ b.style.opacity = '1';
66
+ clearTimeout(errTimer); errTimer = setTimeout(() => { b.style.opacity = '0'; }, 10000);
67
+ } catch (x) { /* ignore */ }
68
+ }
69
+ const safe = (where, fn) => function () { try { return fn.apply(this, arguments); } catch (e) { report(where, e); } };
53
70
  const rectOf = (el) => el.getBoundingClientRect();
54
71
  const isWatch = () => /^\/watch\//.test(location.pathname);
55
72
  const isHome = () => location.pathname === '/';
@@ -65,29 +82,47 @@
65
82
  const EXCL_SELF = 'button[aria-label="Previous"], button[aria-label="Next"], button[aria-label^="Go to page"]';
66
83
  const POPUP = ':scope > .group-hover\\:block';
67
84
 
85
+ // getComputedStyle is the expensive call on a TV CPU; a scan of the page asks for the
86
+ // same ancestors hundreds of times, so results are memoised for the duration of one scan.
87
+ let csCache = null, clipCache = null;
88
+ const cs = (el) => {
89
+ if (!csCache) return getComputedStyle(el);
90
+ let v = csCache.get(el); if (!v) { v = getComputedStyle(el); csCache.set(el, v); }
91
+ return v;
92
+ };
93
+ function withScanCache(fn) {
94
+ const outer = !!csCache;
95
+ if (!outer) { csCache = new Map(); clipCache = new Map(); }
96
+ try { return fn(); } finally { if (!outer) { csCache = null; clipCache = null; } }
97
+ }
68
98
  function styleVisible(el) {
69
- const cs = getComputedStyle(el);
70
- return cs.visibility !== 'hidden' && cs.display !== 'none' && parseFloat(cs.opacity) > 0 && cs.pointerEvents !== 'none';
99
+ const c = cs(el);
100
+ return c.visibility !== 'hidden' && c.display !== 'none' && parseFloat(c.opacity) > 0 && c.pointerEvents !== 'none';
71
101
  }
72
102
  function ancestorsOpaque(el, depth = 4) {
73
103
  let n = el.parentElement;
74
104
  while (n && n !== document.body && depth-- > 0) {
75
- const cs = getComputedStyle(n);
76
- if (cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) return false;
105
+ const c = cs(n);
106
+ if (c.visibility === 'hidden' || parseFloat(c.opacity) === 0) return false;
77
107
  n = n.parentElement;
78
108
  }
79
109
  return true;
80
110
  }
81
111
  // nearest ancestor that clips or scrolls horizontally
82
112
  function clipAncestor(el) {
83
- let n = el.parentElement;
113
+ if (clipCache && clipCache.has(el)) return clipCache.get(el);
114
+ let n = el.parentElement, res = null;
84
115
  while (n && n !== document.body) {
85
- const ox = getComputedStyle(n).overflowX;
86
- if (ox === 'clip' || ox === 'hidden') return { el: n, kind: 'clip' };
87
- if (ox === 'auto' || ox === 'scroll') return { el: n, kind: 'scroll' };
116
+ // the site's rows use `overflow-x: clip`, a value Chromium < 90 (Tizen ≤ 6.5) does not
117
+ // parse recognise the row by its class too, so paging works on older TVs
118
+ if (n.classList.contains('overflow-x-clip')) { res = { el: n, kind: 'clip' }; break; }
119
+ const ox = cs(n).overflowX;
120
+ if (ox === 'clip' || ox === 'hidden') { res = { el: n, kind: 'clip' }; break; }
121
+ if (ox === 'auto' || ox === 'scroll') { res = { el: n, kind: 'scroll' }; break; }
88
122
  n = n.parentElement;
89
123
  }
90
- return null;
124
+ if (clipCache) clipCache.set(el, res);
125
+ return res;
91
126
  }
92
127
  function horizVisible(el, r) {
93
128
  const c = clipAncestor(el);
@@ -166,7 +201,8 @@
166
201
  return (sp && rectOf(sp).height > 0) ? sp : null;
167
202
  }
168
203
 
169
- function collectBlocks(scope) {
204
+ function collectBlocks(scope) { return withScanCache(() => collectBlocksRaw(scope)); }
205
+ function collectBlocksRaw(scope) {
170
206
  const root = scope || document;
171
207
  const seen = new Set(), dupes = new Map(), out = [];
172
208
  for (const el of $$(CAND, root)) {
@@ -254,14 +290,14 @@
254
290
  const item = block.closest('.shrink-0') || block;
255
291
  const sib = dir === A.RIGHT ? item.nextElementSibling : item.previousElementSibling;
256
292
  btn.click();
257
- setTimeout(() => {
293
+ setTimeout(safe('page', () => {
258
294
  let t = sib && (sib.matches('.group') ? sib : sib.querySelector('.group'));
259
295
  if (!(t && isVisibleBlock(t))) {
260
- const vis = $$('.group', car.clip).filter(isVisibleBlock);
296
+ const vis = withScanCache(() => $$('.group', car.clip).filter(isVisibleBlock));
261
297
  t = dir === A.RIGHT ? vis[0] : vis[vis.length - 1];
262
298
  }
263
- if (t) setFocus(t);
264
- }, CFG.carouselAnimMs);
299
+ if (t) setFocus(t); else badge('page: no card');
300
+ }), CFG.carouselAnimMs);
265
301
  return true;
266
302
  }
267
303
  function rowsOnPage() {
@@ -312,8 +348,15 @@
312
348
  function ensureVisible(el) {
313
349
  if (el.closest('header')) return;
314
350
  const r = rectOf(el);
351
+ const c = clipAncestor(el);
352
+ if (c && c.kind === 'clip') {
353
+ // never let the browser scroll a carousel row itself (that would fight its translateX
354
+ // paging); only move the page vertically
355
+ if (r.top < 90 || r.bottom > innerHeight - 30) window.scrollBy({ top: r.top + r.height / 2 - innerHeight / 2 });
356
+ return;
357
+ }
315
358
  if (r.top < 90 || r.bottom > innerHeight - 30) el.scrollIntoView({ block: 'center', inline: 'nearest' });
316
- else { const c = clipAncestor(el); if (c && c.kind === 'scroll') el.scrollIntoView({ block: 'nearest', inline: 'nearest' }); }
359
+ else if (c && c.kind === 'scroll') el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
317
360
  }
318
361
 
319
362
  // per-URL focus memory (survives the full reload of the watch page)
@@ -323,8 +366,10 @@
323
366
  function hrefOf(b) { const a = b.tagName === 'A' ? b : (isCard(b) ? posterOf(b) : null); return a ? a.getAttribute('href') : null; }
324
367
  function remember(block) {
325
368
  const m = loadMem();
326
- const sc = currentScope();
327
- m[memKey()] = { href: hrefOf(block), idx: collectBlocks(sc && sc.contains(block) ? sc : null).indexOf(block) };
369
+ const href = hrefOf(block);
370
+ // the index needs a full page scan only pay for it when there is no href to key on
371
+ const idx = href ? null : (() => { const sc = currentScope(); return collectBlocks(sc && sc.contains(block) ? sc : null).indexOf(block); })();
372
+ m[memKey()] = { href, idx };
328
373
  saveMem(m);
329
374
  }
330
375
  function initialFocus() {
@@ -712,12 +757,33 @@
712
757
 
713
758
  // ------------------------------------------------------------------ key dispatch
714
759
  function consume(e) { e.preventDefault(); e.stopImmediatePropagation(); }
760
+
761
+ // Whatever happens inside a key handler must never leave the module deaf: every key is
762
+ // consumed before it is handled, so an exception would otherwise swallow all input.
763
+ let lastKeyAt = 0, backTaps = [];
764
+ function hardReset(why) {
765
+ try { clearSeek(); hoverClose(); } catch (e) { /* ignore */ }
766
+ S.sub = null; S.menu = null; S.menuItem = null; S.returnMode = null; S.beforeScope = null; S.seekReturn = null;
767
+ S.focused = null;
768
+ if (isWatch() && player()) enterPlayer(); else { S.mode = 'NAV'; initialFocus(); }
769
+ badge('reset: ' + why, true);
770
+ }
771
+ function onKeySafe(e) {
772
+ try { onKey(e); }
773
+ catch (err) { report('key ' + e.keyCode, err); try { hardReset('error'); } catch (x) { report('reset', x); } }
774
+ }
715
775
  function onKey(e) {
716
776
  if (e.__tv) return;
717
777
  let act = TIZEN_KEYS[e.keyCode];
718
778
  if (!act && CFG.keyboardEmulation) act = KB_KEYS[e.keyCode];
719
779
  if (!act) return;
720
780
  if (e.repeat && (act === A.OK || act === A.BACK || act === A.PLAY)) return consume(e);
781
+ // a held D-pad fires faster than a TV can re-scan the page: coalesce repeats
782
+ const now = Date.now();
783
+ if (e.repeat && now - lastKeyAt < 120) return consume(e);
784
+ lastKeyAt = now;
785
+ // escape hatch: Return three times within 1.5 s always resets the module
786
+ if (act === A.BACK) { backTaps = backTaps.filter((t) => now - t < 1500).concat(now); if (backTaps.length >= 3) { backTaps = []; consume(e); return hardReset('3× back'); } }
721
787
 
722
788
  // Back on the home page belongs to the platform: TizenBrew uses it to leave the mod,
723
789
  // so let it bubble instead of swallowing it (the user would be stuck otherwise).
@@ -764,8 +830,8 @@
764
830
  const t0 = Date.now();
765
831
  (function poll() {
766
832
  let v = null; try { v = cond(); } catch (e) { /* ignore */ }
767
- if (v) return ok(v);
768
- if (Date.now() - t0 > timeout) return fail && fail();
833
+ if (v) return safe('waitFor', ok)(v);
834
+ if (Date.now() - t0 > timeout) return fail && safe('waitFor:fail', fail)();
769
835
  setTimeout(poll, 100);
770
836
  })();
771
837
  }
@@ -843,14 +909,21 @@
843
909
  .video-js.tv-fs .vjs-tech { width: 100% !important; height: 100% !important; object-fit: contain; }
844
910
  html.tv-fs-on, html.tv-fs-on body { overflow: hidden !important; }
845
911
  html.tv-fs-on header, html.tv-fs-on footer { display: none !important; }
912
+ #tv-error { position: fixed; left: 16px; bottom: 16px; max-width: 70vw; z-index: 2147483647; margin: 0; white-space: pre-wrap;
913
+ font: 15px/1.35 ui-monospace, monospace; color: #fff; background: rgba(160,0,0,.92); padding: 10px 14px; border-radius: 6px;
914
+ pointer-events: none; opacity: 0; transition: opacity .3s; }
846
915
  #tv-badge { position: fixed; top: 12px; right: 12px; z-index: 2147483647; font: 13px/1.2 ui-monospace, monospace; padding: 6px 10px;
847
916
  background: rgba(0,0,0,.75); color: #fff; border-radius: 6px; pointer-events: none; opacity: 0; transition: opacity .3s; white-space: pre; }
848
917
  ` + (CFG.hideCursor ? '\n html, body, * { cursor: none !important; }' : '');
918
+ // Chromium < 90 ignores `overflow-x: clip`, which would spill every carousel across the
919
+ // page; `hidden` is the closest thing it understands.
920
+ let clipOk = true; try { clipOk = CSS.supports('overflow-x', 'clip'); } catch (e) { clipOk = false; }
921
+ if (!clipOk) st.textContent += '\n .overflow-x-clip { overflow-x: hidden !important; }';
849
922
  document.head.appendChild(st);
850
923
  }
851
924
  let badgeTimer = null;
852
- function badge(extra) {
853
- if (!CFG.debug) return;
925
+ function badge(extra, force) {
926
+ if (!CFG.debug && !force) return;
854
927
  let b = $('#tv-badge');
855
928
  if (!b) { b = document.createElement('div'); b.id = 'tv-badge'; document.body.appendChild(b); }
856
929
  const fs = (S.mode !== 'NAV' && isFs()) ? ' FS' : '';
@@ -863,16 +936,18 @@
863
936
  function init() {
864
937
  if (window.__tvNav && window.__tvNav.destroy) window.__tvNav.destroy(); // hot re-install (dev)
865
938
  injectCSS();
866
- addEventListener('keydown', onKey, true);
939
+ addEventListener('keydown', onKeySafe, true);
867
940
  patchHistory();
868
- addEventListener('tv:route', checkRoute);
869
- const mo = new MutationObserver(() => { clearTimeout(moTimer); moTimer = setTimeout(onMutate, 200); });
941
+ const onRouteSafe = safe('route', checkRoute);
942
+ addEventListener('tv:route', onRouteSafe);
943
+ const onMutateSafe = safe('mutation', onMutate);
944
+ const mo = new MutationObserver(() => { clearTimeout(moTimer); moTimer = setTimeout(onMutateSafe, 200); });
870
945
  mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
871
946
  onRoute();
872
947
  window.__tvNav = {
873
948
  S, CFG, collectBlocks, setFocus, initialFocus, enterPlayer, enterBar, isVisibleBlock, pickTarget, clipAncestor, currentScope,
874
949
  destroy() {
875
- removeEventListener('keydown', onKey, true); removeEventListener('tv:route', checkRoute);
950
+ removeEventListener('keydown', onKeySafe, true); removeEventListener('tv:route', onRouteSafe);
876
951
  mo.disconnect(); clearInterval(keepAlive); clearTimeout(moTimer); clearFocus();
877
952
  const b = $('#tv-badge'); if (b) b.remove(); const st = $('#tv-nav-style'); if (st) st.remove();
878
953
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phimpal-tv",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "D-pad (TV remote) navigation for phimpal.com on Samsung Tizen TVs, as a TizenBrew site-modification module.",
5
5
  "appName": "PhimPal TV",
6
6
  "packageType": "mods",