From 101b5843e92136a8a7e06e9935bfec4c501faed5 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 14 Oct 2026 09:16:39 +0000 Subject: [PATCH] =?UTF-8?q?0.3.2:=20big=20=E2=96=B2=20=E2=96=BC=20page=20b?= =?UTF-8?q?uttons=20on=20every=20list=20for=20TV=20browsers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 long lists were out of reach. The channel list, Movies & Shows, the guide and both category rails get large page-up/page-down buttons; each press moves a screen and puts the remote's focus on the first row in view (the guide moves its view and focused row together). On by default on TV browsers (isTvBrowser: AFT*, Silk, Android TV, webOS, Tizen...); ?pager=on|off overrides it on any device and is remembered. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- package.json | 2 +- src/version.js | 2 +- web/public/app.css | 65 ++++++++++++++++++++++++++++++++++++ web/src/app.js | 83 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 3 deletions(-) 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();