phimpal-tv 0.2.0 → 0.2.2

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 +21 -0
  2. package/mod.js +155 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -100,6 +100,27 @@ 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
+ - (0.2.2) a **black box**: the last 40 steps (key, mode, focus, scope, paging…) are written to
117
+ `localStorage['tv-trace']` as they happen. If a session ends in the middle of a key, the
118
+ **next launch** shows the last 12 steps in the same red box for 25 s — so after a freeze,
119
+ relaunch the module and photograph that box.
120
+ - (0.2.2) a navigation scope (dialog/menu) now has to be on screen **and** contain something
121
+ focusable; an element merely carrying `role="dialog"` cannot trap the keys any more, and a
122
+ scope that refuses to close is ignored afterwards.
123
+
103
124
  ## Known limits
104
125
 
105
126
  - 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.2 — 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,45 @@
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); } };
70
+
71
+ // Black box: the last steps are written to localStorage as they happen. If the previous
72
+ // session died mid-key (frozen page, app killed), the next launch shows that tail on screen.
73
+ const TRACE_KEY = 'tv-trace', TRACE_MAX = 40;
74
+ let traceBuf = [];
75
+ function trace(msg) {
76
+ traceBuf.push((Date.now() % 100000) + ' ' + msg);
77
+ if (traceBuf.length > TRACE_MAX) traceBuf.shift();
78
+ try { localStorage.setItem(TRACE_KEY, traceBuf.join('\n')); } catch (e) { /* ignore */ }
79
+ }
80
+ function showPreviousTrace() {
81
+ let prev = null; try { prev = localStorage.getItem(TRACE_KEY); } catch (e) { /* ignore */ }
82
+ if (!prev || !/\bkey \d+ start\b(?![\s\S]*\bkey end\b)/.test(prev)) return; // previous session ended cleanly
83
+ const tail = prev.split('\n').slice(-12).join('\n');
84
+ try {
85
+ let b = $('#tv-error');
86
+ if (!b) { b = document.createElement('pre'); b.id = 'tv-error'; document.body.appendChild(b); }
87
+ b.textContent = 'phimpal-tv — previous session ended mid-key:\n' + tail;
88
+ b.style.opacity = '1';
89
+ clearTimeout(errTimer); errTimer = setTimeout(() => { b.style.opacity = '0'; }, 25000);
90
+ } catch (e) { /* ignore */ }
91
+ }
53
92
  const rectOf = (el) => el.getBoundingClientRect();
54
93
  const isWatch = () => /^\/watch\//.test(location.pathname);
55
94
  const isHome = () => location.pathname === '/';
@@ -65,29 +104,47 @@
65
104
  const EXCL_SELF = 'button[aria-label="Previous"], button[aria-label="Next"], button[aria-label^="Go to page"]';
66
105
  const POPUP = ':scope > .group-hover\\:block';
67
106
 
107
+ // getComputedStyle is the expensive call on a TV CPU; a scan of the page asks for the
108
+ // same ancestors hundreds of times, so results are memoised for the duration of one scan.
109
+ let csCache = null, clipCache = null;
110
+ const cs = (el) => {
111
+ if (!csCache) return getComputedStyle(el);
112
+ let v = csCache.get(el); if (!v) { v = getComputedStyle(el); csCache.set(el, v); }
113
+ return v;
114
+ };
115
+ function withScanCache(fn) {
116
+ const outer = !!csCache;
117
+ if (!outer) { csCache = new Map(); clipCache = new Map(); }
118
+ try { return fn(); } finally { if (!outer) { csCache = null; clipCache = null; } }
119
+ }
68
120
  function styleVisible(el) {
69
- const cs = getComputedStyle(el);
70
- return cs.visibility !== 'hidden' && cs.display !== 'none' && parseFloat(cs.opacity) > 0 && cs.pointerEvents !== 'none';
121
+ const c = cs(el);
122
+ return c.visibility !== 'hidden' && c.display !== 'none' && parseFloat(c.opacity) > 0 && c.pointerEvents !== 'none';
71
123
  }
72
124
  function ancestorsOpaque(el, depth = 4) {
73
125
  let n = el.parentElement;
74
126
  while (n && n !== document.body && depth-- > 0) {
75
- const cs = getComputedStyle(n);
76
- if (cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) return false;
127
+ const c = cs(n);
128
+ if (c.visibility === 'hidden' || parseFloat(c.opacity) === 0) return false;
77
129
  n = n.parentElement;
78
130
  }
79
131
  return true;
80
132
  }
81
133
  // nearest ancestor that clips or scrolls horizontally
82
134
  function clipAncestor(el) {
83
- let n = el.parentElement;
135
+ if (clipCache && clipCache.has(el)) return clipCache.get(el);
136
+ let n = el.parentElement, res = null;
84
137
  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' };
138
+ // the site's rows use `overflow-x: clip`, a value Chromium < 90 (Tizen ≤ 6.5) does not
139
+ // parse recognise the row by its class too, so paging works on older TVs
140
+ if (n.classList.contains('overflow-x-clip')) { res = { el: n, kind: 'clip' }; break; }
141
+ const ox = cs(n).overflowX;
142
+ if (ox === 'clip' || ox === 'hidden') { res = { el: n, kind: 'clip' }; break; }
143
+ if (ox === 'auto' || ox === 'scroll') { res = { el: n, kind: 'scroll' }; break; }
88
144
  n = n.parentElement;
89
145
  }
90
- return null;
146
+ if (clipCache) clipCache.set(el, res);
147
+ return res;
91
148
  }
92
149
  function horizVisible(el, r) {
93
150
  const c = clipAncestor(el);
@@ -152,21 +209,31 @@
152
209
  }
153
210
  const hoverMenuOpen = () => !!S.hoverTrigger && !!currentScope(); // aria-expanded goes stale, the rendered menu doesn't
154
211
 
155
- // dialogs / menus that should capture navigation
212
+ // dialogs / menus that should capture navigation.
213
+ // A scope must be on screen AND contain something to focus: an element that merely
214
+ // carries role="dialog" (a toast, an off-screen drawer, a banner) would otherwise trap
215
+ // every key — arrows find nothing inside it and Back cannot close it. Scopes that refuse
216
+ // to close are blacklisted for the rest of the page.
156
217
  const subPanel = () => $('.vjs-subtitle-panel.is-open');
218
+ const deadScopes = new WeakSet();
157
219
  function currentScope() {
158
220
  const list = $$('.vjs-modal-dialog:not(.vjs-hidden), [role="dialog"], [role="alertdialog"], [role="menu"], [data-scope="menu"][data-part="content"], [data-scope="popover"][data-part="content"], [data-scope="dialog"][data-part="content"]');
159
221
  const dialog = list.find((e) => {
222
+ if (deadScopes.has(e)) return false;
160
223
  if (e.closest('.video-js') && !e.classList.contains('vjs-modal-dialog')) return false;
224
+ if (e.closest('aside')) return false; // mobile drawer, parked off-screen with a transform
161
225
  const r = rectOf(e);
162
- return r.width > 0 && r.height > 0 && styleVisible(e);
226
+ if (!(r.width > 0 && r.height > 0 && styleVisible(e))) return false;
227
+ if (r.right <= 0 || r.bottom <= 0 || r.left >= innerWidth || r.top >= innerHeight) return false; // off-screen
228
+ return $$(CAND, e).some((c) => c !== e && isVisibleBlock(c)); // has something to focus
163
229
  });
164
230
  if (dialog) return dialog; // a caption-settings modal opens on top of the subtitle panel
165
231
  const sp = subPanel();
166
232
  return (sp && rectOf(sp).height > 0) ? sp : null;
167
233
  }
168
234
 
169
- function collectBlocks(scope) {
235
+ function collectBlocks(scope) { return withScanCache(() => collectBlocksRaw(scope)); }
236
+ function collectBlocksRaw(scope) {
170
237
  const root = scope || document;
171
238
  const seen = new Set(), dupes = new Map(), out = [];
172
239
  for (const el of $$(CAND, root)) {
@@ -253,15 +320,19 @@
253
320
  if (!canClick(btn)) return false;
254
321
  const item = block.closest('.shrink-0') || block;
255
322
  const sib = dir === A.RIGHT ? item.nextElementSibling : item.previousElementSibling;
323
+ trace('page click ' + dir);
256
324
  btn.click();
257
- setTimeout(() => {
325
+ trace('page clicked');
326
+ setTimeout(safe('page', () => {
258
327
  let t = sib && (sib.matches('.group') ? sib : sib.querySelector('.group'));
259
328
  if (!(t && isVisibleBlock(t))) {
260
- const vis = $$('.group', car.clip).filter(isVisibleBlock);
329
+ const vis = withScanCache(() => $$('.group', car.clip).filter(isVisibleBlock));
261
330
  t = dir === A.RIGHT ? vis[0] : vis[vis.length - 1];
262
331
  }
263
- if (t) setFocus(t);
264
- }, CFG.carouselAnimMs);
332
+ trace('page settled target=' + describe(t));
333
+ if (t) setFocus(t); else badge('page: no card');
334
+ trace('page done');
335
+ }), CFG.carouselAnimMs);
265
336
  return true;
266
337
  }
267
338
  function rowsOnPage() {
@@ -290,6 +361,7 @@
290
361
  }
291
362
  function setFocus(block, opts = {}) {
292
363
  if (!block) return;
364
+ trace('focus ' + describe(block));
293
365
  if (S.focused !== block) clearFocus();
294
366
  S.focused = block;
295
367
  if (isCard(block)) { block.classList.add('tv-focus'); (popupOf(block) || block).classList.add('tv-focus-ring'); }
@@ -312,8 +384,15 @@
312
384
  function ensureVisible(el) {
313
385
  if (el.closest('header')) return;
314
386
  const r = rectOf(el);
387
+ const c = clipAncestor(el);
388
+ if (c && c.kind === 'clip') {
389
+ // never let the browser scroll a carousel row itself (that would fight its translateX
390
+ // paging); only move the page vertically
391
+ if (r.top < 90 || r.bottom > innerHeight - 30) window.scrollBy({ top: r.top + r.height / 2 - innerHeight / 2 });
392
+ return;
393
+ }
315
394
  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' }); }
395
+ else if (c && c.kind === 'scroll') el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
317
396
  }
318
397
 
319
398
  // per-URL focus memory (survives the full reload of the watch page)
@@ -323,8 +402,10 @@
323
402
  function hrefOf(b) { const a = b.tagName === 'A' ? b : (isCard(b) ? posterOf(b) : null); return a ? a.getAttribute('href') : null; }
324
403
  function remember(block) {
325
404
  const m = loadMem();
326
- const sc = currentScope();
327
- m[memKey()] = { href: hrefOf(block), idx: collectBlocks(sc && sc.contains(block) ? sc : null).indexOf(block) };
405
+ const href = hrefOf(block);
406
+ // the index needs a full page scan only pay for it when there is no href to key on
407
+ const idx = href ? null : (() => { const sc = currentScope(); return collectBlocks(sc && sc.contains(block) ? sc : null).indexOf(block); })();
408
+ m[memKey()] = { href, idx };
328
409
  saveMem(m);
329
410
  }
330
411
  function initialFocus() {
@@ -652,7 +733,8 @@
652
733
  const scope = currentScope();
653
734
  const blocks = collectBlocks(scope);
654
735
  let t = pickTarget(f, dir, blocks);
655
- if (!t && (dir === A.LEFT || dir === A.RIGHT) && pageCarousel(f, dir)) return;
736
+ trace('move ' + dir + ' scope=' + describe(scope) + ' blocks=' + blocks.length + ' target=' + describe(t));
737
+ if (!t && (dir === A.LEFT || dir === A.RIGHT) && pageCarousel(f, dir)) { trace('paging ' + dir); return; }
656
738
  if (!t && dir === A.UP && !scope && scrollY > 0) {
657
739
  window.scrollTo({ top: 0, behavior: 'smooth' });
658
740
  setTimeout(() => { const t2 = pickTarget(S.focused, A.UP, collectBlocks()); if (t2) setFocus(t2); }, 450);
@@ -670,7 +752,9 @@
670
752
  if (scope && scope.classList.contains('vjs-subtitle-panel')) return closeSubPanel();
671
753
  const hoverT = S.hoverTrigger;
672
754
  const finish = () => {
673
- if (currentScope()) return; // still open, stay in scope
755
+ const still = currentScope();
756
+ if (still === scope) { deadScopes.add(scope); trace('scope refused to close, ignoring it'); } // never trap the user
757
+ else if (still) return; // a different dialog is open, stay in it
674
758
  if (S.returnMode) { const m = S.returnMode; S.returnMode = null; return m === 'PLAYER' ? enterPlayer() : enterBar(); }
675
759
  const back = S.beforeScope || hoverT; S.beforeScope = null;
676
760
  if (back && isVisibleBlock(back)) return setFocus(back, { noHover: true }); // don't re-open a hover menu
@@ -712,12 +796,41 @@
712
796
 
713
797
  // ------------------------------------------------------------------ key dispatch
714
798
  function consume(e) { e.preventDefault(); e.stopImmediatePropagation(); }
799
+
800
+ // Whatever happens inside a key handler must never leave the module deaf: every key is
801
+ // consumed before it is handled, so an exception would otherwise swallow all input.
802
+ let lastKeyAt = 0, backTaps = [];
803
+ function hardReset(why) {
804
+ try { clearSeek(); hoverClose(); } catch (e) { /* ignore */ }
805
+ S.sub = null; S.menu = null; S.menuItem = null; S.returnMode = null; S.beforeScope = null; S.seekReturn = null;
806
+ S.focused = null;
807
+ if (isWatch() && player()) enterPlayer(); else { S.mode = 'NAV'; initialFocus(); }
808
+ badge('reset: ' + why, true);
809
+ }
810
+ function onKeySafe(e) {
811
+ const known = TIZEN_KEYS[e.keyCode] || (CFG.keyboardEmulation && KB_KEYS[e.keyCode]);
812
+ if (known) trace('key ' + e.keyCode + ' start mode=' + S.mode + ' sub=' + S.sub + ' focus=' + describe(S.focused));
813
+ try { onKey(e); }
814
+ catch (err) { report('key ' + e.keyCode, err); try { hardReset('error'); } catch (x) { report('reset', x); } }
815
+ if (known) trace('key end mode=' + S.mode + ' focus=' + describe(S.focused));
816
+ }
817
+ function describe(el) {
818
+ if (!el) return 'none';
819
+ const a = el.tagName === 'A' ? el : (el.querySelector && el.querySelector('a[href]'));
820
+ return el.tagName + (el.id ? '#' + el.id : '') + '.' + (el.className + '').trim().split(/\s+/).slice(0, 2).join('.') + (a && a.getAttribute('href') ? '[' + a.getAttribute('href').slice(0, 30) + ']' : '');
821
+ }
715
822
  function onKey(e) {
716
823
  if (e.__tv) return;
717
824
  let act = TIZEN_KEYS[e.keyCode];
718
825
  if (!act && CFG.keyboardEmulation) act = KB_KEYS[e.keyCode];
719
826
  if (!act) return;
720
827
  if (e.repeat && (act === A.OK || act === A.BACK || act === A.PLAY)) return consume(e);
828
+ // a held D-pad fires faster than a TV can re-scan the page: coalesce repeats
829
+ const now = Date.now();
830
+ if (e.repeat && now - lastKeyAt < 120) return consume(e);
831
+ lastKeyAt = now;
832
+ // escape hatch: Return three times within 1.5 s always resets the module
833
+ 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
834
 
722
835
  // Back on the home page belongs to the platform: TizenBrew uses it to leave the mod,
723
836
  // so let it bubble instead of swallowing it (the user would be stuck otherwise).
@@ -744,6 +857,7 @@
744
857
  if (S.mode !== 'NAV') { S.returnMode = S.mode === 'SEEK' ? 'PLAYER' : S.mode; clearSeek(); S.mode = 'NAV'; S.sub = null; S.menu = null; }
745
858
  if (!S.focused || !scope.contains(S.focused)) {
746
859
  const bl = collectBlocks(scope);
860
+ trace('scope grabbed key: ' + describe(scope) + ' blocks=' + bl.length);
747
861
  if (bl.length) { S.beforeScope = S.focused; setFocus(bl[0]); if (act !== A.BACK) return; }
748
862
  }
749
863
  } else if (S.returnMode) {
@@ -764,8 +878,8 @@
764
878
  const t0 = Date.now();
765
879
  (function poll() {
766
880
  let v = null; try { v = cond(); } catch (e) { /* ignore */ }
767
- if (v) return ok(v);
768
- if (Date.now() - t0 > timeout) return fail && fail();
881
+ if (v) return safe('waitFor', ok)(v);
882
+ if (Date.now() - t0 > timeout) return fail && safe('waitFor:fail', fail)();
769
883
  setTimeout(poll, 100);
770
884
  })();
771
885
  }
@@ -843,14 +957,21 @@
843
957
  .video-js.tv-fs .vjs-tech { width: 100% !important; height: 100% !important; object-fit: contain; }
844
958
  html.tv-fs-on, html.tv-fs-on body { overflow: hidden !important; }
845
959
  html.tv-fs-on header, html.tv-fs-on footer { display: none !important; }
960
+ #tv-error { position: fixed; left: 16px; bottom: 16px; max-width: 70vw; z-index: 2147483647; margin: 0; white-space: pre-wrap;
961
+ font: 15px/1.35 ui-monospace, monospace; color: #fff; background: rgba(160,0,0,.92); padding: 10px 14px; border-radius: 6px;
962
+ pointer-events: none; opacity: 0; transition: opacity .3s; }
846
963
  #tv-badge { position: fixed; top: 12px; right: 12px; z-index: 2147483647; font: 13px/1.2 ui-monospace, monospace; padding: 6px 10px;
847
964
  background: rgba(0,0,0,.75); color: #fff; border-radius: 6px; pointer-events: none; opacity: 0; transition: opacity .3s; white-space: pre; }
848
965
  ` + (CFG.hideCursor ? '\n html, body, * { cursor: none !important; }' : '');
966
+ // Chromium < 90 ignores `overflow-x: clip`, which would spill every carousel across the
967
+ // page; `hidden` is the closest thing it understands.
968
+ let clipOk = true; try { clipOk = CSS.supports('overflow-x', 'clip'); } catch (e) { clipOk = false; }
969
+ if (!clipOk) st.textContent += '\n .overflow-x-clip { overflow-x: hidden !important; }';
849
970
  document.head.appendChild(st);
850
971
  }
851
972
  let badgeTimer = null;
852
- function badge(extra) {
853
- if (!CFG.debug) return;
973
+ function badge(extra, force) {
974
+ if (!CFG.debug && !force) return;
854
975
  let b = $('#tv-badge');
855
976
  if (!b) { b = document.createElement('div'); b.id = 'tv-badge'; document.body.appendChild(b); }
856
977
  const fs = (S.mode !== 'NAV' && isFs()) ? ' FS' : '';
@@ -863,16 +984,20 @@
863
984
  function init() {
864
985
  if (window.__tvNav && window.__tvNav.destroy) window.__tvNav.destroy(); // hot re-install (dev)
865
986
  injectCSS();
866
- addEventListener('keydown', onKey, true);
987
+ showPreviousTrace();
988
+ trace('init ' + location.pathname + ' ' + innerWidth + 'x' + innerHeight);
989
+ addEventListener('keydown', onKeySafe, true);
867
990
  patchHistory();
868
- addEventListener('tv:route', checkRoute);
869
- const mo = new MutationObserver(() => { clearTimeout(moTimer); moTimer = setTimeout(onMutate, 200); });
991
+ const onRouteSafe = safe('route', checkRoute);
992
+ addEventListener('tv:route', onRouteSafe);
993
+ const onMutateSafe = safe('mutation', onMutate);
994
+ const mo = new MutationObserver(() => { clearTimeout(moTimer); moTimer = setTimeout(onMutateSafe, 200); });
870
995
  mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
871
996
  onRoute();
872
997
  window.__tvNav = {
873
998
  S, CFG, collectBlocks, setFocus, initialFocus, enterPlayer, enterBar, isVisibleBlock, pickTarget, clipAncestor, currentScope,
874
999
  destroy() {
875
- removeEventListener('keydown', onKey, true); removeEventListener('tv:route', checkRoute);
1000
+ removeEventListener('keydown', onKeySafe, true); removeEventListener('tv:route', onRouteSafe);
876
1001
  mo.disconnect(); clearInterval(keepAlive); clearTimeout(moTimer); clearFocus();
877
1002
  const b = $('#tv-badge'); if (b) b.remove(); const st = $('#tv-nav-style'); if (st) st.remove();
878
1003
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phimpal-tv",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",