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/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).
|