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/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # phimpal-tv
2
+
3
+ D-pad navigation for [phimpal.com](https://phimpal.com/) on a Samsung TV, shipped as a
4
+ **TizenBrew site-modification module**. The Samsung browser's pointer is replaced by
5
+ block-to-block navigation with the remote: focus rings, a card popup, a control-bar mode
6
+ and a preview-based seek that never stutters the stream.
7
+
8
+ | File | Role |
9
+ |---|---|
10
+ | `phimpal-tv.user.js` | **The source.** Desktop test build (Tampermonkey), keyboard stands in for the remote |
11
+ | `mod.js` | Generated TV build injected by TizenBrew — do not edit by hand |
12
+ | `build.js` | Derives `mod.js` from the userscript (`npm run build`) |
13
+ | `package.json` | TizenBrew manifest |
14
+ | `SPECS.md` | Site analysis, key map, mode state machine, algorithms |
15
+
16
+ ## Remote keys
17
+
18
+ | Button | Everywhere | On the player |
19
+ |---|---|---|
20
+ | ↑ ↓ ← → | move the focus ring between blocks | ←/→ open the seek cursor, ↓ the control bar |
21
+ | OK | open the focused item | fullscreen, then play/pause; commits a seek |
22
+ | Return | back / close a menu / exit fullscreen | |
23
+ | Play/Pause | direct **Xem** on a focused card | play/pause |
24
+ | Ch ▲ / ▼ | previous / next row | ±5 min in the seek cursor |
25
+
26
+ Volume, Home and the app shortcuts stay with the TV. Colour keys are not used —
27
+ on the Frame's remote they only exist behind the `123` keypad.
28
+
29
+ Two behaviours worth knowing:
30
+
31
+ - **Seeking never jumps immediately.** ←/→ move a preview cursor along the red timeline
32
+ with the site's own thumbnail; the step accelerates (10 s → 30 s → 1 min → 5 min).
33
+ OK commits, Return cancels — nothing is re-buffered until you confirm.
34
+ - **Cards open their popup on focus**, exactly like the mouse hover: OK opens the title
35
+ page, ↓ then OK plays straight away.
36
+
37
+ ## Install on the TV
38
+
39
+ 1. **Install TizenBrew** on the Frame — follow the
40
+ [official guide](https://github.com/reisxd/TizenBrew/blob/main/docs/README.md)
41
+ (developer mode + TizenBrew Installer). Nothing here works without it.
42
+
43
+ 2. **Publish this module to npm** — TizenBrew installs modules as npm packages:
44
+
45
+ ```bash
46
+ cd phimpal-tizen
47
+ npm run build # regenerates mod.js from the userscript
48
+ npm login
49
+ npm publish --access public
50
+ ```
51
+
52
+ The package name is `phimpal-tv` (change `name` in `package.json` if it is taken,
53
+ e.g. `@yourname/phimpal-tv`).
54
+
55
+ 3. **Add it in TizenBrew** → module manager → add an NPM module → type the package name
56
+ → launch it. TizenBrew opens `https://phimpal.com/`, injects `mod.js` and registers
57
+ the media/channel keys listed in `package.json`.
58
+
59
+ 4. **First run: log in.** The site is account-gated. Focus the email field, press OK, and
60
+ the TV's on-screen keyboard appears; Return leaves the field. The session cookie
61
+ persists in the webview, so this is a one-time chore.
62
+
63
+ ### Updating
64
+
65
+ Bump `version` in `package.json`, `npm run build`, `npm publish`, then update the module
66
+ in TizenBrew.
67
+
68
+ ## Desktop testing (before touching the TV)
69
+
70
+ Install `phimpal-tv.user.js` in Violentmonkey/Tampermonkey and open phimpal.com. The
71
+ keyboard emulates the remote:
72
+
73
+ | Keyboard | Remote |
74
+ |---|---|
75
+ | Arrows / Enter | D-pad / OK |
76
+ | Esc or Backspace | Return |
77
+ | Space | Play/Pause |
78
+ | PageUp / PageDown | Ch ▲ / ▼ |
79
+
80
+ A badge in the top-right shows the current mode and the last key code. In the TV build
81
+ the badge is off; enable it on the TV by running `localStorage['tv-debug'] = '1'` once
82
+ (TizenBrew's dev console) and reloading.
83
+
84
+ ## What the TV build changes
85
+
86
+ `build.js` only flips a few switches, so both builds share one engine:
87
+
88
+ - keyboard emulation off — only real remote codes are accepted;
89
+ - the debug badge is off unless `tv-debug` is set;
90
+ - the pointer is hidden;
91
+ - the keys from `package.json` are also registered defensively if the Tizen API is reachable.
92
+
93
+ Three TV-specific behaviours live in the engine itself:
94
+
95
+ - **Return on the home page is not swallowed**, so TizenBrew can close the module.
96
+ Anywhere else it means back / close.
97
+ - **Fullscreen has a fallback**: if the webview refuses `requestFullscreen`, the player is
98
+ stretched over the viewport with CSS instead (header and footer hidden).
99
+ - **Hover menus are forced visible.** The avatar menu is mounted on `mouseenter` but the
100
+ site fades it in only while the element really matches `:hover`, which never happens
101
+ with a remote — so the module reveals it itself.
102
+
103
+ ## Known limits
104
+
105
+ - The per-track timing button (clock icon) in the subtitle panel is not reachable with the
106
+ D-pad: it sits inside a track row and would trap horizontal moves.
107
+ - The hero row auto-rotates; if it moves under the focus, the focus jumps to the first
108
+ visible card of that row.
109
+ - Selectors follow the site. Svelte's hashed classes are never used, but a redesign of the
110
+ cards, the carousels or the player would need a pass over `SPECS.md`.
111
+ - Tizen's WebKit is older than desktop Chrome; the engine sticks to ES5-compatible syntax
112
+ in spirit but does use `const`/arrow functions/template literals, which Tizen 5.5+
113
+ (2020 Frame and newer) supports. For a 2017–2019 set, transpile before publishing.
package/SPECS.md ADDED
@@ -0,0 +1,342 @@
1
+ # phimpal-tv — Navigation specs
2
+
3
+ Remote-friendly (D-pad) navigation layer for https://phimpal.com, to be shipped
4
+ as a TizenBrew *mod* module for a Samsung The Frame TV. Phase 1 is a userscript
5
+ tested on a desktop browser with the keyboard emulating the remote.
6
+
7
+ ## 1. Target site facts (inspected 2026-09-04)
8
+
9
+ | Item | Finding |
10
+ |---|---|
11
+ | Framework | SvelteKit SPA, Tailwind v4 + Skeleton UI, `<html class="dark">` |
12
+ | Auth | Login required (`/` → `/auth/login`). Cookie session, persists in the webview. |
13
+ | Routing | Client-side, except the play link (`data-sveltekit-reload`) which is a full page load `/api/play/:id` → 302 → `/watch/:id` |
14
+ | Class names | `svelte-xxxx` hashes change per build → never used. Only Tailwind utilities, ARIA, hrefs and Video.js classes. |
15
+ | Player | Video.js v8 (`.video-js`, `el.player` API), same-origin `<video class="vjs-tech">`, HLS blob, no iframe. Plugins: `videojs-hotkeys`, `vjs-seek-buttons` (±5 s). Autoplays windowed under a 64 px fixed header. |
16
+
17
+ ### Pages
18
+
19
+ | Page | Route | Blocks |
20
+ |---|---|---|
21
+ | Home | `/` | fixed header (logo, `/top`, `/browse?type=movie`, `/browse?type=show`, `/faq`, `/search`, avatar menu); filter `<select>`s; N horizontal carousels of cards |
22
+ | Browse / Top | `/browse?…`, `/top` | filters + card grid/carousels |
23
+ | Search | `/search` | one `input.input` (autofocus) + result cards |
24
+ | Title (movie) | `/title/:slug~:id` | `a[href^="/api/play/"]` "XEM PHIM", bookmark button, year/country/genre links, cast carousel, trailer, similar titles |
25
+ | Title (show) | same | `<select>` season, `<select>` episode, "Xem" button, "Đã xem" toggle, rest as movie |
26
+ | Watch | `/watch/:id` | Video.js player, control bar buttons, "Kích hoạt VIP mode", "Tab Song ngữ", comment form ("Gửi"), back-link to title |
27
+
28
+ ### Card anatomy (`div.group.relative`)
29
+
30
+ ```
31
+ div.group
32
+ ├─ a[href^="/title/"] .group-hover:invisible ← poster (visible)
33
+ └─ div.hidden.group-hover:block (-m-5, z-10) ← popup, display:none until :hover
34
+ ├─ a[href^="/title/"] backdrop image
35
+ ├─ a[href^="/title/"] title + synopsis
36
+ └─ a[href^="/api/play/"] "Xem" ← direct to watch page
37
+ ```
38
+
39
+ Mouse semantics to reproduce: click anywhere on the card → title page;
40
+ click "Xem" → watch page.
41
+
42
+ ### Carousel anatomy
43
+
44
+ ```
45
+ div.container ← ONE container for all rows of the page
46
+ └─ div.overflow-x-clip ← one per row
47
+ └─ div.relative
48
+ ├─ div.flex[style*="--item-w"][style*="translateX"] ← track, paginated by translateX
49
+ │ └─ div.shrink-0 > div.group (× N, most off-screen)
50
+ └─ div.absolute (arrows)
51
+ ├─ button[aria-label="Previous"] (opacity-0 on first page)
52
+ └─ button[aria-label="Next"]
53
+ ```
54
+
55
+ `overflow-x: clip` → `scrollIntoView` is useless. Paging is done by clicking
56
+ Previous / Next **found inside the row's own `.overflow-x-clip`** (searching the
57
+ shared container would always hit the first row's arrows). A page shifts by
58
+ `visible − 1` cards, so pages overlap by one card. Hero row ("Phim đề cử")
59
+ auto-rotates on a timer.
60
+
61
+ ### Subtitle panel (custom Video.js plugin)
62
+
63
+ The control-bar button `.vjs-subtitle-manager-btn` toggles
64
+ `.vjs-subtitle-panel.is-open`, an absolutely-positioned panel inside `.video-js`:
65
+
66
+ ```
67
+ .vjs-subtitle-panel.is-open
68
+ ├─ .vjs-sp-header > .vjs-sp-tabs > .vjs-sp-tab[data-tab=single|dual|filters]
69
+ ├─ .vjs-sp-body
70
+ │ ├─ .vjs-sp-pane[data-pane=single].active > #vjs-single-list > .vjs-sp-track-item
71
+ │ │ (.selected marks the active track,
72
+ │ │ .vjs-sp-shift-btn = timing offset)
73
+ │ ├─ .vjs-sp-pane[data-pane=dual] > .vjs-sp-dual-grid > 2 × .vjs-sp-track-list
74
+ │ └─ .vjs-sp-pane[data-pane=filters] > .vjs-sp-filter-row > .vjs-sp-topbottom-label
75
+ └─ .vjs-sp-footer > .vjs-sp-caption-btn ("Cài đặt")
76
+ ```
77
+
78
+ Everything is plain `<div>` with no ARIA and no `tabindex`, so the parts are
79
+ listed explicitly in `SP_CAND`. Escape does **not** close the panel: closing
80
+ means clicking the control-bar button again (or clicking outside). Picking a
81
+ track in the single pane closes the panel by itself. The panel animates its
82
+ opacity, so the entry point polls until its items are measurable.
83
+
84
+ ### Header avatar menu (hover-driven)
85
+
86
+ `header button[aria-haspopup="menu"]` opens `div[role="menu"][aria-label="Options"]`
87
+ (fixed, z-9999) on **mouseenter** and closes it on **mouseleave** of the trigger
88
+ or the menu; `click()` does nothing. The script fakes the mouse: focus on the
89
+ trigger → synthetic `mouseenter`; leaving → synthetic `mouseleave`. The bookmark
90
+ menu on title pages is a Zag/Skeleton menu (`[data-scope=menu]`), click-driven,
91
+ closed with Escape — handled by the generic dialog path.
92
+
93
+ ### Focusable noise to ignore
94
+
95
+ On the home page: 497 focusables, 341 invisible. Sources: popup links (3 per
96
+ card), off-screen carousel items, mobile-only header/drawer duplicates
97
+ (`md:hidden`, `aside.-translate-x-full`), carousel arrows and dots.
98
+
99
+ ## 2. Input
100
+
101
+ ### Physical remote (Samsung Smart Remote, The Frame)
102
+
103
+ | Key | Tizen keyCode | Registered? |
104
+ |---|---|---|
105
+ | ← ↑ → ↓ | 37 38 39 40 | default |
106
+ | Select (OK) | 13 | default |
107
+ | Return (Back) | 10009 | default |
108
+ | Play/Pause | 10252 | must `tizen.tvinputdevice.registerKey('MediaPlayPause')` |
109
+ | Channel ▲ / ▼ | 427 / 428 | must register `ChannelUp` / `ChannelDown` |
110
+
111
+ Not usable: volume (TV level), Home/Power/Ambient/app shortcuts (system),
112
+ color keys and digits (only via the `123` virtual keypad → rejected).
113
+
114
+ ### Keyboard emulation (phase 1, desktop)
115
+
116
+ | Keyboard | Emulates |
117
+ |---|---|
118
+ | Arrow keys | D-pad |
119
+ | Enter | OK |
120
+ | Escape | Back |
121
+ | Space | Play/Pause |
122
+ | PageUp / PageDown | Channel ▲ / ▼ |
123
+
124
+ Every other key is ignored by the script (passes through). Mouse is untouched.
125
+
126
+ ## 3. Modes
127
+
128
+ ```
129
+ NAV – a page block is focused; D-pad moves focus geometrically
130
+ CARD – a card is focused, its popup is open (sub-state of NAV)
131
+ PLAYER – on /watch, the video is the focused block; D-pad drives playback
132
+ SEEK – scrubbing: a preview cursor moves on the progress bar, OK commits
133
+ BAR – on /watch, focus is inside the Video.js control bar (sub-state of NAV)
134
+ ```
135
+
136
+ A small badge (top-right, 2 s fade) shows the mode after each change, plus the
137
+ last key code (phase 1 debug only).
138
+
139
+ ## 4. Key map
140
+
141
+ ### NAV (all pages)
142
+
143
+ | Key | Action |
144
+ |---|---|
145
+ | ← ↑ → ↓ | move focus to the nearest visible block in that direction (horizontal moves stay on the same line; the fixed header is only a target when nothing else is above) |
146
+ | → / ← at the edge of a carousel page | click the row's Next / Previous, then focus the first / last newly visible card |
147
+ | focus lands on a hover-menu trigger (avatar) | menu opens; **↓ / OK** enter it, **Back** closes it, ← → ↑ close it and move on |
148
+ | OK | `click()` the focused element (`<a>`, `<button>`); `<select>`/`<input>` → native focus so the TV keyboard/picker opens |
149
+ | Back | if a `<select>`/`<input>` is active → blur; else `history.back()`; on `/` → do nothing (let TizenBrew handle exit) |
150
+ | Play/Pause | ignored |
151
+ | Ch ▲ / ▼ | focus the first card of the previous / next carousel row |
152
+
153
+ ### CARD (card focused)
154
+
155
+ | Key | Action |
156
+ |---|---|
157
+ | focus enters | add `.tv-focus` on `div.group` → popup opens (CSS below); highlight = whole popup |
158
+ | OK | go to title page (click the poster link) |
159
+ | ↓ | move sub-focus to "Xem" (if not already); ↓ again → leave the card downwards |
160
+ | ↑ | from "Xem" → back to card body; from card body → leave upwards |
161
+ | ← → | neighbouring card (popup closes, new one opens) |
162
+ | Play/Pause | click "Xem" → watch page |
163
+ | focus leaves | remove `.tv-focus` |
164
+
165
+ ### Watch page
166
+
167
+ Landing state: **PLAYER** mode, windowed, autoplaying. Player element gets the
168
+ focus ring.
169
+
170
+ | Key | PLAYER (windowed or fullscreen) |
171
+ |---|---|
172
+ | OK | not fullscreen → `player.requestFullscreen()`; fullscreen → play/pause |
173
+ | Play/Pause | play/pause (any mode) |
174
+ | ← / → | enter **SEEK** (scrub) mode — no jump happens yet |
175
+ | Ch ▲ / ▼ | enter **SEEK** with a ±5 min step |
176
+ | ↑ | windowed: leave the player upwards → **NAV** on the header links (↓ from there re-enters PLAYER); fullscreen: no-op |
177
+ | ↓ | show controls and enter **BAR** mode on the play button |
178
+ | Back | fullscreen → exit fullscreen (stay PLAYER); windowed → `history.back()` (title page) |
179
+
180
+ | Key | BAR (control bar focused) |
181
+ |---|---|
182
+ | ← → | move across visible `.vjs-control-bar` buttons (skip `.vjs-hidden`, `.vjs-disabled`, progress bar) |
183
+ | OK | click; a `.vjs-menu` (speed) or the subtitle panel becomes the navigation scope |
184
+ | ↑ | back to PLAYER |
185
+ | ↓ | windowed only → NAV on the page buttons below the player; fullscreen → no-op |
186
+ | Back | if a vjs menu is open → close it; else → PLAYER |
187
+ | Play/Pause | play/pause |
188
+
189
+ ### SEEK (scrub) mode
190
+
191
+ The stream re-buffers slowly, so a keypress must never move playback. `←`/`→`
192
+ in PLAYER (or OK on the progress bar in BAR) enter SEEK: a pending position is
193
+ tracked in `S.seekTo` while the video keeps playing untouched.
194
+
195
+ The preview is the site's own: a synthetic `mouseover` + `mousemove` on
196
+ `.vjs-progress-control` at the matching x makes its thumbnail sprite
197
+ (`assets.phimzoo.com/thumb/<id>.jpg`) appear and follow. `.vjs-mouse-display` is
198
+ CSS-gated on real `:hover`, so its time tooltip stays hidden; the script draws
199
+ its own caret + time label (`#tv-seek`) instead. `mouseout` + `mouseleave` hide
200
+ the thumbnail again.
201
+
202
+ | Key | SEEK |
203
+ |---|---|
204
+ | ← / → | move the cursor; step accelerates while pressing: 10 s → 30 s → 60 s → 5 min (resets after 800 ms or on direction change) |
205
+ | Ch ▲ / ▼ | ±5 min |
206
+ | OK | commit: `player.currentTime(seekTo)`, resume if it was playing, back to **PLAYER** |
207
+ | Back / ↑ | cancel, back to **PLAYER** (playback never moved) |
208
+ | ↓ | cancel, back to **BAR** |
209
+ | Play/Pause | play/pause, stays in SEEK |
210
+
211
+ Fallback: if `duration()` is unknown or infinite (live), arrows revert to the
212
+ old immediate ±10 s seek.
213
+
214
+ | Key | Subtitle panel (scope, entered from the `Phụ đề` bar button) |
215
+ |---|---|
216
+ | focus enters | lands on the currently selected track (or the first entry of the active pane) |
217
+ | ← ↑ → ↓ | move between tabs, track entries, the two bilingual columns, filter toggles and `Cài đặt` |
218
+ | OK on a tab | switch pane, focus its first entry |
219
+ | OK on a track / filter | apply it; if the panel closes itself, focus returns to the `Phụ đề` button in **BAR** |
220
+ | Back | close the panel (clicks the bar button) and return to **BAR** |
221
+
222
+ | Key | NAV below player (windowed) |
223
+ |---|---|
224
+ | ↑ from the top row | → BAR |
225
+ | OK on the player block | → PLAYER |
226
+ | Back | `history.back()` |
227
+
228
+ Every key in PLAYER/BAR calls `player.userActive(true)` first so the control
229
+ bar is visible while interacting.
230
+
231
+ ## 5. Spatial navigation algorithm
232
+
233
+ Candidates = `a[href], button, select, input, textarea, [tabindex]:not([tabindex="-1"]), .video-js`
234
+ filtered by:
235
+
236
+ 1. `getBoundingClientRect()` width & height > 0
237
+ 2. computed `visibility != hidden`, `opacity > 0`, `pointer-events != none`
238
+ 3. no ancestor with `display:none`
239
+ 4. rect intersects the viewport (with 1 px tolerance) — kills off-screen carousel items
240
+ 5. not inside `header` when the header is translated off (`translate-y-*` ≠ 0)
241
+ 6. exclusions: carousel `Previous/Next/Go to page` buttons, `aside` drawer, `.md\:hidden` duplicates, `.vjs-hidden`, hover-popup links (collapsed into the card block)
242
+
243
+ Dedupe: any candidate inside `div.group` collapses to the group → one block per card.
244
+
245
+ Direction scoring from the focused rect `F` to candidate `C`:
246
+
247
+ ```
248
+ must be strictly in direction (C.left >= F.right - overlapTol for →, etc.)
249
+ primary = distance along the axis
250
+ secondary = misalignment perpendicular (centre-to-centre), weighted ×2
251
+ score = primary + 2 * secondary
252
+ ```
253
+
254
+ Ties broken by DOM order. If no candidate in that direction and the focused
255
+ block is in a carousel → try paging. If still nothing → stay (no wrap).
256
+
257
+ Focus memory: per carousel row, remember the last focused card so ↑/↓ across
258
+ rows returns to it (Netflix behaviour). Per URL, remember the last block so
259
+ Back restores it.
260
+
261
+ ## 6. Rendering
262
+
263
+ Injected CSS (`<style id="tv-nav-style">`):
264
+
265
+ ```css
266
+ .tv-focus-ring { outline: 4px solid #fff; outline-offset: 3px; border-radius: 10px;
267
+ box-shadow: 0 0 0 8px rgba(0,0,0,.6); z-index: 20; position: relative; }
268
+ .group.tv-focus > .group-hover\:invisible { visibility: hidden !important; }
269
+ .group.tv-focus .group-hover\:block { display: block !important; }
270
+ .tv-focus .tv-sub-focus { outline: 3px solid #fff; outline-offset: 2px; }
271
+ .video-js.tv-focus-ring { outline-offset: -4px; }
272
+ #tv-badge { position: fixed; top: 12px; right: 12px; z-index: 99999; font: 14px/1 monospace;
273
+ padding: 6px 10px; background: rgba(0,0,0,.7); color: #fff; border-radius: 6px;
274
+ pointer-events: none; transition: opacity .3s; }
275
+ body.tv-mode { cursor: none; }
276
+ ```
277
+
278
+ Focused element also receives `scrollIntoView({block:'center'})` (vertical
279
+ only; horizontal handled by carousel paging).
280
+
281
+ ## 7. Lifecycle
282
+
283
+ 1. On load: inject CSS + badge, patch `history.pushState/replaceState`, listen
284
+ `popstate`, start a `MutationObserver` on `body` (debounced 150 ms) to
285
+ re-validate the focused element (if removed → refocus nearest / first block).
286
+ 2. Route handler on every URL change:
287
+ - `/watch/*` → wait for `.video-js` (poll 100 ms, max 10 s) → PLAYER mode.
288
+ - `/search` → focus the input (native), Back blurs.
289
+ - otherwise → NAV, restore per-URL memory or focus first card (fallback: first block).
290
+ 3. Keydown listener on `window` with `capture: true`; handled keys →
291
+ `preventDefault()` + `stopPropagation()` so `videojs-hotkeys` and Svelte
292
+ handlers do not double-act.
293
+
294
+ ## 8. Phase 2 — TizenBrew packaging
295
+
296
+ A TizenBrew *site modification* module is a plain npm package whose
297
+ `package.json` carries a flat manifest (per the
298
+ [module docs](https://github.com/reisxd/TizenBrew/blob/main/docs/MODULES.md)):
299
+
300
+ ```json
301
+ {
302
+ "packageType": "mods",
303
+ "appName": "PhimPal TV",
304
+ "websiteURL": "https://phimpal.com/",
305
+ "main": "mod.js",
306
+ "keys": ["MediaPlayPause", "MediaPlay", "MediaPause", "ChannelUp", "ChannelDown"]
307
+ }
308
+ ```
309
+
310
+ TizenBrew opens `websiteURL`, injects `main` into the page and registers `keys`
311
+ through the TVInputDevice API. Modules are installed by npm package name in the
312
+ module manager, so publishing to npm is the delivery step.
313
+
314
+ `build.js` derives `mod.js` from `phimpal-tv.user.js` (the single source):
315
+ it strips the userscript header and flips three flags — `keyboardEmulation`
316
+ off, `debug` behind `localStorage['tv-debug']`, `hideCursor` on — then prepends
317
+ a defensive `tizen.tvinputdevice.registerKey` loop and re-checks the bundle
318
+ (required symbols present, no leftover header, parses).
319
+
320
+ ### TV-specific behaviour in the engine
321
+
322
+ | Concern | Resolution |
323
+ |---|---|
324
+ | Leaving the module | Return on `/` is **not** consumed, so TizenBrew handles the exit. Elsewhere it means back / close a scope. |
325
+ | Fullscreen | `requestFullscreen` first; if it throws, rejects, or nothing happened after 400 ms, `.tv-fs` stretches the player over the viewport (header/footer hidden). `isFs()` covers both, so the key map is unchanged. |
326
+ | Hover menus without a pointer | The avatar menu mounts on `mouseenter` but the site fades it in only while the node truly matches `:hover`. The module waits for the node and applies `.tv-force-visible` (`opacity/visibility/pointer-events !important`). Note the 150 ms fade: read state after ~500 ms, not immediately. |
327
+ | Pointer | `cursor: none` everywhere in the TV build. |
328
+
329
+ ### Verified on the built module (browser pane, Tizen key codes only)
330
+
331
+ Keyboard codes ignored; 37/38/39/40/13 navigate; 10009 backs out; 10252
332
+ toggles playback; 427/428 step the seek cursor by 5 min; the CSS fullscreen
333
+ fallback fills the viewport and Return leaves it; Return from `/watch/:id`
334
+ lands on the title page (the `/api/play/` redirect does not stay in history);
335
+ Return on `/` is passed through to the platform.
336
+
337
+ ### Still unverified (needs the real TV)
338
+
339
+ HLS playback in the Tizen webview (Video.js uses MSE, expected to work),
340
+ whether `requestFullscreen` is accepted there or the fallback kicks in, cookie
341
+ persistence for the login across TizenBrew restarts, and remote key repeat
342
+ timing (the seek acceleration window is tuned at 800 ms).