diff --git a/README.md b/README.md
index d381c68..a546141 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ hdtilt is a player. It ships with no content. You add a playlist from a provider
- **A global nav** with Live TV (with a filter box), Movies & Shows, TV Guide, ★ Favorites, Recent, Account and Settings. It is always on screen outside the picture; over the picture, Back or Menu brings it up, focused for the remote.
- **iPhone and iPad Safari:** they have no Media Source Extensions, so they cannot play an MPEG-TS stream directly. hdtilt asks an Xtream line for its HLS version when it offers one, and otherwise the server repackages the stream as HLS (`ffmpeg -c copy`, no transcoding, shared by everyone watching that channel). Self-hosters need `ffmpeg` on the PATH, or `HDTILT_FFMPEG`.
- **Favorites:** tap ☆ on any channel row or on the channel banner, or press F. They sit under ★ Favorites in the nav and sync with your account.
-- **The TiviMate basics:** ↑/↓ zaps channels, OK opens the list, ◀ opens groups, digits jump to a channel number, Back returns to the last channel. Favorites and recents are built in. Every filter (Live TV, the guide, Movies & Shows, the CLI and MCP) finds all the words you type in any order, ignoring case, accents and punctuation, so `espn us` finds "US| ESPN HD" and `sports1` finds "Sports 1". Works with a mouse, touch, a keyboard or a TV remote (Fire TV, Android TV, webOS, Tizen keys).
+- **The TiviMate basics:** ↑/↓ zaps channels, OK opens the list, ◀ opens groups, digits jump to a channel number, Back returns to the last channel. Favorites and recents are built in. Every filter (Live TV, the guide, Movies & Shows, the CLI and MCP) finds all the words you type in any order, ignoring case, accents and punctuation, so `espn us` finds "US| ESPN HD" and `sports1` finds "Sports 1". On a TV browser (Silk on Fire TV, Android TV, webOS, Tizen) big ▲ ▼ buttons page through every list, the guide and the category rails, because a TV pointer cannot scroll a list inside the page; `?pager=on` or `?pager=off` turns them on or off on any device. Works with a mouse, touch, a keyboard or a TV remote (Fire TV, Android TV, webOS, Tizen keys).
## Accounts
diff --git a/package.json b/package.json
index 505f324..b6dee7a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "hdtilt",
- "version": "0.3.1",
+ "version": "0.3.2",
"description": "Open-source IPTV player: M3U, Xtream Codes and XMLTV, with a TV guide. Web/PWA, desktop, CLI, TUI, MCP and API.",
"license": "MIT",
"author": {
diff --git a/src/version.js b/src/version.js
index d9a6219..7195b10 100644
--- a/src/version.js
+++ b/src/version.js
@@ -1 +1 @@
-export const VERSION = '0.3.1';
+export const VERSION = '0.3.2';
diff --git a/web/public/app.css b/web/public/app.css
index 9de8712..0371593 100644
--- a/web/public/app.css
+++ b/web/public/app.css
@@ -1217,3 +1217,68 @@ body.gated .nav-items button:not(#nav-account) {
grid-template-columns: 4.2rem 1fr;
}
}
+
+/* ---- page buttons (a TV browser's pointer cannot scroll an inner list) ---- */
+.pager {
+ display: none;
+}
+.pager-on .pager {
+ display: flex;
+}
+.pager button {
+ border: 1px solid var(--line);
+ background: rgba(22, 30, 41, 0.92);
+ color: var(--text);
+ cursor: pointer;
+ line-height: 1;
+}
+.pager button:hover,
+.pager button:focus-visible {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: var(--accent-ink);
+}
+.channels-wrap {
+ position: relative;
+}
+.rows-pager {
+ position: absolute;
+ right: 0.6rem;
+ top: 50%;
+ transform: translateY(-50%);
+ flex-direction: column;
+ gap: 1rem;
+ z-index: 2;
+}
+.rows-pager button {
+ width: 5.5rem;
+ height: 5.5rem;
+ border-radius: 1.2rem;
+ font-size: 2.4rem;
+}
+/* Rows stop short of the buttons, so nothing is under them. */
+.pager-on .channels .row {
+ right: 7rem;
+}
+.guide-pager {
+ top: auto;
+ bottom: 1.2rem;
+ transform: none;
+ right: 1.2rem;
+}
+.groups .rail-pager {
+ position: sticky;
+ bottom: -1.4rem;
+ gap: 0.5rem;
+ padding: 0.6rem 0 0.2rem;
+ background: var(--panel-2);
+}
+.groups .rail-pager button {
+ flex: 1;
+ width: auto;
+ height: 3.6rem;
+ justify-content: center;
+ font-size: 1.6rem;
+ border-radius: 0.8rem;
+ color: var(--text);
+}
diff --git a/web/src/app.js b/web/src/app.js
index 7c2367a..02c8c4d 100644
--- a/web/src/app.js
+++ b/web/src/app.js
@@ -796,6 +796,7 @@ function renderGroups() {
b.onclick = () => pickGroup(i);
nav.append(b);
});
+ railPager(nav);
nav.querySelector('.focus')?.scrollIntoView({ block: 'nearest' });
}
@@ -1049,6 +1050,7 @@ function renderVodRail() {
b.onclick = () => pickVodRail(i);
rail.append(b);
});
+ railPager(rail);
rail.querySelector('.focus')?.scrollIntoView({ block: 'nearest' });
}
@@ -2571,6 +2573,87 @@ addEventListener('resize', () => {
});
// For tests and the curious: the whole app state, read-only by convention.
+/* ---------------------------------------------------------- page buttons */
+// Silk on a Fire TV drives the page with a pointer, and that pointer cannot
+// scroll a list inside the page (the page itself never scrolls), so a long
+// list could only be reached by filtering. Big ▲ ▼ buttons page through
+// every list instead. On by default on a TV browser; ?pager=on|off overrides
+// that on any device, and is remembered.
+
+{
+ const asked = new URLSearchParams(location.search).get('pager');
+ if (asked === 'on' || asked === 'off') store.prefs.write('pager', asked);
+}
+const pagerOn = store.prefs.read('pager', isTv ? 'on' : 'off') === 'on';
+document.body.classList.toggle('pager-on', pagerOn);
+
+const PAGER_HTML =
+ '' +
+ '';
+
+function pager(cls, onPage) {
+ const p = el('div', `pager ${cls}`, PAGER_HTML);
+ p.addEventListener('click', (e) => {
+ const b = e.target.closest('[data-page]');
+ if (!b) return;
+ e.stopPropagation();
+ onPage(Number(b.dataset.page));
+ });
+ return p;
+}
+
+/** A list of rows: scroll one screen, and the remote's focus lands on the first row in view. */
+function pageList(box, dir, setFocus) {
+ const rp = rowPx();
+ const rows = Math.max(1, Math.floor(box.clientHeight / rp) - 1);
+ box.scrollTop = Math.max(0, box.scrollTop + dir * rows * rp);
+ setFocus(Math.ceil(box.scrollTop / rp));
+}
+
+/** A rail of groups or categories (re-rendered often, so it gets a fresh pair each time). */
+function railPager(rail) {
+ if (!pagerOn) return;
+ rail.append(pager('rail-pager', (dir) => rail.scrollBy({ top: dir * rail.clientHeight * 0.8 })));
+}
+
+if (pagerOn) {
+ $('list')
+ .querySelector('.channels-wrap')
+ .append(
+ pager('rows-pager', (dir) =>
+ pageList($('channels'), dir, (i) => {
+ if (!S.view.length) return;
+ S.pane = 'channels';
+ S.focus = Math.min(S.view.length - 1, i);
+ renderChannels();
+ }),
+ ),
+ );
+ $('vod')
+ .querySelector('.channels-wrap')
+ .append(
+ pager('rows-pager', (dir) =>
+ pageList($('vod-items'), dir, (i) => {
+ if (!V.view.length) return;
+ V.pane = 'items';
+ V.focus = Math.min(V.view.length - 1, i);
+ renderVodItems();
+ }),
+ ),
+ );
+ $('guide').append(
+ pager('rows-pager guide-pager', (dir) => {
+ // The view and the focused row move together, a screen at a time.
+ const { grid, rowH } = guideDims();
+ const n = Math.max(1, Math.floor(grid.clientHeight / rowH) - 2);
+ const last = Math.max(0, G.list.length - 1);
+ G.row = Math.min(last, Math.max(0, G.row + dir * n));
+ G.top = Math.min(last, Math.max(0, G.top + dir * n));
+ renderGuide();
+ }),
+ );
+}
+
globalThis.hdtilt = S;
globalThis.hdtiltVod = V;
boot();