Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion src/version.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const VERSION = '0.3.1';
export const VERSION = '0.3.2';
65 changes: 65 additions & 0 deletions web/public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
83 changes: 83 additions & 0 deletions web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,7 @@ function renderGroups() {
b.onclick = () => pickGroup(i);
nav.append(b);
});
railPager(nav);
nav.querySelector('.focus')?.scrollIntoView({ block: 'nearest' });
}

Expand Down Expand Up @@ -1049,6 +1050,7 @@ function renderVodRail() {
b.onclick = () => pickVodRail(i);
rail.append(b);
});
railPager(rail);
rail.querySelector('.focus')?.scrollIntoView({ block: 'nearest' });
}

Expand Down Expand Up @@ -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 =
'<button type="button" data-page="-1" tabindex="-1" aria-label="Page up">▲</button>' +
'<button type="button" data-page="1" tabindex="-1" aria-label="Page down">▼</button>';

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();
Loading