Compose for TV UI, generic + Dispatcharr sources, device-flow login, favorites, stream profiles, richer remote

- Compose for TV scaffold: D-pad app shell (Live/Einstellungen), channel list
  with focus handling, fullscreen player with auto-hiding overlay; zapping via
  channel/D-pad keys (closes #2)
- Generic source end-to-end: M3U + XMLTV configurable on the TV, Now/Next with
  progress in the list, channel cache (closes #3)
- OIDC device-flow login: server URL is the only input, issuer/client id come
  from the fork's status endpoint; QR + user code screen, silent refresh,
  logout (closes #4)
- Dispatcharr source via Bearer API: channels, groups, EPG grid; switchable
  against the generic source (closes #5)
- Per-user favorites: star via long-press, favorites filter, backend-synced
  (closes #6)
- Stream profile selection (Standard/Passthrough/audiofix/720p from the
  backend profile list), applied per stream URL (closes #7)
- Phone remote: Now/Next lines, favorites star + filter over the WebSocket
  protocol (closes #8)

Device verification pending (TV currently in use).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-25 01:35:48 +02:00
parent 358b4c6a47
commit 0cf22204d3
19 changed files with 1817 additions and 455 deletions

View File

@@ -241,7 +241,7 @@
const state = {
ws: null, connected: false, authorized: false,
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
channels: [], playlistUrl: '',
channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false,
retryDelay: 1000, volumeDragging: false, searchTerm: '',
};
@@ -316,6 +316,7 @@
$('tv-name').textContent = msg.device || 'TV';
if (msg.status) { state.status = msg.status; }
if (msg.channels) { state.channels = msg.channels; }
if (msg.extras) { state.extras = msg.extras; }
state.playlistUrl = msg.playlistUrl || '';
$('pair-error').textContent = '';
showView('view-remote');
@@ -329,6 +330,7 @@
case 'channels':
state.channels = msg.channels || [];
state.playlistUrl = msg.playlistUrl || '';
if (msg.extras) { state.extras = msg.extras; }
renderChannels();
break;
case 'toast':
@@ -383,8 +385,24 @@
$('setup').classList.add('open');
return;
}
const favSet = new Set(state.extras.favorites || []);
const favOn = state.extras.favoritesSupported && state.favOnly;
const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
if (state.extras.favoritesSupported) {
const bar = document.createElement('div');
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px';
[['Alle', false], ['★ Favoriten', true]].forEach(([label, val]) => {
const chip = document.createElement('button');
chip.textContent = label;
chip.style.cssText = 'padding:7px 14px;border-radius:999px;font-size:12px;background:' +
(state.favOnly === val ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
chip.addEventListener('click', () => { state.favOnly = val; renderChannels(); });
bar.appendChild(chip);
});
list.appendChild(bar);
}
const frag = document.createDocumentFragment();
filtered.slice(0, 500).forEach((c) => {
shown.slice(0, 500).forEach((c) => {
const idx = state.channels.indexOf(c);
const btn = document.createElement('button');
btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : '');
@@ -403,12 +421,38 @@
const name = document.createElement('span');
name.className = 'name'; name.textContent = c.name;
meta.appendChild(name);
if (c.group) {
const info = (state.extras.nowNext || [])[idx];
if (info && info.now) {
const now = document.createElement('span');
now.className = 'grp';
const pct = info.stop > info.start
? Math.round(100 * (Date.now() - info.start) / (info.stop - info.start)) : 0;
now.textContent = info.now;
meta.appendChild(now);
const barWrap = document.createElement('span');
barWrap.style.cssText = 'display:block;height:2px;border-radius:1px;background:#1e2126;margin-top:4px;max-width:180px';
const bar = document.createElement('span');
bar.style.cssText = 'display:block;height:2px;border-radius:1px;background:#5fd4c4;width:' +
Math.max(0, Math.min(100, pct)) + '%';
barWrap.appendChild(bar);
meta.appendChild(barWrap);
} else if (c.group) {
const grp = document.createElement('span');
grp.className = 'grp'; grp.textContent = c.group;
meta.appendChild(grp);
}
btn.appendChild(num); btn.appendChild(meta);
if (state.extras.favoritesSupported && c.backendId) {
const star = document.createElement('span');
const isFav = favSet.has(c.backendId);
star.textContent = isFav ? '★' : '☆';
star.style.cssText = 'font-size:20px;padding:8px;color:' + (isFav ? '#5fd4c4' : '#6b717a');
star.addEventListener('click', (e) => {
e.stopPropagation();
send({ type: 'set_favorite', id: c.backendId });
});
btn.appendChild(star);
}
btn.addEventListener('click', () => playChannel(c));
frag.appendChild(btn);
});