phimpal-tv 0.2.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.
- package/README.md +113 -0
- package/SPECS.md +342 -0
- package/mod.js +882 -0
- package/package.json +26 -0
package/mod.js
ADDED
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
// PhimPal TV v0.2.0 — generated by build.js from phimpal-tv.user.js, do not edit.
|
|
2
|
+
// TizenBrew site-modification module for https://phimpal.com/
|
|
3
|
+
(function registerRemoteKeys() {
|
|
4
|
+
try {
|
|
5
|
+
var api = window.tizen && window.tizen.tvinputdevice;
|
|
6
|
+
if (!api || !api.registerKey) return;
|
|
7
|
+
["MediaPlayPause","MediaPlay","MediaPause","ChannelUp","ChannelDown"].forEach(function (k) { try { api.registerKey(k); } catch (e) { /* unsupported key */ } });
|
|
8
|
+
} catch (e) { /* not running on Tizen */ }
|
|
9
|
+
})();
|
|
10
|
+
|
|
11
|
+
(function () {
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
// ------------------------------------------------------------------ config
|
|
15
|
+
const CFG = {
|
|
16
|
+
keyboardEmulation: false, // remote key codes only
|
|
17
|
+
debug: (function () { try { return localStorage.getItem('tv-debug') === '1'; } catch (e) { return false; } })(), // badge off unless asked
|
|
18
|
+
hideCursor: true, // the D-pad replaces the pointer
|
|
19
|
+
seekSteps: [10, 30, 60, 300], // seconds per press, accelerates while you keep pressing
|
|
20
|
+
seekAccelWindow: 800, // ms between presses to accelerate
|
|
21
|
+
chanSeek: 300, // Channel up/down step inside seek mode, seconds
|
|
22
|
+
badgeMs: 2000,
|
|
23
|
+
carouselAnimMs: 560, // site uses duration-500 on the track
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ------------------------------------------------------------------ keys
|
|
27
|
+
const A = { LEFT: 'left', RIGHT: 'right', UP: 'up', DOWN: 'down', OK: 'ok', BACK: 'back', PLAY: 'play', CHUP: 'chup', CHDOWN: 'chdown' };
|
|
28
|
+
const TIZEN_KEYS = { 37: A.LEFT, 38: A.UP, 39: A.RIGHT, 40: A.DOWN, 13: A.OK, 10009: A.BACK, 10252: A.PLAY, 427: A.CHUP, 428: A.CHDOWN };
|
|
29
|
+
const KB_KEYS = { 27: A.BACK, 8: A.BACK, 32: A.PLAY, 33: A.CHUP, 34: A.CHDOWN };
|
|
30
|
+
|
|
31
|
+
// ------------------------------------------------------------------ state
|
|
32
|
+
const S = {
|
|
33
|
+
mode: 'NAV', // NAV | PLAYER | BAR
|
|
34
|
+
focused: null, // focused block element
|
|
35
|
+
sub: null, // 'xem' (card popup play button) | 'menu' (vjs menu open)
|
|
36
|
+
menu: null, menuBtn: null, menuItem: null,
|
|
37
|
+
returnMode: null, // mode to restore when a dialog/menu scope closes
|
|
38
|
+
beforeScope: null,
|
|
39
|
+
hoverTrigger: null, // [aria-haspopup] element whose hover-menu we opened synthetically
|
|
40
|
+
rowMem: new WeakMap(),
|
|
41
|
+
seek: { t: 0, dir: 0, lvl: 0 },
|
|
42
|
+
seekTo: null, // pending target (seconds) while scrubbing; committed with OK
|
|
43
|
+
seekReturn: null, // mode to go back to when scrubbing ends
|
|
44
|
+
seekWasPlaying: false,
|
|
45
|
+
url: location.href,
|
|
46
|
+
lastKey: null,
|
|
47
|
+
fsExitAt: 0,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ------------------------------------------------------------------ dom helpers
|
|
51
|
+
const $ = (s, r = document) => r.querySelector(s);
|
|
52
|
+
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
|
|
53
|
+
const rectOf = (el) => el.getBoundingClientRect();
|
|
54
|
+
const isWatch = () => /^\/watch\//.test(location.pathname);
|
|
55
|
+
const isHome = () => location.pathname === '/';
|
|
56
|
+
const player = () => { const v = $('.video-js'); return v && v.player ? v.player : null; };
|
|
57
|
+
const editingEl = () => { const a = document.activeElement; return a && /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName) ? a : null; };
|
|
58
|
+
|
|
59
|
+
// The subtitle panel is built from plain <div>s with no ARIA, so its parts are listed explicitly.
|
|
60
|
+
// (.vjs-sp-shift-btn, the per-track timing button, is left out: it sits inside a track item
|
|
61
|
+
// and would create a one-way trap for horizontal moves.)
|
|
62
|
+
const SP_CAND = '.vjs-sp-tab, .vjs-sp-track-item, .vjs-sp-caption-btn, .vjs-sp-topbottom-label';
|
|
63
|
+
const CAND = 'a[href], button, select, input:not([type=hidden]), textarea, [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"], [tabindex]:not([tabindex="-1"]), .video-js, ' + SP_CAND;
|
|
64
|
+
const EXCL_ANCESTOR = 'aside, .vjs-control-bar, .vjs-big-play-button, .vjs-hidden, .vjs-menu, .vjs-modal-dialog, .vjs-subtitle-panel, .vjs-text-track-display, [aria-hidden="true"], #tv-badge';
|
|
65
|
+
const EXCL_SELF = 'button[aria-label="Previous"], button[aria-label="Next"], button[aria-label^="Go to page"]';
|
|
66
|
+
const POPUP = ':scope > .group-hover\\:block';
|
|
67
|
+
|
|
68
|
+
function styleVisible(el) {
|
|
69
|
+
const cs = getComputedStyle(el);
|
|
70
|
+
return cs.visibility !== 'hidden' && cs.display !== 'none' && parseFloat(cs.opacity) > 0 && cs.pointerEvents !== 'none';
|
|
71
|
+
}
|
|
72
|
+
function ancestorsOpaque(el, depth = 4) {
|
|
73
|
+
let n = el.parentElement;
|
|
74
|
+
while (n && n !== document.body && depth-- > 0) {
|
|
75
|
+
const cs = getComputedStyle(n);
|
|
76
|
+
if (cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) return false;
|
|
77
|
+
n = n.parentElement;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
// nearest ancestor that clips or scrolls horizontally
|
|
82
|
+
function clipAncestor(el) {
|
|
83
|
+
let n = el.parentElement;
|
|
84
|
+
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' };
|
|
88
|
+
n = n.parentElement;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
function horizVisible(el, r) {
|
|
93
|
+
const c = clipAncestor(el);
|
|
94
|
+
if (c && c.kind === 'scroll') return true; // reachable by scrolling
|
|
95
|
+
const box = c ? rectOf(c.el) : { left: 0, right: innerWidth };
|
|
96
|
+
const vis = Math.min(r.right, box.right) - Math.max(r.left, box.left);
|
|
97
|
+
return vis >= r.width * 0.7; // partially clipped carousel items are not targets
|
|
98
|
+
}
|
|
99
|
+
function isVisibleBlock(el) {
|
|
100
|
+
if (!el || !el.isConnected) return false;
|
|
101
|
+
const r = rectOf(el);
|
|
102
|
+
if (r.width < 4 || r.height < 4) return false;
|
|
103
|
+
if (!styleVisible(el) || !ancestorsOpaque(el)) return false;
|
|
104
|
+
if (!horizVisible(el, r)) return false;
|
|
105
|
+
const h = el.closest('header');
|
|
106
|
+
if (h && rectOf(h).bottom <= 0) return false; // header slid away
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const popupOf = (g) => g.querySelector(POPUP);
|
|
111
|
+
const xemOf = (g) => { const p = popupOf(g); return p && p.querySelector('a[href^="/api/play/"]'); };
|
|
112
|
+
const posterOf = (g) => g.querySelector('a[href^="/title/"]') || g.querySelector('a[href]');
|
|
113
|
+
const isCard = (b) => !!b && b.classList.contains('group') && !!popupOf(b);
|
|
114
|
+
const isPlayerEl = (b) => !!b && b.classList.contains('video-js');
|
|
115
|
+
|
|
116
|
+
function blockOf(el) {
|
|
117
|
+
if (el.classList.contains('video-js')) return el;
|
|
118
|
+
const g = el.closest('.group');
|
|
119
|
+
if (g && popupOf(g)) return g;
|
|
120
|
+
return el;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// hover-opened menus (header avatar): the site opens them on mouseenter and
|
|
124
|
+
// closes them on mouseleave of the trigger or of the menu. We fake the mouse.
|
|
125
|
+
const HOVER_TRIGGER = 'button[aria-haspopup="menu"]:not([class*="vjs-"])';
|
|
126
|
+
const isHoverTrigger = (el) => !!el && el.matches && el.matches(HOVER_TRIGGER);
|
|
127
|
+
function mouse(el, type) { if (el) el.dispatchEvent(new MouseEvent(type, { bubbles: type !== 'mouseenter' && type !== 'mouseleave', cancelable: true })); }
|
|
128
|
+
// The synthetic mouseenter mounts the menu, but the site fades it in only while the
|
|
129
|
+
// element really matches :hover — which never happens with a remote. So we mount it
|
|
130
|
+
// and make it visible ourselves.
|
|
131
|
+
const HOVER_MENU = '[role="menu"], [data-scope="menu"][data-part="content"], [data-scope="popover"][data-part="content"]';
|
|
132
|
+
function hoverMenuEl() {
|
|
133
|
+
return $$(HOVER_MENU).find((e) => !e.closest('.video-js') && rectOf(e).height > 0) || null;
|
|
134
|
+
}
|
|
135
|
+
function hoverOpen(trigger) {
|
|
136
|
+
if (S.hoverTrigger && S.hoverTrigger !== trigger) hoverClose();
|
|
137
|
+
S.hoverTrigger = trigger;
|
|
138
|
+
mouse(trigger, 'pointerenter'); mouse(trigger, 'mouseenter'); mouse(trigger, 'pointerover'); mouse(trigger, 'mouseover');
|
|
139
|
+
waitFor(hoverMenuEl, 1000, (el) => {
|
|
140
|
+
if (S.hoverTrigger !== trigger) return;
|
|
141
|
+
el.classList.add('tv-force-visible');
|
|
142
|
+
badge('menu');
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function hoverClose() {
|
|
146
|
+
const t = S.hoverTrigger; if (!t) return;
|
|
147
|
+
S.hoverTrigger = null;
|
|
148
|
+
const menu = hoverMenuEl();
|
|
149
|
+
$$('.tv-force-visible').forEach((e) => e.classList.remove('tv-force-visible'));
|
|
150
|
+
if (menu) { mouse(menu, 'mouseleave'); mouse(menu, 'mouseout'); }
|
|
151
|
+
mouse(t, 'mouseleave'); mouse(t, 'mouseout');
|
|
152
|
+
}
|
|
153
|
+
const hoverMenuOpen = () => !!S.hoverTrigger && !!currentScope(); // aria-expanded goes stale, the rendered menu doesn't
|
|
154
|
+
|
|
155
|
+
// dialogs / menus that should capture navigation
|
|
156
|
+
const subPanel = () => $('.vjs-subtitle-panel.is-open');
|
|
157
|
+
function currentScope() {
|
|
158
|
+
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
|
+
const dialog = list.find((e) => {
|
|
160
|
+
if (e.closest('.video-js') && !e.classList.contains('vjs-modal-dialog')) return false;
|
|
161
|
+
const r = rectOf(e);
|
|
162
|
+
return r.width > 0 && r.height > 0 && styleVisible(e);
|
|
163
|
+
});
|
|
164
|
+
if (dialog) return dialog; // a caption-settings modal opens on top of the subtitle panel
|
|
165
|
+
const sp = subPanel();
|
|
166
|
+
return (sp && rectOf(sp).height > 0) ? sp : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function collectBlocks(scope) {
|
|
170
|
+
const root = scope || document;
|
|
171
|
+
const seen = new Set(), dupes = new Map(), out = [];
|
|
172
|
+
for (const el of $$(CAND, root)) {
|
|
173
|
+
if (!scope && el.closest(EXCL_ANCESTOR)) continue;
|
|
174
|
+
if (scope && el.closest('.vjs-hidden, #tv-badge')) continue;
|
|
175
|
+
if (el.matches(EXCL_SELF)) continue;
|
|
176
|
+
if (!isVisibleBlock(el)) continue;
|
|
177
|
+
const b = blockOf(el);
|
|
178
|
+
if (seen.has(b)) continue;
|
|
179
|
+
if (b.tagName === 'BUTTON' && b.querySelector('a[href]')) continue; // wrapper button around links
|
|
180
|
+
if (b.tagName === 'A') { // same link twice in one container (image + caption)
|
|
181
|
+
const c = b.closest('.group, button, li, .shrink-0, article') || b.parentElement;
|
|
182
|
+
let set = dupes.get(c); if (!set) dupes.set(c, (set = new Set()));
|
|
183
|
+
const href = b.getAttribute('href');
|
|
184
|
+
if (set.has(href)) continue;
|
|
185
|
+
set.add(href);
|
|
186
|
+
}
|
|
187
|
+
seen.add(b); out.push(b);
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ------------------------------------------------------------------ spatial navigation
|
|
193
|
+
// The fixed header floats over the content: only consider it when nothing else
|
|
194
|
+
// is available in that direction (otherwise "up" from mid-page would always hit it).
|
|
195
|
+
function pickTarget(from, dir, blocks) {
|
|
196
|
+
const inHeader = !!from.closest('header');
|
|
197
|
+
if (!inHeader) {
|
|
198
|
+
const t = pickAmong(from, dir, blocks.filter((b) => !b.closest('header')));
|
|
199
|
+
if (t) return t;
|
|
200
|
+
}
|
|
201
|
+
return pickAmong(from, dir, blocks);
|
|
202
|
+
}
|
|
203
|
+
function pickAmong(from, dir, blocks) {
|
|
204
|
+
const F = rectOf(from), fcx = (F.left + F.right) / 2, fcy = (F.top + F.bottom) / 2;
|
|
205
|
+
const horiz = dir === A.LEFT || dir === A.RIGHT;
|
|
206
|
+
const fromClip = horiz ? clipAncestor(from) : null;
|
|
207
|
+
let best = null, bs = Infinity;
|
|
208
|
+
for (const b of blocks) {
|
|
209
|
+
if (b === from) continue;
|
|
210
|
+
const C = rectOf(b), cx = (C.left + C.right) / 2, cy = (C.top + C.bottom) / 2;
|
|
211
|
+
let ok = false, primary = 0, secondary = 0;
|
|
212
|
+
switch (dir) {
|
|
213
|
+
case A.RIGHT: ok = C.left >= fcx && cx > fcx; primary = Math.max(0, C.left - F.right); secondary = Math.abs(cy - fcy); break;
|
|
214
|
+
case A.LEFT: ok = C.right <= fcx && cx < fcx; primary = Math.max(0, F.left - C.right); secondary = Math.abs(cy - fcy); break;
|
|
215
|
+
case A.DOWN: ok = C.top >= fcy && cy > fcy; primary = Math.max(0, C.top - F.bottom); secondary = xGap(F, C); break;
|
|
216
|
+
case A.UP: ok = C.bottom <= fcy && cy < fcy; primary = Math.max(0, F.top - C.bottom); secondary = xGap(F, C); break;
|
|
217
|
+
}
|
|
218
|
+
if (!ok) continue;
|
|
219
|
+
if (horiz) {
|
|
220
|
+
if (fromClip && !fromClip.el.contains(b)) continue; // stay inside the row
|
|
221
|
+
if (Math.min(F.bottom, C.bottom) - Math.max(F.top, C.top) <= 0) continue; // same line only
|
|
222
|
+
const score = primary + 2 * secondary;
|
|
223
|
+
if (score < bs) { bs = score; best = b; }
|
|
224
|
+
} else {
|
|
225
|
+
const score = primary + secondary;
|
|
226
|
+
if (score < bs) { bs = score; best = b; }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return best;
|
|
230
|
+
}
|
|
231
|
+
function xGap(F, C) {
|
|
232
|
+
const overlap = Math.min(F.right, C.right) - Math.max(F.left, C.left);
|
|
233
|
+
const dc = Math.abs((C.left + C.right) / 2 - (F.left + F.right) / 2);
|
|
234
|
+
if (overlap > 0) return dc * 0.25;
|
|
235
|
+
return Math.max(F.left - C.right, C.left - F.right) + F.width * 0.25 + dc * 0.25;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// carousel paging (overflow-x: clip + translateX track + Prev/Next buttons)
|
|
239
|
+
function carouselOf(block) {
|
|
240
|
+
const c = clipAncestor(block);
|
|
241
|
+
if (!c || c.kind !== 'clip') return null;
|
|
242
|
+
// arrows live inside the clip element itself (.overflow-x-clip > .relative > [track, arrows]);
|
|
243
|
+
// all rows share one .container, so never search above the clip
|
|
244
|
+
const next = $('button[aria-label="Next"]', c.el), prev = $('button[aria-label="Previous"]', c.el);
|
|
245
|
+
if (!next && !prev) return null;
|
|
246
|
+
return { clip: c.el, next, prev };
|
|
247
|
+
}
|
|
248
|
+
const canClick = (b) => !!b && !b.disabled && rectOf(b).width > 0 && parseFloat(getComputedStyle(b).opacity) > 0;
|
|
249
|
+
function pageCarousel(block, dir) {
|
|
250
|
+
const car = carouselOf(block);
|
|
251
|
+
if (!car) return false;
|
|
252
|
+
const btn = dir === A.RIGHT ? car.next : car.prev;
|
|
253
|
+
if (!canClick(btn)) return false;
|
|
254
|
+
const item = block.closest('.shrink-0') || block;
|
|
255
|
+
const sib = dir === A.RIGHT ? item.nextElementSibling : item.previousElementSibling;
|
|
256
|
+
btn.click();
|
|
257
|
+
setTimeout(() => {
|
|
258
|
+
let t = sib && (sib.matches('.group') ? sib : sib.querySelector('.group'));
|
|
259
|
+
if (!(t && isVisibleBlock(t))) {
|
|
260
|
+
const vis = $$('.group', car.clip).filter(isVisibleBlock);
|
|
261
|
+
t = dir === A.RIGHT ? vis[0] : vis[vis.length - 1];
|
|
262
|
+
}
|
|
263
|
+
if (t) setFocus(t);
|
|
264
|
+
}, CFG.carouselAnimMs);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
function rowsOnPage() {
|
|
268
|
+
return $$('.overflow-x-clip, [class*="overflow-x-auto"], [class*="overflow-x-scroll"]')
|
|
269
|
+
.filter((r) => $('.group, a[href]', r) && rectOf(r).height > 0)
|
|
270
|
+
.sort((a, b) => rectOf(a).top - rectOf(b).top);
|
|
271
|
+
}
|
|
272
|
+
function jumpRow(dir) {
|
|
273
|
+
const rows = rowsOnPage(); if (!rows.length) return;
|
|
274
|
+
const cur = S.focused && clipAncestor(S.focused);
|
|
275
|
+
let i = cur ? rows.indexOf(cur.el) : -1;
|
|
276
|
+
if (i < 0) { const y = S.focused ? rectOf(S.focused).top : 0; i = rows.findIndex((r) => rectOf(r).top > y); if (i < 0) i = rows.length; if (dir === A.UP) i--; else i--; }
|
|
277
|
+
i += dir === A.DOWN ? 1 : -1;
|
|
278
|
+
const row = rows[i]; if (!row) return;
|
|
279
|
+
const mem = S.rowMem.get(row);
|
|
280
|
+
const t = (mem && isVisibleBlock(mem)) ? mem : collectBlocks().find((b) => row.contains(b));
|
|
281
|
+
if (t) setFocus(t);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ------------------------------------------------------------------ focus rendering
|
|
285
|
+
function clearFocus() {
|
|
286
|
+
const b = S.focused; if (!b) return;
|
|
287
|
+
b.classList.remove('tv-focus', 'tv-focus-ring', 'tv-sub-active');
|
|
288
|
+
$$('.tv-focus-ring, .tv-sub-focus', b).forEach((e) => e.classList.remove('tv-focus-ring', 'tv-sub-focus'));
|
|
289
|
+
S.sub = null;
|
|
290
|
+
}
|
|
291
|
+
function setFocus(block, opts = {}) {
|
|
292
|
+
if (!block) return;
|
|
293
|
+
if (S.focused !== block) clearFocus();
|
|
294
|
+
S.focused = block;
|
|
295
|
+
if (isCard(block)) { block.classList.add('tv-focus'); (popupOf(block) || block).classList.add('tv-focus-ring'); }
|
|
296
|
+
else block.classList.add('tv-focus-ring');
|
|
297
|
+
const c = clipAncestor(block); if (c) S.rowMem.set(c.el, block);
|
|
298
|
+
if (isHoverTrigger(block)) { if (!opts.noHover) hoverOpen(block); }
|
|
299
|
+
else if (S.hoverTrigger) { const sc = currentScope(); if (!sc || !sc.contains(block)) hoverClose(); }
|
|
300
|
+
if (!isWatch()) remember(block);
|
|
301
|
+
if (!opts.noScroll) ensureVisible(block);
|
|
302
|
+
badge();
|
|
303
|
+
}
|
|
304
|
+
function setSub(s) {
|
|
305
|
+
const f = S.focused; if (!f) return;
|
|
306
|
+
$$('.tv-sub-focus', f).forEach((e) => e.classList.remove('tv-sub-focus'));
|
|
307
|
+
S.sub = s;
|
|
308
|
+
if (s === 'xem') { const x = xemOf(f); if (x) { x.classList.add('tv-sub-focus'); f.classList.add('tv-sub-active'); } }
|
|
309
|
+
else f.classList.remove('tv-sub-active');
|
|
310
|
+
badge();
|
|
311
|
+
}
|
|
312
|
+
function ensureVisible(el) {
|
|
313
|
+
if (el.closest('header')) return;
|
|
314
|
+
const r = rectOf(el);
|
|
315
|
+
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' }); }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// per-URL focus memory (survives the full reload of the watch page)
|
|
320
|
+
const memKey = () => location.pathname + location.search;
|
|
321
|
+
function loadMem() { try { return JSON.parse(sessionStorage.getItem('tv-nav-mem') || '{}'); } catch (e) { return {}; } }
|
|
322
|
+
function saveMem(m) { try { sessionStorage.setItem('tv-nav-mem', JSON.stringify(m)); } catch (e) { /* ignore */ } }
|
|
323
|
+
function hrefOf(b) { const a = b.tagName === 'A' ? b : (isCard(b) ? posterOf(b) : null); return a ? a.getAttribute('href') : null; }
|
|
324
|
+
function remember(block) {
|
|
325
|
+
const m = loadMem();
|
|
326
|
+
const sc = currentScope();
|
|
327
|
+
m[memKey()] = { href: hrefOf(block), idx: collectBlocks(sc && sc.contains(block) ? sc : null).indexOf(block) };
|
|
328
|
+
saveMem(m);
|
|
329
|
+
}
|
|
330
|
+
function initialFocus() {
|
|
331
|
+
const scope = currentScope();
|
|
332
|
+
const blocks = collectBlocks(scope);
|
|
333
|
+
if (!blocks.length) return false;
|
|
334
|
+
let t = null;
|
|
335
|
+
if (!scope) {
|
|
336
|
+
const m = loadMem()[memKey()];
|
|
337
|
+
if (m) {
|
|
338
|
+
if (m.href) t = blocks.find((b) => hrefOf(b) === m.href);
|
|
339
|
+
if (!t && m.idx != null && blocks[m.idx]) t = blocks[m.idx];
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!t && !scope) t = blocks.find((b) => b.tagName === 'A' && /^\/api\/play\//.test(b.getAttribute('href') || '')); // title page: XEM PHIM
|
|
343
|
+
if (!t) t = blocks.find(isCard) || blocks.find((b) => !b.closest('header')) || blocks[0];
|
|
344
|
+
setFocus(t);
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ------------------------------------------------------------------ player
|
|
349
|
+
function enterPlayer() {
|
|
350
|
+
const p = player(); if (!p) return;
|
|
351
|
+
if (S.seekTo != null) clearSeek();
|
|
352
|
+
S.mode = 'PLAYER'; S.sub = null; S.menu = null;
|
|
353
|
+
setFocus(p.el(), { noScroll: true });
|
|
354
|
+
p.userActive(true);
|
|
355
|
+
if (!isFs()) window.scrollTo({ top: 0 });
|
|
356
|
+
badge();
|
|
357
|
+
}
|
|
358
|
+
function togglePlay() { const p = player(); if (!p) return; p.userActive(true); p.paused() ? p.play() : p.pause(); }
|
|
359
|
+
|
|
360
|
+
// Fullscreen: the real API when the webview allows it, otherwise a CSS stand-in
|
|
361
|
+
// (some Tizen builds refuse requestFullscreen outside a trusted gesture).
|
|
362
|
+
const fakeFs = () => { const p = player(); return !!p && p.el().classList.contains('tv-fs'); };
|
|
363
|
+
const isFs = () => { const p = player(); return !!p && (p.isFullscreen() || fakeFs()); };
|
|
364
|
+
function setFakeFs(on) {
|
|
365
|
+
const p = player(); if (!p) return;
|
|
366
|
+
p.el().classList.toggle('tv-fs', on);
|
|
367
|
+
document.documentElement.classList.toggle('tv-fs-on', on);
|
|
368
|
+
if (on) window.scrollTo({ top: 0 });
|
|
369
|
+
badge(on ? 'fullscreen' : 'windowed');
|
|
370
|
+
}
|
|
371
|
+
function goFullscreen() {
|
|
372
|
+
const p = player(); if (!p) return;
|
|
373
|
+
try {
|
|
374
|
+
const r = p.requestFullscreen();
|
|
375
|
+
if (r && r.catch) r.catch(() => setFakeFs(true));
|
|
376
|
+
setTimeout(() => { if (!p.isFullscreen() && !fakeFs()) setFakeFs(true); }, 400);
|
|
377
|
+
} catch (e) { setFakeFs(true); }
|
|
378
|
+
}
|
|
379
|
+
function leaveFullscreen() {
|
|
380
|
+
const p = player(); if (!p) return;
|
|
381
|
+
if (fakeFs()) setFakeFs(false);
|
|
382
|
+
if (p.isFullscreen()) { try { p.exitFullscreen(); } catch (e) { /* ignore */ } }
|
|
383
|
+
}
|
|
384
|
+
function seekBy(d) {
|
|
385
|
+
const p = player(); if (!p) return; p.userActive(true);
|
|
386
|
+
const dur = p.duration() || Infinity;
|
|
387
|
+
p.currentTime(Math.max(0, Math.min(dur, p.currentTime() + d)));
|
|
388
|
+
}
|
|
389
|
+
// ---------------------------------------------------------------- seek (scrub) mode
|
|
390
|
+
// The stream is slow to re-buffer, so arrows never seek directly: they move a preview
|
|
391
|
+
// cursor along the progress bar (driven by the site's own mouse-hover thumbnail) and
|
|
392
|
+
// only OK commits the jump.
|
|
393
|
+
function progressEls() {
|
|
394
|
+
const p = player(); if (!p) return null;
|
|
395
|
+
const control = $('.vjs-progress-control', p.el()), holder = $('.vjs-progress-holder', p.el());
|
|
396
|
+
return (control && holder && rectOf(holder).width > 0) ? { control, holder } : null;
|
|
397
|
+
}
|
|
398
|
+
function fmtTime(t) {
|
|
399
|
+
t = Math.max(0, Math.round(t));
|
|
400
|
+
const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60;
|
|
401
|
+
const pad = (n) => (n < 10 ? '0' + n : '' + n);
|
|
402
|
+
return (h ? h + ':' + pad(m) : '' + m) + ':' + pad(s);
|
|
403
|
+
}
|
|
404
|
+
function hoverAt(ratio) { // makes the site show its thumbnail preview at that position
|
|
405
|
+
const e = progressEls(); if (!e) return;
|
|
406
|
+
const r = rectOf(e.holder);
|
|
407
|
+
const x = r.left + Math.max(0, Math.min(1, ratio)) * r.width, y = r.top + r.height / 2;
|
|
408
|
+
for (const type of ['mouseover', 'mousemove']) {
|
|
409
|
+
e.control.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, view: window }));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function hoverOff() {
|
|
413
|
+
const e = progressEls(); if (!e) return;
|
|
414
|
+
e.control.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, cancelable: true, relatedTarget: document.body }));
|
|
415
|
+
e.control.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }));
|
|
416
|
+
}
|
|
417
|
+
function drawSeek() {
|
|
418
|
+
const p = player(), e = progressEls(); if (!p || !e) return;
|
|
419
|
+
const dur = p.duration() || 0; if (!dur) return;
|
|
420
|
+
const ratio = S.seekTo / dur;
|
|
421
|
+
hoverAt(ratio);
|
|
422
|
+
let box = $('#tv-seek', e.holder);
|
|
423
|
+
if (!box) { box = document.createElement('div'); box.id = 'tv-seek'; box.innerHTML = '<i></i><b></b>'; e.holder.appendChild(box); }
|
|
424
|
+
box.style.left = (ratio * 100) + '%';
|
|
425
|
+
const delta = Math.round(S.seekTo - p.currentTime());
|
|
426
|
+
$('b', box).textContent = fmtTime(S.seekTo) + (delta ? ' ' + (delta > 0 ? '+' : '−') + fmtTime(Math.abs(delta)) : '');
|
|
427
|
+
p.el().classList.add('tv-seeking');
|
|
428
|
+
p.userActive(true);
|
|
429
|
+
}
|
|
430
|
+
function clearSeek() {
|
|
431
|
+
const p = player();
|
|
432
|
+
const box = $('#tv-seek'); if (box) box.remove();
|
|
433
|
+
if (p) p.el().classList.remove('tv-seeking');
|
|
434
|
+
hoverOff();
|
|
435
|
+
S.seekTo = null; S.seek.lvl = 0; S.seek.dir = 0;
|
|
436
|
+
}
|
|
437
|
+
function stepSeek(dir, fixed) {
|
|
438
|
+
const p = player(); if (!p) return;
|
|
439
|
+
const dur = p.duration();
|
|
440
|
+
if (!dur || !isFinite(dur)) return seekBy(dir * CFG.seekSteps[0]); // live/unknown length: old behaviour
|
|
441
|
+
if (S.seekTo == null) { // entering seek mode
|
|
442
|
+
S.seekReturn = S.mode === 'BAR' ? 'BAR' : 'PLAYER';
|
|
443
|
+
S.seekWasPlaying = !p.paused();
|
|
444
|
+
S.seekTo = p.currentTime();
|
|
445
|
+
S.mode = 'SEEK';
|
|
446
|
+
}
|
|
447
|
+
let stepped;
|
|
448
|
+
if (fixed) stepped = fixed;
|
|
449
|
+
else {
|
|
450
|
+
const now = Date.now();
|
|
451
|
+
if (S.seek.dir === dir && now - S.seek.t < CFG.seekAccelWindow) S.seek.lvl = Math.min(S.seek.lvl + 1, CFG.seekSteps.length - 1);
|
|
452
|
+
else S.seek.lvl = 0;
|
|
453
|
+
S.seek.dir = dir; S.seek.t = now;
|
|
454
|
+
stepped = CFG.seekSteps[S.seek.lvl];
|
|
455
|
+
}
|
|
456
|
+
S.seekTo = Math.max(0, Math.min(dur, S.seekTo + dir * stepped));
|
|
457
|
+
drawSeek();
|
|
458
|
+
badge('seek ' + fmtTime(S.seekTo));
|
|
459
|
+
}
|
|
460
|
+
function commitSeek() {
|
|
461
|
+
const p = player(); const to = S.seekTo, back = S.seekReturn, play = S.seekWasPlaying;
|
|
462
|
+
clearSeek(); S.seekReturn = null;
|
|
463
|
+
if (p && to != null) { p.currentTime(to); if (play && p.paused()) p.play(); }
|
|
464
|
+
badge('→ ' + fmtTime(to || 0));
|
|
465
|
+
return back === 'BAR' ? enterBar() : enterPlayer();
|
|
466
|
+
}
|
|
467
|
+
function cancelSeek(to) {
|
|
468
|
+
const back = to || S.seekReturn; clearSeek(); S.seekReturn = null;
|
|
469
|
+
return back === 'BAR' ? enterBar() : enterPlayer();
|
|
470
|
+
}
|
|
471
|
+
function keySeek(act) {
|
|
472
|
+
const p = player(); if (!p) { S.mode = 'NAV'; clearSeek(); return initialFocus(); }
|
|
473
|
+
p.userActive(true);
|
|
474
|
+
switch (act) {
|
|
475
|
+
case A.LEFT: return stepSeek(-1);
|
|
476
|
+
case A.RIGHT: return stepSeek(+1);
|
|
477
|
+
case A.CHDOWN: return stepSeek(-1, CFG.chanSeek);
|
|
478
|
+
case A.CHUP: return stepSeek(+1, CFG.chanSeek);
|
|
479
|
+
case A.OK: return commitSeek();
|
|
480
|
+
case A.BACK: case A.UP: return cancelSeek('PLAYER');
|
|
481
|
+
case A.DOWN: return cancelSeek('BAR');
|
|
482
|
+
case A.PLAY: return togglePlay();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function keyPlayer(act) {
|
|
486
|
+
const p = player(); if (!p) { S.mode = 'NAV'; return initialFocus(); }
|
|
487
|
+
p.userActive(true);
|
|
488
|
+
switch (act) {
|
|
489
|
+
case A.LEFT: return stepSeek(-1);
|
|
490
|
+
case A.RIGHT: return stepSeek(+1);
|
|
491
|
+
case A.UP: { // windowed: leave the player upwards (header links); fullscreen: nothing above
|
|
492
|
+
if (isFs()) return badge();
|
|
493
|
+
const t = pickTarget(p.el(), A.UP, collectBlocks());
|
|
494
|
+
if (!t) return badge('edge');
|
|
495
|
+
S.mode = 'NAV'; return setFocus(t);
|
|
496
|
+
}
|
|
497
|
+
case A.DOWN: return enterBar();
|
|
498
|
+
case A.OK:
|
|
499
|
+
if (isFs()) return togglePlay();
|
|
500
|
+
return goFullscreen();
|
|
501
|
+
case A.PLAY: return togglePlay();
|
|
502
|
+
case A.CHUP: return stepSeek(+1, CFG.chanSeek);
|
|
503
|
+
case A.CHDOWN: return stepSeek(-1, CFG.chanSeek);
|
|
504
|
+
case A.BACK:
|
|
505
|
+
if (isFs()) { leaveFullscreen(); return; }
|
|
506
|
+
if (Date.now() - S.fsExitAt < 400) return; // Esc just exited fullscreen natively
|
|
507
|
+
history.back(); return;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// control bar
|
|
512
|
+
function barButtons() {
|
|
513
|
+
const p = player(); if (!p) return [];
|
|
514
|
+
const bar = $('.vjs-control-bar', p.el()); if (!bar) return [];
|
|
515
|
+
return $$('button, [role="button"], .vjs-progress-control', bar)
|
|
516
|
+
.filter((b) => !b.closest('.vjs-menu') && !b.closest('.vjs-hidden') && !b.closest('.vjs-disabled') && rectOf(b).width > 0)
|
|
517
|
+
.sort((a, b) => rectOf(a).left - rectOf(b).left);
|
|
518
|
+
}
|
|
519
|
+
function enterBar() {
|
|
520
|
+
const p = player(); if (!p) return;
|
|
521
|
+
if (S.seekTo != null) clearSeek();
|
|
522
|
+
p.userActive(true);
|
|
523
|
+
S.mode = 'BAR'; S.sub = null;
|
|
524
|
+
const btns = barButtons(); if (!btns.length) return enterPlayer();
|
|
525
|
+
setFocus(btns.find((b) => b.classList.contains('vjs-play-control')) || btns[0], { noScroll: true });
|
|
526
|
+
}
|
|
527
|
+
function navBelow() {
|
|
528
|
+
const p = player(); if (!p) return;
|
|
529
|
+
S.mode = 'NAV';
|
|
530
|
+
const t = pickTarget(p.el(), A.DOWN, collectBlocks());
|
|
531
|
+
if (t) setFocus(t); else enterBar();
|
|
532
|
+
}
|
|
533
|
+
function keyBar(act) {
|
|
534
|
+
const p = player(); if (!p) { S.mode = 'NAV'; return initialFocus(); }
|
|
535
|
+
p.userActive(true);
|
|
536
|
+
if (S.sub === 'menu') return keyMenu(act);
|
|
537
|
+
const btns = barButtons(); const i = btns.indexOf(S.focused);
|
|
538
|
+
switch (act) {
|
|
539
|
+
case A.LEFT: if (i > 0) setFocus(btns[i - 1], { noScroll: true }); else if (i < 0) enterBar(); return;
|
|
540
|
+
case A.RIGHT: if (i >= 0 && i < btns.length - 1) setFocus(btns[i + 1], { noScroll: true }); else if (i < 0) enterBar(); return;
|
|
541
|
+
case A.UP: return enterPlayer();
|
|
542
|
+
case A.DOWN: return isFs() ? undefined : navBelow();
|
|
543
|
+
case A.OK:
|
|
544
|
+
if (S.focused && S.focused.classList.contains('vjs-progress-control')) return stepSeek(0); // scrub, don't jump
|
|
545
|
+
if (S.focused) { S.focused.click(); setTimeout(afterBarClick, 80); }
|
|
546
|
+
return;
|
|
547
|
+
case A.PLAY: return togglePlay();
|
|
548
|
+
case A.BACK: return enterPlayer();
|
|
549
|
+
case A.CHUP: return seekBy(CFG.chanSeek);
|
|
550
|
+
case A.CHDOWN: return seekBy(-CFG.chanSeek);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
function afterBarClick() {
|
|
554
|
+
const p = player(); if (!p) return;
|
|
555
|
+
const menu = $('.vjs-menu.vjs-lock-showing', p.el());
|
|
556
|
+
if (menu) {
|
|
557
|
+
S.sub = 'menu'; S.menu = menu; S.menuBtn = S.focused;
|
|
558
|
+
const items = menuItems();
|
|
559
|
+
focusMenuItem(items.find((x) => x.classList.contains('vjs-selected')) || items[0]);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (S.focused && S.focused.classList.contains('vjs-subtitle-manager-btn')) { // panel animates in
|
|
563
|
+
const btn = S.focused;
|
|
564
|
+
return waitFor(subPanel, 1500, () => enterSubPanel(btn));
|
|
565
|
+
}
|
|
566
|
+
// other modal dialogs are picked up by currentScope() on the next key
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// subtitle panel: plain divs, opened/closed by toggling the control-bar button
|
|
570
|
+
function spItems(pane) {
|
|
571
|
+
const sp = subPanel(); if (!sp) return [];
|
|
572
|
+
return $$(pane === 'tabs' ? '.vjs-sp-tab' : SP_CAND, sp).filter(isVisibleBlock);
|
|
573
|
+
}
|
|
574
|
+
function enterSubPanel(fromBtn) {
|
|
575
|
+
const sp = subPanel(); if (!sp) return;
|
|
576
|
+
S.returnMode = S.returnMode || (S.mode === 'NAV' ? 'BAR' : S.mode);
|
|
577
|
+
S.mode = 'NAV'; S.sub = null; S.menu = null;
|
|
578
|
+
if (fromBtn) S.beforeScope = fromBtn;
|
|
579
|
+
const items = spItems();
|
|
580
|
+
const sel = items.find((x) => x.classList.contains('selected') && !x.classList.contains('vjs-sp-tab'));
|
|
581
|
+
if (items.length) setFocus(sel || items.find((x) => !x.classList.contains('vjs-sp-tab')) || items[0], { noScroll: true });
|
|
582
|
+
badge('subtitles');
|
|
583
|
+
}
|
|
584
|
+
function closeSubPanel() {
|
|
585
|
+
const p = player(); const btn = p && $('.vjs-subtitle-manager-btn', p.el());
|
|
586
|
+
if (btn) btn.click(); // the button toggles the panel; Escape does not close it
|
|
587
|
+
setTimeout(afterSubPanel, 150);
|
|
588
|
+
}
|
|
589
|
+
function afterSubPanel() { // the panel also closes itself once a track is picked
|
|
590
|
+
const back = S.beforeScope; S.beforeScope = null;
|
|
591
|
+
const m = S.returnMode; S.returnMode = null;
|
|
592
|
+
if (back && isVisibleBlock(back) && back.closest('.vjs-control-bar')) { S.mode = 'BAR'; return setFocus(back, { noScroll: true }); }
|
|
593
|
+
return m === 'PLAYER' ? enterPlayer() : enterBar();
|
|
594
|
+
}
|
|
595
|
+
const menuItems = () => S.menu ? $$('.vjs-menu-item', S.menu).filter((x) => !x.classList.contains('vjs-hidden') && rectOf(x).height > 0) : [];
|
|
596
|
+
function focusMenuItem(it) {
|
|
597
|
+
if (!it || !S.menu) return;
|
|
598
|
+
$$('.tv-focus-ring', S.menu).forEach((e) => e.classList.remove('tv-focus-ring'));
|
|
599
|
+
it.classList.add('tv-focus-ring'); it.scrollIntoView({ block: 'nearest' });
|
|
600
|
+
S.menuItem = it; badge();
|
|
601
|
+
}
|
|
602
|
+
function closeMenu() {
|
|
603
|
+
if (S.menu) {
|
|
604
|
+
$$('.tv-focus-ring', S.menu).forEach((e) => e.classList.remove('tv-focus-ring'));
|
|
605
|
+
if (S.menu.classList.contains('vjs-lock-showing') && S.menuBtn) S.menuBtn.click();
|
|
606
|
+
}
|
|
607
|
+
S.sub = null; S.menu = null; S.menuItem = null;
|
|
608
|
+
if (S.menuBtn) setFocus(S.menuBtn, { noScroll: true }); else enterBar();
|
|
609
|
+
}
|
|
610
|
+
function keyMenu(act) {
|
|
611
|
+
const items = menuItems(); const i = items.indexOf(S.menuItem);
|
|
612
|
+
switch (act) {
|
|
613
|
+
case A.UP: if (i > 0) focusMenuItem(items[i - 1]); return;
|
|
614
|
+
case A.DOWN: if (i < items.length - 1) focusMenuItem(items[i + 1]); return;
|
|
615
|
+
case A.OK:
|
|
616
|
+
if (S.menuItem) S.menuItem.click();
|
|
617
|
+
setTimeout(() => { if (!S.menu || !S.menu.classList.contains('vjs-lock-showing')) closeMenu(); }, 80);
|
|
618
|
+
return;
|
|
619
|
+
case A.PLAY: return togglePlay();
|
|
620
|
+
default: return closeMenu(); // BACK, LEFT, RIGHT
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ------------------------------------------------------------------ NAV mode
|
|
625
|
+
function activate(f) {
|
|
626
|
+
if (isPlayerEl(f)) return enterPlayer();
|
|
627
|
+
if (isHoverTrigger(f)) { // menu closed (after Back): re-open and step into it
|
|
628
|
+
hoverOpen(f);
|
|
629
|
+
setTimeout(() => { const sc = currentScope(); const bl = sc ? collectBlocks(sc) : []; if (bl.length) { S.beforeScope = f; setFocus(bl[0]); } else f.click(); }, 250);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (f.closest('.vjs-subtitle-panel')) {
|
|
633
|
+
const wasTab = f.classList.contains('vjs-sp-tab');
|
|
634
|
+
f.click();
|
|
635
|
+
setTimeout(() => {
|
|
636
|
+
if (!subPanel()) return afterSubPanel(); // picking a track closes the panel
|
|
637
|
+
if (wasTab) { const it = spItems().filter((x) => !x.classList.contains('vjs-sp-tab')); if (it.length) setFocus(it[0], { noScroll: true }); }
|
|
638
|
+
}, 200);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (isCard(f)) { const t = S.sub === 'xem' ? xemOf(f) : posterOf(f); if (t) t.click(); return; }
|
|
642
|
+
if (/^(SELECT|INPUT|TEXTAREA)$/.test(f.tagName)) { f.focus(); badge('EDIT'); return; }
|
|
643
|
+
f.click();
|
|
644
|
+
}
|
|
645
|
+
function move(dir) {
|
|
646
|
+
const f = S.focused;
|
|
647
|
+
if (isCard(f)) {
|
|
648
|
+
if (dir === A.DOWN && S.sub !== 'xem' && xemOf(f)) return setSub('xem');
|
|
649
|
+
if (dir === A.UP && S.sub === 'xem') return setSub(null);
|
|
650
|
+
}
|
|
651
|
+
if (isPlayerEl(f)) { if (dir === A.DOWN) return navBelow(); return enterBar(); }
|
|
652
|
+
const scope = currentScope();
|
|
653
|
+
const blocks = collectBlocks(scope);
|
|
654
|
+
let t = pickTarget(f, dir, blocks);
|
|
655
|
+
if (!t && (dir === A.LEFT || dir === A.RIGHT) && pageCarousel(f, dir)) return;
|
|
656
|
+
if (!t && dir === A.UP && !scope && scrollY > 0) {
|
|
657
|
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
658
|
+
setTimeout(() => { const t2 = pickTarget(S.focused, A.UP, collectBlocks()); if (t2) setFocus(t2); }, 450);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (!t) return badge('edge');
|
|
662
|
+
if (dir === A.UP || dir === A.DOWN) {
|
|
663
|
+
const c = clipAncestor(t);
|
|
664
|
+
if (c) { const m = S.rowMem.get(c.el); if (m && m !== f && isVisibleBlock(m) && blocks.includes(m)) t = m; }
|
|
665
|
+
}
|
|
666
|
+
if (isPlayerEl(t)) return dir === A.UP ? enterBar() : enterPlayer();
|
|
667
|
+
setFocus(t);
|
|
668
|
+
}
|
|
669
|
+
function closeScope(scope) {
|
|
670
|
+
if (scope && scope.classList.contains('vjs-subtitle-panel')) return closeSubPanel();
|
|
671
|
+
const hoverT = S.hoverTrigger;
|
|
672
|
+
const finish = () => {
|
|
673
|
+
if (currentScope()) return; // still open, stay in scope
|
|
674
|
+
if (S.returnMode) { const m = S.returnMode; S.returnMode = null; return m === 'PLAYER' ? enterPlayer() : enterBar(); }
|
|
675
|
+
const back = S.beforeScope || hoverT; S.beforeScope = null;
|
|
676
|
+
if (back && isVisibleBlock(back)) return setFocus(back, { noHover: true }); // don't re-open a hover menu
|
|
677
|
+
S.focused = null; initialFocus();
|
|
678
|
+
};
|
|
679
|
+
const generic = () => {
|
|
680
|
+
const closeBtn = $('.vjs-close-button, [data-part="close-trigger"], button[aria-label*="close" i], button[aria-label*="Đóng" i], button[title*="Đóng" i]', scope);
|
|
681
|
+
if (closeBtn) closeBtn.click();
|
|
682
|
+
else {
|
|
683
|
+
const ev = new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true });
|
|
684
|
+
ev.__tv = true;
|
|
685
|
+
(document.activeElement || document.body).dispatchEvent(ev);
|
|
686
|
+
const trig = $('[aria-expanded="true"]');
|
|
687
|
+
if (trig) setTimeout(() => { if (currentScope()) trig.click(); }, 50);
|
|
688
|
+
}
|
|
689
|
+
setTimeout(finish, 150);
|
|
690
|
+
};
|
|
691
|
+
if (hoverT) { // hover menu first: fake mouseleave; if it is a click-menu after all, fall back
|
|
692
|
+
hoverClose();
|
|
693
|
+
setTimeout(() => { if (currentScope()) generic(); else finish(); }, 120);
|
|
694
|
+
} else generic();
|
|
695
|
+
}
|
|
696
|
+
function navBack(scope) {
|
|
697
|
+
if (scope) return closeScope(scope);
|
|
698
|
+
if (location.pathname === '/') return badge('home');
|
|
699
|
+
history.back();
|
|
700
|
+
}
|
|
701
|
+
function keyNav(act, scope) {
|
|
702
|
+
if (!S.focused || !isVisibleBlock(S.focused)) { S.focused = null; initialFocus(); if (act === A.BACK) navBack(scope); return; }
|
|
703
|
+
switch (act) {
|
|
704
|
+
case A.OK: return activate(S.focused);
|
|
705
|
+
case A.PLAY: if (isCard(S.focused)) { const x = xemOf(S.focused); if (x) x.click(); } else if (isWatch()) togglePlay(); return;
|
|
706
|
+
case A.BACK: return navBack(scope);
|
|
707
|
+
case A.CHUP: return jumpRow(A.UP);
|
|
708
|
+
case A.CHDOWN: return jumpRow(A.DOWN);
|
|
709
|
+
default: return move(act);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ------------------------------------------------------------------ key dispatch
|
|
714
|
+
function consume(e) { e.preventDefault(); e.stopImmediatePropagation(); }
|
|
715
|
+
function onKey(e) {
|
|
716
|
+
if (e.__tv) return;
|
|
717
|
+
let act = TIZEN_KEYS[e.keyCode];
|
|
718
|
+
if (!act && CFG.keyboardEmulation) act = KB_KEYS[e.keyCode];
|
|
719
|
+
if (!act) return;
|
|
720
|
+
if (e.repeat && (act === A.OK || act === A.BACK || act === A.PLAY)) return consume(e);
|
|
721
|
+
|
|
722
|
+
// Back on the home page belongs to the platform: TizenBrew uses it to leave the mod,
|
|
723
|
+
// so let it bubble instead of swallowing it (the user would be stuck otherwise).
|
|
724
|
+
if (act === A.BACK && isHome() && S.mode === 'NAV' && !editingEl() && !currentScope()) { hoverClose(); return; }
|
|
725
|
+
|
|
726
|
+
const ed = editingEl();
|
|
727
|
+
if (ed) {
|
|
728
|
+
const textish = ed.tagName !== 'SELECT';
|
|
729
|
+
if (act === A.BACK) { ed.blur(); consume(e); S.lastKey = e.keyCode; if (S.focused) setFocus(S.focused, { noScroll: true }); else initialFocus(); return; }
|
|
730
|
+
if (textish && (act === A.UP || act === A.DOWN)) { ed.blur(); /* fall through to navigation */ }
|
|
731
|
+
else return; // native handling (typing, select value change, Enter)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
consume(e); S.lastKey = e.keyCode;
|
|
735
|
+
|
|
736
|
+
let scope = currentScope();
|
|
737
|
+
// hover-menu open under a focused trigger (avatar): OK/↓ enter it, Back closes it, other arrows leave
|
|
738
|
+
if (scope && S.hoverTrigger && S.focused === S.hoverTrigger) {
|
|
739
|
+
if (act === A.OK || act === A.DOWN) { const bl = collectBlocks(scope); if (bl.length) { S.beforeScope = S.focused; setFocus(bl[0]); return; } }
|
|
740
|
+
else if (act === A.BACK) { hoverClose(); return badge(); }
|
|
741
|
+
hoverClose(); scope = null;
|
|
742
|
+
}
|
|
743
|
+
if (scope) {
|
|
744
|
+
if (S.mode !== 'NAV') { S.returnMode = S.mode === 'SEEK' ? 'PLAYER' : S.mode; clearSeek(); S.mode = 'NAV'; S.sub = null; S.menu = null; }
|
|
745
|
+
if (!S.focused || !scope.contains(S.focused)) {
|
|
746
|
+
const bl = collectBlocks(scope);
|
|
747
|
+
if (bl.length) { S.beforeScope = S.focused; setFocus(bl[0]); if (act !== A.BACK) return; }
|
|
748
|
+
}
|
|
749
|
+
} else if (S.returnMode) {
|
|
750
|
+
const m = S.returnMode; S.returnMode = null;
|
|
751
|
+
return m === 'PLAYER' ? enterPlayer() : enterBar();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
switch (S.mode) {
|
|
755
|
+
case 'PLAYER': return keyPlayer(act);
|
|
756
|
+
case 'SEEK': return keySeek(act);
|
|
757
|
+
case 'BAR': return keyBar(act);
|
|
758
|
+
default: return keyNav(act, scope);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ------------------------------------------------------------------ routing & lifecycle
|
|
763
|
+
function waitFor(cond, timeout, ok, fail) {
|
|
764
|
+
const t0 = Date.now();
|
|
765
|
+
(function poll() {
|
|
766
|
+
let v = null; try { v = cond(); } catch (e) { /* ignore */ }
|
|
767
|
+
if (v) return ok(v);
|
|
768
|
+
if (Date.now() - t0 > timeout) return fail && fail();
|
|
769
|
+
setTimeout(poll, 100);
|
|
770
|
+
})();
|
|
771
|
+
}
|
|
772
|
+
function onRoute() {
|
|
773
|
+
S.url = location.href;
|
|
774
|
+
hoverClose(); clearSeek(); S.seekReturn = null;
|
|
775
|
+
clearFocus(); S.focused = null; S.mode = 'NAV'; S.sub = null; S.menu = null; S.returnMode = null; S.beforeScope = null;
|
|
776
|
+
setFakeFs(false);
|
|
777
|
+
if (isWatch()) {
|
|
778
|
+
waitFor(player, 10000, (p) => {
|
|
779
|
+
enterPlayer();
|
|
780
|
+
p.on('fullscreenchange', () => { if (!p.isFullscreen()) S.fsExitAt = Date.now(); badge(p.isFullscreen() ? 'fullscreen' : 'windowed'); });
|
|
781
|
+
p.on('useractive', () => { if (S.mode === 'BAR') p.userActive(true); });
|
|
782
|
+
}, () => waitFor(() => collectBlocks().length, 6000, () => initialFocus()));
|
|
783
|
+
} else {
|
|
784
|
+
waitFor(() => collectBlocks().length, 6000, () => { if (!S.focused) initialFocus(); });
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
function patchHistory() {
|
|
788
|
+
if (history.__tvPatched) return; history.__tvPatched = true;
|
|
789
|
+
const fire = () => setTimeout(() => dispatchEvent(new Event('tv:route')), 0);
|
|
790
|
+
for (const k of ['pushState', 'replaceState']) {
|
|
791
|
+
const orig = history[k];
|
|
792
|
+
history[k] = function () { const r = orig.apply(this, arguments); fire(); return r; };
|
|
793
|
+
}
|
|
794
|
+
addEventListener('popstate', fire);
|
|
795
|
+
}
|
|
796
|
+
function checkRoute() { if (location.href !== S.url) onRoute(); }
|
|
797
|
+
|
|
798
|
+
let moTimer = null;
|
|
799
|
+
function onMutate() {
|
|
800
|
+
if (location.href !== S.url) return onRoute();
|
|
801
|
+
if (S.mode === 'NAV') {
|
|
802
|
+
const f = S.focused;
|
|
803
|
+
if (f && f.isConnected && !isVisibleBlock(f)) {
|
|
804
|
+
// e.g. carousel auto-rotated under the focus → nearest visible card in the same row
|
|
805
|
+
const c = clipAncestor(f);
|
|
806
|
+
if (c) { const t = collectBlocks().find((b) => c.el.contains(b)); if (t) return setFocus(t, { noScroll: true }); }
|
|
807
|
+
}
|
|
808
|
+
if (!f || !f.isConnected || !isVisibleBlock(f)) { S.focused = null; initialFocus(); }
|
|
809
|
+
} else if (S.mode === 'BAR') {
|
|
810
|
+
if (!S.focused || !S.focused.isConnected) enterBar();
|
|
811
|
+
} else if (S.mode === 'PLAYER' && !player()) { S.mode = 'NAV'; initialFocus(); }
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// keep the control bar visible while navigating it
|
|
815
|
+
const keepAlive = setInterval(() => { if (S.mode === 'BAR' || subPanel()) { const p = player(); if (p) p.userActive(true); } }, 1000);
|
|
816
|
+
|
|
817
|
+
// ------------------------------------------------------------------ UI
|
|
818
|
+
function injectCSS() {
|
|
819
|
+
if ($('#tv-nav-style')) return;
|
|
820
|
+
const st = document.createElement('style'); st.id = 'tv-nav-style';
|
|
821
|
+
st.textContent = `
|
|
822
|
+
.tv-focus-ring { outline: 4px solid #fff !important; outline-offset: 3px; border-radius: 10px; box-shadow: 0 0 0 8px rgba(0,0,0,.55); }
|
|
823
|
+
.group.tv-focus > .group-hover\\:invisible { visibility: hidden !important; }
|
|
824
|
+
.group.tv-focus .group-hover\\:block { display: block !important; }
|
|
825
|
+
.tv-sub-focus { outline: 3px solid #ffd54f !important; outline-offset: 3px; border-radius: 8px; }
|
|
826
|
+
.tv-sub-active > .tv-focus-ring { outline-color: rgba(255,255,255,.35) !important; box-shadow: none; }
|
|
827
|
+
.video-js.tv-focus-ring { outline-offset: -4px; border-radius: 0; box-shadow: none; }
|
|
828
|
+
.video-js.vjs-fullscreen.tv-focus-ring { outline: none !important; }
|
|
829
|
+
.vjs-control-bar .tv-focus-ring { outline: 3px solid #fff !important; outline-offset: -3px; border-radius: 4px; box-shadow: none; }
|
|
830
|
+
.vjs-menu-item.tv-focus-ring { outline: 2px solid #fff !important; outline-offset: -2px; border-radius: 0; box-shadow: none; }
|
|
831
|
+
.vjs-subtitle-panel .tv-focus-ring { outline: 2px solid #fff !important; outline-offset: -2px; border-radius: 6px; box-shadow: none; }
|
|
832
|
+
.vjs-progress-control.tv-focus-ring { outline: 2px solid #fff !important; outline-offset: 2px; border-radius: 4px; box-shadow: none; }
|
|
833
|
+
.tv-force-visible { opacity: 1 !important; visibility: visible !important; pointer-events: auto !important; }
|
|
834
|
+
.video-js.tv-seeking .vjs-control-bar { opacity: 1 !important; visibility: visible !important; }
|
|
835
|
+
.video-js.tv-seeking .vjs-progress-control { pointer-events: none; }
|
|
836
|
+
#tv-seek { position: absolute; bottom: 0; z-index: 3; pointer-events: none; transform: translateX(-50%); }
|
|
837
|
+
#tv-seek i { display: block; width: 3px; height: 22px; margin: 0 auto; background: #fff; box-shadow: 0 0 4px rgba(0,0,0,.8); }
|
|
838
|
+
#tv-seek b { position: absolute; bottom: 26px; left: 50%; transform: translateX(-50%); white-space: nowrap;
|
|
839
|
+
font: 600 13px/1.3 ui-monospace, monospace; color: #fff; background: rgba(0,0,0,.85); padding: 3px 8px; border-radius: 4px; }
|
|
840
|
+
.video-js.tv-fs { position: fixed !important; inset: 0 !important; top: 0 !important; left: 0 !important;
|
|
841
|
+
width: 100vw !important; height: 100vh !important; max-width: none !important; padding: 0 !important;
|
|
842
|
+
z-index: 2147483000; background: #000; }
|
|
843
|
+
.video-js.tv-fs .vjs-tech { width: 100% !important; height: 100% !important; object-fit: contain; }
|
|
844
|
+
html.tv-fs-on, html.tv-fs-on body { overflow: hidden !important; }
|
|
845
|
+
html.tv-fs-on header, html.tv-fs-on footer { display: none !important; }
|
|
846
|
+
#tv-badge { position: fixed; top: 12px; right: 12px; z-index: 2147483647; font: 13px/1.2 ui-monospace, monospace; padding: 6px 10px;
|
|
847
|
+
background: rgba(0,0,0,.75); color: #fff; border-radius: 6px; pointer-events: none; opacity: 0; transition: opacity .3s; white-space: pre; }
|
|
848
|
+
` + (CFG.hideCursor ? '\n html, body, * { cursor: none !important; }' : '');
|
|
849
|
+
document.head.appendChild(st);
|
|
850
|
+
}
|
|
851
|
+
let badgeTimer = null;
|
|
852
|
+
function badge(extra) {
|
|
853
|
+
if (!CFG.debug) return;
|
|
854
|
+
let b = $('#tv-badge');
|
|
855
|
+
if (!b) { b = document.createElement('div'); b.id = 'tv-badge'; document.body.appendChild(b); }
|
|
856
|
+
const fs = (S.mode !== 'NAV' && isFs()) ? ' FS' : '';
|
|
857
|
+
b.textContent = S.mode + (S.sub ? ':' + S.sub.toUpperCase() : '') + fs + (extra ? ' ' + extra : '') + (S.lastKey ? ' key ' + S.lastKey : '');
|
|
858
|
+
b.style.opacity = '1';
|
|
859
|
+
clearTimeout(badgeTimer); badgeTimer = setTimeout(() => { b.style.opacity = '0'; }, CFG.badgeMs);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// ------------------------------------------------------------------ boot
|
|
863
|
+
function init() {
|
|
864
|
+
if (window.__tvNav && window.__tvNav.destroy) window.__tvNav.destroy(); // hot re-install (dev)
|
|
865
|
+
injectCSS();
|
|
866
|
+
addEventListener('keydown', onKey, true);
|
|
867
|
+
patchHistory();
|
|
868
|
+
addEventListener('tv:route', checkRoute);
|
|
869
|
+
const mo = new MutationObserver(() => { clearTimeout(moTimer); moTimer = setTimeout(onMutate, 200); });
|
|
870
|
+
mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
|
|
871
|
+
onRoute();
|
|
872
|
+
window.__tvNav = {
|
|
873
|
+
S, CFG, collectBlocks, setFocus, initialFocus, enterPlayer, enterBar, isVisibleBlock, pickTarget, clipAncestor, currentScope,
|
|
874
|
+
destroy() {
|
|
875
|
+
removeEventListener('keydown', onKey, true); removeEventListener('tv:route', checkRoute);
|
|
876
|
+
mo.disconnect(); clearInterval(keepAlive); clearTimeout(moTimer); clearFocus();
|
|
877
|
+
const b = $('#tv-badge'); if (b) b.remove(); const st = $('#tv-nav-style'); if (st) st.remove();
|
|
878
|
+
},
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
if (document.body) init(); else addEventListener('DOMContentLoaded', init);
|
|
882
|
+
})();
|