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.
Files changed (4) hide show
  1. package/README.md +113 -0
  2. package/SPECS.md +342 -0
  3. package/mod.js +882 -0
  4. 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
+ })();