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.
- package/README.md +14 -0
- package/mod.js +101 -26
- 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.
|
|
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
|
|
70
|
-
return
|
|
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
|
|
76
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
|
327
|
-
|
|
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',
|
|
939
|
+
addEventListener('keydown', onKeySafe, true);
|
|
867
940
|
patchHistory();
|
|
868
|
-
|
|
869
|
-
|
|
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',
|
|
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