Bootstrap Castarr from NodeCast prototype

Imported the native TV app (Kotlin, ExoPlayer, embedded remote server, QR
pairing) plus CONTEXT.md and ADRs 0001-0005. Rename, Compose for TV UI and
the Dispatcharr integration follow as tracked issues.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 00:09:00 +02:00
commit 6c5fee849e
44 changed files with 2418 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application
android:allowBackup="true"
android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.NodeCast">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize|density">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,502 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0a0b0d">
<title>NodeCast Remote</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600&display=swap">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; }
body {
font-family: 'Space Grotesk', system-ui, sans-serif;
background: #0a0b0d; color: #f2f3f5;
display: flex; flex-direction: column;
padding-top: env(safe-area-inset-top);
user-select: none; -webkit-user-select: none;
}
button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
input { font: inherit; color: #f2f3f5; }
header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 24px 0;
}
.wordmark { display: flex; align-items: center; gap: 9px; font-size: 14px; font-weight: 500;
letter-spacing: .2em; text-transform: uppercase; }
.tvchip { display: flex; align-items: center; gap: 8px; border: 1px solid #1e2126;
border-radius: 999px; padding: 8px 14px; font-size: 12px; color: #9aa0a8; letter-spacing: .03em; }
.dot { width: 6px; height: 6px; border-radius: 50%; background: #5fd4c4; }
.dot.off { background: #e5484d; }
main { flex: 1 1 auto; overflow-y: auto; display: flex; flex-direction: column; }
.view { flex: 1 1 auto; display: none; flex-direction: column; }
.view.active { display: flex; }
/* --- Steuerung --- */
#view-remote { justify-content: center; gap: 44px; padding: 24px; }
.nowcard {
position: relative; height: 200px; border-radius: 24px; overflow: hidden;
background: radial-gradient(130% 110% at 70% 20%, #16324a 0%, #0d1b2e 55%, #080c14 100%);
box-shadow: 0 0 0 1px rgba(255,255,255,.06);
}
.nowcard .glow { position: absolute; top: -40px; right: -20px; width: 220px; height: 220px;
border-radius: 50%; background: radial-gradient(circle, rgba(95,212,196,.18) 0%, rgba(95,212,196,0) 60%); }
.nowcard .stop {
position: absolute; top: 14px; right: 14px; width: 44px; height: 44px; border-radius: 50%;
display: none; align-items: center; justify-content: center;
background: rgba(10,11,13,.45); border: 1px solid rgba(255,255,255,.1);
}
.nowcard.playing .stop { display: flex; }
.nowcard .info {
position: absolute; left: 0; right: 0; bottom: 0; padding: 44px 22px 20px;
display: flex; flex-direction: column; gap: 5px;
background: linear-gradient(to top, rgba(8,12,20,.85) 0%, rgba(8,12,20,0) 100%);
}
.liverow { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 500;
letter-spacing: .16em; color: #b8bdc4; }
.livedot { width: 6px; height: 6px; border-radius: 50%; background: #e5484d; }
#now-title { font-size: 24px; font-weight: 500; letter-spacing: -.01em; }
#now-sub { font-size: 13px; font-weight: 300; color: #9aa0a8; }
.transport { display: flex; align-items: center; justify-content: center; gap: 34px; }
.btn-side { width: 56px; height: 56px; border-radius: 50%; border: 1px solid #1e2126;
display: flex; align-items: center; justify-content: center; }
.btn-side:active { background: #16181c; }
.btn-main { width: 88px; height: 88px; border-radius: 50%; background: #5fd4c4;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 12px 44px rgba(95,212,196,.28); transition: transform .1s; }
.btn-main:active { transform: scale(.94); }
.btn-main:disabled { opacity: .35; box-shadow: none; }
.volrow { display: flex; align-items: center; gap: 16px; padding: 0 8px; }
input[type=range] { flex: 1; appearance: none; -webkit-appearance: none; height: 3px;
border-radius: 2px; background: #1e2126; outline: none; }
input[type=range]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none;
width: 22px; height: 22px; border-radius: 50%; background: #f2f3f5;
box-shadow: 0 2px 8px rgba(0,0,0,.5); }
/* --- Sender --- */
#view-channels { padding: 18px 12px 0; gap: 14px; }
.chead { display: flex; align-items: center; gap: 10px; padding: 0 12px; }
.chead h1 { font-size: 26px; font-weight: 500; letter-spacing: -.01em; flex: 1; }
.iconbtn { width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center;
justify-content: center; }
.iconbtn:active { background: #16181c; }
.searchrow { display: flex; align-items: center; gap: 10px; background: #121418;
border-radius: 12px; padding: 0 16px; margin: 0 12px; }
.searchrow input { flex: 1; background: none; border: none; outline: none; height: 48px;
font-size: 15px; font-weight: 300; }
.searchrow input::placeholder { color: #6b717a; }
#setup { display: none; flex-direction: column; gap: 10px; margin: 0 12px; padding: 16px;
background: #101216; border-radius: 14px; }
#setup.open { display: flex; }
#setup label { font-size: 12px; color: #9aa0a8; letter-spacing: .04em; }
#setup input { background: #0a0b0d; border: 1px solid #1e2126; border-radius: 10px;
height: 46px; padding: 0 14px; font-size: 14px; outline: none; width: 100%; }
#setup input:focus { border-color: #5fd4c4; }
.btn-accent { height: 46px; border-radius: 10px; background: #5fd4c4; color: #07110f;
font-weight: 500; font-size: 14px; }
.btn-accent:active { opacity: .85; }
#channel-list { display: flex; flex-direction: column; padding-bottom: 12px; }
.chan { display: flex; align-items: center; gap: 14px; padding: 12px; border-radius: 14px;
text-align: left; width: 100%; }
.chan:active { background: #121418; }
.chan.current { background: rgba(95,212,196,.07); }
.chan .num { width: 44px; height: 44px; border-radius: 12px; background: #17191e; flex: none;
display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 500;
color: #7d838c; overflow: hidden; }
.chan.current .num { background: #16324a; color: #9fc3d8; }
.chan .num img { width: 100%; height: 100%; object-fit: contain; }
.chan .meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.chan .name { font-size: 16px; font-weight: 400; color: #e6e8ea; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.chan.current .name { color: #5fd4c4; font-weight: 500; }
.chan .grp { font-size: 12px; font-weight: 300; color: #6b717a; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.empty { padding: 40px 24px; text-align: center; color: #6b717a; font-size: 14px;
font-weight: 300; line-height: 1.5; }
/* --- Tabs --- */
nav { display: flex; border-top: 1px solid #16181c; padding: 10px 24px;
padding-bottom: calc(14px + env(safe-area-inset-bottom)); gap: 12px; flex: none; }
nav button { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 5px;
padding: 6px 0; font-size: 11px; color: #6b717a; letter-spacing: .04em; }
nav button.active { color: #5fd4c4; font-weight: 500; }
nav button svg { stroke: #6b717a; }
nav button.active svg { stroke: #5fd4c4; }
/* --- Pairing --- */
#view-pair { justify-content: center; align-items: center; gap: 26px; padding: 24px; text-align: center; }
#view-pair h1 { font-size: 24px; font-weight: 500; }
#view-pair p { font-size: 14px; color: #9aa0a8; font-weight: 300; max-width: 280px; line-height: 1.5; }
#code-input { background: #0a0b0d; border: 1px solid #1e2126; border-radius: 12px; height: 56px;
width: 180px; text-align: center; font-size: 26px; letter-spacing: .3em; outline: none; }
#code-input:focus { border-color: #5fd4c4; }
#pair-btn { width: 180px; }
#pair-error { color: #e5484d; font-size: 13px; min-height: 18px; }
#toast { position: fixed; left: 50%; bottom: 110px; transform: translateX(-50%) translateY(20px);
background: #1a1d22; border: 1px solid #23262b; color: #e6e8ea; font-size: 13px;
padding: 12px 20px; border-radius: 999px; opacity: 0; pointer-events: none;
transition: all .25s; white-space: nowrap; max-width: 90vw; overflow: hidden; text-overflow: ellipsis; }
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
</style>
</head>
<body>
<header>
<div class="wordmark">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#5fd4c4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="13" rx="2"/><path d="M8.5 22h7"/></svg>
NodeCast
</div>
<div class="tvchip"><span class="dot off" id="conn-dot"></span><span id="tv-name">Getrennt</span></div>
</header>
<main>
<!-- Pairing -->
<section class="view" id="view-pair">
<h1>Mit dem TV koppeln</h1>
<p>Gib den 4-stelligen Code ein, der unten auf dem TV-Bildschirm steht.</p>
<input id="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="4" placeholder="····">
<button class="btn-accent" id="pair-btn">Verbinden</button>
<div id="pair-error"></div>
</section>
<!-- Steuerung -->
<section class="view" id="view-remote">
<div class="nowcard" id="nowcard">
<div class="glow"></div>
<button class="stop" id="btn-stop" aria-label="Stopp">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#e6e8ea"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
<div class="info">
<div class="liverow" id="liverow" style="visibility:hidden"><span class="livedot"></span><span id="live-label">LIVE</span></div>
<div id="now-title">Nichts läuft</div>
<div id="now-sub">Wähle einen Sender oder starte eine URL</div>
</div>
</div>
<div class="transport">
<button class="btn-side" id="btn-prev" aria-label="Vorheriger Sender">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#c8ccd2" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M19 20L9 12l10-8v16z"/><path d="M5 19V5"/></svg>
</button>
<button class="btn-main" id="btn-play" aria-label="Wiedergabe/Pause">
<svg id="ic-play" width="30" height="30" viewBox="0 0 24 24" fill="#07110f" style="display:none"><path d="M8 5v14l11-7L8 5z"/></svg>
<svg id="ic-pause" width="30" height="30" viewBox="0 0 24 24" fill="#07110f"><path d="M7 4.5h3.4v15H7zM13.6 4.5H17v15h-3.4z"/></svg>
</button>
<button class="btn-side" id="btn-next" aria-label="Nächster Sender">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#c8ccd2" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4l10 8-10 8V4z"/><path d="M19 5v14"/></svg>
</button>
</div>
<div class="volrow">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6b717a" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H2v6h4l5 4V5z"/></svg>
<input type="range" id="volume" min="0" max="100" value="50" aria-label="Lautstärke">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6b717a" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 010 7"/><path d="M18.4 5.6a9 9 0 010 12.8"/></svg>
</div>
</section>
<!-- Sender -->
<section class="view" id="view-channels">
<div class="chead">
<h1>Sender</h1>
<button class="iconbtn" id="btn-setup" aria-label="Playlist einrichten">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="#9aa0a8" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09a1.65 1.65 0 001.51-1 1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33h.09A1.65 1.65 0 0010 3.09V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51h.09a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82v.09a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
</button>
</div>
<div id="setup">
<label for="playlist-input">M3U-Playlist-URL</label>
<input id="playlist-input" type="url" placeholder="https://…/playlist.m3u" autocapitalize="off" autocorrect="off">
<button class="btn-accent" id="btn-load-playlist">Playlist laden</button>
<label for="url-input" style="margin-top:6px">Oder direkte Stream-URL</label>
<input id="url-input" type="url" placeholder="https://…/stream.m3u8" autocapitalize="off" autocorrect="off">
<button class="btn-accent" id="btn-play-url">Auf TV abspielen</button>
</div>
<div class="searchrow">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#6b717a" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
<input id="search" type="search" placeholder="Sender suchen" autocapitalize="off">
</div>
<div id="channel-list"></div>
</section>
</main>
<nav id="tabs" style="display:none">
<button data-view="view-remote" class="active">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18.5h2"/></svg>
Steuerung
</button>
<button data-view="view-channels">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h10"/></svg>
Sender
</button>
</nav>
<div id="toast"></div>
<script>
(() => {
'use strict';
const $ = (id) => document.getElementById(id);
const state = {
ws: null, connected: false, authorized: false,
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
channels: [], playlistUrl: '',
retryDelay: 1000, volumeDragging: false, searchTerm: '',
};
// --- pairing credentials ---
const params = new URLSearchParams(location.search);
if (params.get('t')) {
localStorage.setItem('nodecast_token', params.get('t'));
history.replaceState(null, '', location.pathname);
}
const getToken = () => localStorage.getItem('nodecast_token') || '';
let manualCode = '';
const deviceName = (() => {
const ua = navigator.userAgent;
if (/iPhone/.test(ua)) return 'iPhone';
if (/iPad/.test(ua)) return 'iPad';
if (/Android/.test(ua)) return 'Android-Handy';
return 'Browser';
})();
// --- views ---
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === id));
document.querySelectorAll('nav button').forEach(b => b.classList.toggle('active', b.dataset.view === id));
$('tabs').style.display = id === 'view-pair' ? 'none' : 'flex';
}
document.querySelectorAll('nav button').forEach(b =>
b.addEventListener('click', () => showView(b.dataset.view)));
// --- websocket ---
function connect() {
if (state.ws && (state.ws.readyState === 0 || state.ws.readyState === 1)) return;
const ws = new WebSocket('ws://' + location.host + '/');
state.ws = ws;
ws.onopen = () => {
state.connected = true;
const hello = { type: 'hello', name: deviceName };
if (getToken()) hello.token = getToken();
else if (manualCode) hello.code = manualCode;
else { showView('view-pair'); ws.close(); return; }
ws.send(JSON.stringify(hello));
};
ws.onmessage = (ev) => {
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
handle(msg);
};
ws.onclose = () => {
state.connected = false; state.authorized = false;
renderConnection();
setTimeout(connect, state.retryDelay);
state.retryDelay = Math.min(state.retryDelay * 1.6, 10000);
};
ws.onerror = () => ws.close();
}
function send(obj) {
if (state.ws && state.ws.readyState === 1 && state.authorized) {
state.ws.send(JSON.stringify(obj));
}
}
function handle(msg) {
switch (msg.type) {
case 'welcome':
state.authorized = true;
state.retryDelay = 1000;
if (msg.token) localStorage.setItem('nodecast_token', msg.token);
$('tv-name').textContent = msg.device || 'TV';
if (msg.status) { state.status = msg.status; }
if (msg.channels) { state.channels = msg.channels; }
state.playlistUrl = msg.playlistUrl || '';
$('pair-error').textContent = '';
showView('view-remote');
renderAll();
break;
case 'status':
state.status = msg;
renderStatus();
renderChannels();
break;
case 'channels':
state.channels = msg.channels || [];
state.playlistUrl = msg.playlistUrl || '';
renderChannels();
break;
case 'toast':
toast(msg.message);
break;
case 'error':
if (msg.error === 'bad_code' || msg.error === 'rate_limited') {
localStorage.removeItem('nodecast_token');
manualCode = '';
$('pair-error').textContent = msg.error === 'rate_limited'
? 'Zu viele Versuche — kurz warten.' : 'Falscher Code.';
showView('view-pair');
}
break;
}
}
// --- rendering ---
function renderConnection() {
$('conn-dot').classList.toggle('off', !state.authorized);
if (!state.authorized) $('tv-name').textContent = 'Getrennt';
}
function renderStatus() {
renderConnection();
const s = state.status;
const playing = s.state === 'playing' || s.state === 'buffering';
const hasMedia = s.state !== 'idle' && s.channel;
$('nowcard').classList.toggle('playing', !!hasMedia);
$('now-title').textContent = hasMedia ? s.channel : 'Nichts läuft';
$('now-sub').textContent = hasMedia
? (s.state === 'buffering' ? 'Lädt…' : s.state === 'paused' ? 'Pausiert'
: s.state === 'error' ? 'Wiedergabefehler' : (s.group || 'Wird abgespielt'))
: 'Wähle einen Sender oder starte eine URL';
$('liverow').style.visibility = s.live ? 'visible' : 'hidden';
$('ic-play').style.display = playing ? 'none' : 'block';
$('ic-pause').style.display = playing ? 'block' : 'none';
$('btn-play').disabled = !hasMedia;
if (!state.volumeDragging && typeof s.volume === 'number') {
$('volume').value = Math.round(s.volume * 100);
}
}
function renderChannels() {
const list = $('channel-list');
const term = state.searchTerm.toLowerCase();
const filtered = term
? state.channels.filter(c => (c.name + ' ' + c.group).toLowerCase().includes(term))
: state.channels;
if (!state.channels.length) {
list.innerHTML = '<div class="empty">Noch keine Sender.<br>Lade oben über das Zahnrad eine M3U-Playlist.</div>';
$('setup').classList.add('open');
return;
}
const frag = document.createDocumentFragment();
filtered.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' : '');
const num = document.createElement('span');
num.className = 'num';
if (c.logo) {
const img = document.createElement('img');
img.src = c.logo; img.loading = 'lazy'; img.alt = '';
img.onerror = () => { img.remove(); num.textContent = idx + 1; };
num.appendChild(img);
} else {
num.textContent = idx + 1;
}
const meta = document.createElement('span');
meta.className = 'meta';
const name = document.createElement('span');
name.className = 'name'; name.textContent = c.name;
meta.appendChild(name);
if (c.group) {
const grp = document.createElement('span');
grp.className = 'grp'; grp.textContent = c.group;
meta.appendChild(grp);
}
btn.appendChild(num); btn.appendChild(meta);
btn.addEventListener('click', () => playChannel(c));
frag.appendChild(btn);
});
list.innerHTML = '';
list.appendChild(frag);
if (state.playlistUrl) $('playlist-input').value = state.playlistUrl;
}
function renderAll() { renderStatus(); renderChannels(); }
// --- actions ---
function playChannel(c) {
send({ type: 'play', url: c.url, name: c.name, group: c.group });
showView('view-remote');
toast(c.name);
}
function step(dir) {
if (!state.channels.length) return;
const cur = state.channels.findIndex(c => c.name === state.status.channel);
const next = cur < 0 ? 0 : (cur + dir + state.channels.length) % state.channels.length;
playChannel(state.channels[next]);
}
$('btn-play').addEventListener('click', () => send({ type: 'toggle' }));
$('btn-stop').addEventListener('click', () => send({ type: 'stop' }));
$('btn-prev').addEventListener('click', () => step(-1));
$('btn-next').addEventListener('click', () => step(1));
let volTimer = null;
const vol = $('volume');
vol.addEventListener('input', () => {
state.volumeDragging = true;
clearTimeout(volTimer);
volTimer = setTimeout(() => {
send({ type: 'volume', value: vol.value / 100 });
state.volumeDragging = false;
}, 120);
});
$('btn-setup').addEventListener('click', () => $('setup').classList.toggle('open'));
$('btn-load-playlist').addEventListener('click', () => {
const url = $('playlist-input').value.trim();
if (!url) return;
send({ type: 'set_playlist', url });
toast('Playlist wird geladen…');
});
$('btn-play-url').addEventListener('click', () => {
const url = $('url-input').value.trim();
if (!url) return;
send({ type: 'play', url, name: url.split('/').pop() || url, group: '' });
showView('view-remote');
});
$('search').addEventListener('input', (e) => {
state.searchTerm = e.target.value;
renderChannels();
});
$('pair-btn').addEventListener('click', () => {
const code = $('code-input').value.trim();
if (code.length !== 4) { $('pair-error').textContent = 'Bitte 4 Ziffern eingeben.'; return; }
manualCode = code;
$('pair-error').textContent = '';
if (state.ws) state.ws.close(); else connect();
});
let toastTimer = null;
function toast(text) {
const t = $('toast');
t.textContent = text;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2200);
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
state.retryDelay = 1000;
connect();
send({ type: 'get_state' });
}
});
// --- boot ---
if (getToken()) { connect(); showView('view-remote'); }
else { showView('view-pair'); }
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,281 @@
package com.nodecast.tv
import android.app.Activity
import android.graphics.Color
import android.media.AudioManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.KeyEvent
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.media3.ui.PlayerView
import com.nodecast.tv.pairing.Pairing
import com.nodecast.tv.pairing.Qr
import com.nodecast.tv.player.PlayerController
import com.nodecast.tv.playlist.Channel
import com.nodecast.tv.playlist.PlaylistRepository
import com.nodecast.tv.server.ControlServer
import org.json.JSONObject
class MainActivity : Activity(), ControlServer.Listener {
private lateinit var playerController: PlayerController
private lateinit var server: ControlServer
private lateinit var playlist: PlaylistRepository
private lateinit var audioManager: AudioManager
private lateinit var pairingScreen: View
private lateinit var playerScreen: View
private lateinit var playerView: PlayerView
private lateinit var overlay: View
private lateinit var overlayChannel: TextView
private lateinit var overlayState: TextView
private lateinit var overlayLiveDot: View
private lateinit var overlayLiveLabel: TextView
private lateinit var overlayProgress: View
private lateinit var deviceChip: View
private lateinit var deviceName: TextView
private lateinit var pairingStatus: TextView
private val mainHandler = Handler(Looper.getMainLooper())
private val hideOverlay = Runnable { overlay.animate().alpha(0f).setDuration(400).start() }
private val ticker = object : Runnable {
override fun run() {
updateProgress()
server.broadcastStatus()
mainHandler.postDelayed(this, TICK_INTERVAL_MS)
}
}
private var connectedName: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
playlist = PlaylistRepository(this)
playerController = PlayerController(this) { onPlaybackChanged() }
bindViews()
setupPairingScreen()
server = ControlServer(this, this)
server.startServer()
}
private fun bindViews() {
pairingScreen = findViewById(R.id.pairing_screen)
playerScreen = findViewById(R.id.player_screen)
playerView = findViewById(R.id.player_view)
overlay = findViewById(R.id.overlay)
overlayChannel = findViewById(R.id.overlay_channel)
overlayState = findViewById(R.id.overlay_state)
overlayLiveDot = findViewById(R.id.overlay_live_dot)
overlayLiveLabel = findViewById(R.id.overlay_live_label)
overlayProgress = findViewById(R.id.overlay_progress)
deviceChip = findViewById(R.id.device_chip)
deviceName = findViewById(R.id.device_name)
pairingStatus = findViewById(R.id.pairing_status)
playerView.useController = false
playerView.player = playerController.player
overlayProgress.pivotX = 0f
}
private fun setupPairingScreen() {
val address = Pairing.lanAddress()
val qrImage = findViewById<ImageView>(R.id.qr_image)
val urlText = findViewById<TextView>(R.id.pairing_url)
val codeText = findViewById<TextView>(R.id.pairing_code)
codeText.text = getString(R.string.pairing_code, Pairing.code(this))
if (address != null) {
urlText.text = Pairing.remoteUrl(address)
qrImage.setImageBitmap(
Qr.encode(Pairing.pairingUrl(this, address), QR_SIZE_PX, Color.parseColor("#101216"))
)
} else {
urlText.text = getString(R.string.no_network)
}
}
// --- playback state → UI + remote ---
private fun onPlaybackChanged() {
val showPlayer = playerController.hasMedia
pairingScreen.visibility = if (showPlayer) View.GONE else View.VISIBLE
playerScreen.visibility = if (showPlayer) View.VISIBLE else View.GONE
val active = playerController.state == "playing" || playerController.state == "buffering"
playerScreen.keepScreenOn = active
mainHandler.removeCallbacks(ticker)
if (showPlayer) {
overlayChannel.text = playerController.channelName
val live = playerController.player.isCurrentMediaItemLive
overlayLiveDot.visibility = if (live) View.VISIBLE else View.GONE
overlayLiveLabel.text = if (live) getString(R.string.live) else ""
overlayState.text = when (playerController.state) {
"paused" -> getString(R.string.paused)
"buffering" -> getString(R.string.buffering)
"error" -> getString(R.string.playback_error, playerController.errorMessage.orEmpty())
else -> getString(R.string.playing)
}
updateProgress()
showOverlay(autoHide = playerController.state == "playing")
if (active) mainHandler.postDelayed(ticker, TICK_INTERVAL_MS)
}
onClientsChanged(-1, null)
server.broadcastStatus()
}
private fun updateProgress() {
val player = playerController.player
val fraction = if (player.isCurrentMediaItemLive || player.duration <= 0) {
1f
} else {
(player.currentPosition.toFloat() / player.duration).coerceIn(0f, 1f)
}
overlayProgress.scaleX = fraction
}
private fun showOverlay(autoHide: Boolean) {
mainHandler.removeCallbacks(hideOverlay)
overlay.animate().alpha(1f).setDuration(200).start()
if (autoHide) mainHandler.postDelayed(hideOverlay, OVERLAY_HIDE_DELAY_MS)
}
// --- ControlServer.Listener (called on main thread) ---
override fun onPlay(url: String, name: String, group: String) {
playerController.play(url, name, group)
}
override fun onTogglePlay() = playerController.toggle()
override fun onPause() = playerController.pause()
override fun onResume() = playerController.resume()
override fun onStopCast() = playerController.stop()
override fun onSeek(deltaSeconds: Long) {
playerController.seekBy(deltaSeconds)
showOverlay(autoHide = true)
}
override fun onVolume(value: Float) {
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, (value * max).toInt().coerceIn(0, max), 0)
server.broadcastStatus()
}
override fun onSetPlaylist(url: String) {
playlist.refresh(url) { result ->
mainHandler.post {
result
.onSuccess { server.broadcastChannels() }
.onFailure { server.broadcastToast(getString(R.string.playlist_error)) }
}
}
}
override fun onClientsChanged(count: Int, newestName: String?) {
if (newestName != null) connectedName = newestName
if (count >= 0) {
pairingStatus.text = if (count > 0) {
getString(R.string.paired_with, connectedName.orEmpty())
} else {
getString(R.string.ready_to_pair)
}
}
val chipVisible = connectedName != null && playerController.hasMedia
deviceChip.visibility = if (chipVisible) View.VISIBLE else View.GONE
deviceName.text = connectedName.orEmpty()
}
override fun currentStatus(): JSONObject {
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val vol = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
val player = playerController.player
return JSONObject()
.put("state", playerController.state)
.put("channel", playerController.channelName)
.put("group", playerController.channelGroup)
.put("live", player.isCurrentMediaItemLive)
.put("seekable", player.isCurrentMediaItemSeekable)
.put("position", player.currentPosition.coerceAtLeast(0))
.put("duration", player.duration.coerceAtLeast(0))
.put("volume", if (max > 0) vol.toDouble() / max else 0.0)
.put("error", playerController.errorMessage ?: "")
}
override fun currentChannels(): List<Channel> = playlist.channels
override fun currentPlaylistUrl(): String = playlist.playlistUrl
// --- TV remote keys (TV-PC / TV-PP) ---
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (playerController.hasMedia) {
when (keyCode) {
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
playerController.toggle()
return true
}
KeyEvent.KEYCODE_MEDIA_PLAY -> {
playerController.resume()
return true
}
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
playerController.pause()
return true
}
KeyEvent.KEYCODE_MEDIA_STOP -> {
playerController.stop()
return true
}
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_MEDIA_REWIND -> {
onSeek(-SEEK_STEP_SECONDS)
return true
}
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
onSeek(SEEK_STEP_SECONDS)
return true
}
KeyEvent.KEYCODE_BACK -> {
playerController.stop()
return true
}
else -> Unit
}
}
return super.onKeyDown(keyCode, event)
}
// --- lifecycle ---
override fun onStop() {
super.onStop()
// TV-NP: video must not keep playing when the user leaves the app.
playerController.pause()
}
override fun onDestroy() {
super.onDestroy()
mainHandler.removeCallbacksAndMessages(null)
server.stopServer()
playerController.release()
}
private companion object {
const val QR_SIZE_PX = 512
const val OVERLAY_HIDE_DELAY_MS = 4_000L
const val TICK_INTERVAL_MS = 2_000L
const val SEEK_STEP_SECONDS = 10L
}
}

View File

@@ -0,0 +1,54 @@
package com.nodecast.tv.pairing
import android.content.Context
import java.net.Inet4Address
import java.net.NetworkInterface
import java.security.SecureRandom
object Pairing {
const val PORT = 8765
/**
* Four-digit pairing code, generated once and kept stable so an already
* paired phone survives app restarts. Human fallback only — the QR code
* carries the long token below.
*/
fun code(context: Context): String {
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE)
prefs.getString("code", null)?.let { return it }
val code = "%04d".format(SecureRandom().nextInt(10_000))
prefs.edit().putString("code", code).apply()
return code
}
/** 128-bit random token embedded in the QR code; not brute-forceable. */
fun token(context: Context): String {
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE)
prefs.getString("token", null)?.let { return it }
val bytes = ByteArray(16).also { SecureRandom().nextBytes(it) }
val token = bytes.joinToString("") { "%02x".format(it) }
prefs.edit().putString("token", token).apply()
return token
}
/** Best-guess LAN IPv4 address of this device, or null when offline. */
fun lanAddress(): String? {
val candidates = runCatching {
NetworkInterface.getNetworkInterfaces().asSequence()
.filter { it.isUp && !it.isLoopback }
.flatMap { it.inetAddresses.asSequence() }
.filterIsInstance<Inet4Address>()
.filter { it.isSiteLocalAddress }
.map { it.hostAddress }
.filterNotNull()
.toList()
}.getOrDefault(emptyList())
return candidates.firstOrNull()
}
fun remoteUrl(address: String): String = "http://$address:$PORT"
fun pairingUrl(context: Context, address: String): String =
"${remoteUrl(address)}/?t=${token(context)}"
}

View File

@@ -0,0 +1,26 @@
package com.nodecast.tv.pairing
import android.graphics.Bitmap
import android.graphics.Color
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
object Qr {
fun encode(content: String, size: Int, foreground: Int, background: Int = Color.WHITE): Bitmap {
val hints = mapOf(
EncodeHintType.MARGIN to 0,
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M,
)
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
val pixels = IntArray(size * size)
for (y in 0 until size) {
for (x in 0 until size) {
pixels[y * size + x] = if (matrix.get(x, y)) foreground else background
}
}
return Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
}
}

View File

@@ -0,0 +1,105 @@
package com.nodecast.tv.player
import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
/**
* Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause
* keys and Google Assistant work alongside the phone remote (TV-PP/TV-VC of
* the TV app quality guidelines).
*/
class PlayerController(
context: Context,
private val onChanged: () -> Unit,
) {
val player: ExoPlayer = ExoPlayer.Builder(context).build()
private val mediaSession: MediaSession = MediaSession.Builder(context, player).build()
var channelName: String = ""
private set
var channelGroup: String = ""
private set
var errorMessage: String? = null
private set
init {
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) = onChanged()
override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()
override fun onPlayerError(error: PlaybackException) {
errorMessage = error.errorCodeName
onChanged()
}
})
}
val state: String
get() = when {
errorMessage != null -> "error"
player.playbackState == Player.STATE_BUFFERING -> "buffering"
player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing"
player.playbackState == Player.STATE_READY -> "paused"
else -> "idle"
}
val hasMedia: Boolean
get() = player.mediaItemCount > 0 && player.playbackState != Player.STATE_IDLE
fun play(url: String, name: String, group: String) {
if (url.isEmpty()) return
errorMessage = null
channelName = name.ifEmpty { url }
channelGroup = group
val item = MediaItem.Builder()
.setUri(url)
.setMediaMetadata(MediaMetadata.Builder().setTitle(channelName).build())
.build()
player.setMediaItem(item)
player.prepare()
player.play()
}
fun toggle() {
if (!hasMedia) return
if (player.isPlaying) player.pause() else resume()
}
fun pause() {
if (player.isPlaying) player.pause()
}
fun resume() {
if (!hasMedia) return
if (player.playbackState == Player.STATE_ENDED) player.seekToDefaultPosition()
player.play()
}
fun stop() {
player.stop()
player.clearMediaItems()
channelName = ""
channelGroup = ""
errorMessage = null
onChanged()
}
fun seekBy(deltaSeconds: Long) {
if (!hasMedia || !player.isCurrentMediaItemSeekable) return
val target = (player.currentPosition + deltaSeconds * 1000)
.coerceIn(0, player.duration.coerceAtLeast(0))
player.seekTo(target)
}
fun release() {
mediaSession.release()
player.release()
}
}

View File

@@ -0,0 +1,35 @@
package com.nodecast.tv.playlist
import org.json.JSONArray
import org.json.JSONObject
data class Channel(
val name: String,
val url: String,
val group: String,
val logo: String,
) {
fun toJson(): JSONObject = JSONObject()
.put("name", name)
.put("url", url)
.put("group", group)
.put("logo", logo)
companion object {
fun fromJson(obj: JSONObject): Channel = Channel(
name = obj.optString("name"),
url = obj.optString("url"),
group = obj.optString("group"),
logo = obj.optString("logo"),
)
fun listToJson(channels: List<Channel>): JSONArray {
val arr = JSONArray()
channels.forEach { arr.put(it.toJson()) }
return arr
}
fun listFromJson(arr: JSONArray): List<Channel> =
(0 until arr.length()).map { fromJson(arr.getJSONObject(it)) }
}
}

View File

@@ -0,0 +1,48 @@
package com.nodecast.tv.playlist
object M3uParser {
private const val MAX_CHANNELS = 5000
private val attrRegex = Regex("""([\w-]+)="([^"]*)"""")
fun parse(content: String): List<Channel> {
val channels = mutableListOf<Channel>()
var name = ""
var group = ""
var logo = ""
var pendingInfo = false
for (rawLine in content.lineSequence()) {
val line = rawLine.trim()
when {
line.startsWith("#EXTINF", ignoreCase = true) -> {
val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] }
group = attrs["group-title"].orEmpty()
logo = attrs["tvg-logo"].orEmpty()
name = line.substringAfterLast(',', "").trim()
if (name.isEmpty()) name = attrs["tvg-name"].orEmpty()
pendingInfo = true
}
line.startsWith("#EXTGRP", ignoreCase = true) -> {
group = line.substringAfter(':', "").trim()
}
line.isEmpty() || line.startsWith("#") -> Unit
pendingInfo -> {
channels.add(Channel(name.ifEmpty { line }, line, group, logo))
if (channels.size >= MAX_CHANNELS) return channels
name = ""
group = ""
logo = ""
pendingInfo = false
}
else -> {
// Bare URL without #EXTINF — still a playable entry.
channels.add(Channel(line, line, "", ""))
if (channels.size >= MAX_CHANNELS) return channels
}
}
}
return channels
}
}

View File

@@ -0,0 +1,63 @@
package com.nodecast.tv.playlist
import android.content.Context
import org.json.JSONArray
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.Executors
/**
* Fetches and caches the M3U playlist. The channel list is persisted so the
* remote sees its channels again right after an app restart.
*/
class PlaylistRepository(context: Context) {
private val prefs = context.getSharedPreferences("playlist", Context.MODE_PRIVATE)
private val executor = Executors.newSingleThreadExecutor()
var channels: List<Channel> = loadCached()
private set
val playlistUrl: String
get() = prefs.getString(KEY_URL, "").orEmpty()
fun refresh(url: String, onResult: (Result<List<Channel>>) -> Unit) {
executor.execute {
val result = runCatching {
val content = download(url)
val parsed = M3uParser.parse(content)
require(parsed.isNotEmpty()) { "playlist is empty" }
parsed
}
result.onSuccess { parsed ->
channels = parsed
prefs.edit()
.putString(KEY_URL, url)
.putString(KEY_CACHE, Channel.listToJson(parsed).toString())
.apply()
}
onResult(result)
}
}
private fun download(url: String): String {
val connection = URL(url).openConnection() as HttpURLConnection
return try {
connection.connectTimeout = 15_000
connection.readTimeout = 30_000
connection.instanceFollowRedirects = true
connection.inputStream.bufferedReader().use { it.readText() }
} finally {
connection.disconnect()
}
}
private fun loadCached(): List<Channel> = runCatching {
Channel.listFromJson(JSONArray(prefs.getString(KEY_CACHE, "[]").orEmpty()))
}.getOrDefault(emptyList())
private companion object {
const val KEY_URL = "url"
const val KEY_CACHE = "channels"
}
}

View File

@@ -0,0 +1,244 @@
package com.nodecast.tv.server
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.nodecast.tv.pairing.Pairing
import com.nodecast.tv.playlist.Channel
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoWSD
import org.json.JSONObject
import java.io.IOException
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.CopyOnWriteArrayList
/**
* Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
* page) over HTTP and takes playback commands over a WebSocket. A client
* authorizes itself with the four-digit pairing code from the QR code.
*/
class ControlServer(
private val context: Context,
private val listener: Listener,
) : NanoWSD(Pairing.PORT) {
interface Listener {
fun onPlay(url: String, name: String, group: String)
fun onTogglePlay()
fun onPause()
fun onResume()
fun onStopCast()
fun onSeek(deltaSeconds: Long)
fun onVolume(value: Float)
fun onSetPlaylist(url: String)
fun onClientsChanged(count: Int, newestName: String?)
fun currentStatus(): JSONObject
fun currentChannels(): List<Channel>
fun currentPlaylistUrl(): String
}
private val mainHandler = Handler(Looper.getMainLooper())
private val clients = CopyOnWriteArrayList<RemoteSocket>()
private val pairingCode = Pairing.code(context)
private val pairingToken = Pairing.token(context)
private var pingTimer: Timer? = null
// Rate limit for the human-typable 4-digit code (the QR token is not
// brute-forceable and stays exempt).
private val codeAttempts = ArrayDeque<Long>()
@Synchronized
private fun codeAttemptAllowed(): Boolean {
val now = System.currentTimeMillis()
while (codeAttempts.isNotEmpty() && now - codeAttempts.first() > CODE_ATTEMPT_WINDOW_MS) {
codeAttempts.removeFirst()
}
if (codeAttempts.size >= CODE_ATTEMPT_MAX) return false
codeAttempts.addLast(now)
return true
}
fun startServer() {
start(0, true)
pingTimer = Timer("ws-ping", true).also {
it.schedule(object : TimerTask() {
override fun run() = pingClients()
}, PING_INTERVAL_MS, PING_INTERVAL_MS)
}
}
fun stopServer() {
pingTimer?.cancel()
pingTimer = null
stop()
}
// --- HTTP ---
override fun serveHttp(session: IHTTPSession): Response {
return when (session.uri) {
"/", "/index.html" -> {
val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() }
newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply {
addHeader("Cache-Control", "no-store")
}
}
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found")
}
}
// --- WebSocket ---
override fun openWebSocket(handshake: IHTTPSession): WebSocket = RemoteSocket(handshake)
fun broadcast(message: JSONObject) {
val payload = message.toString()
clients.forEach { client ->
if (client.authorized) client.trySend(payload)
}
}
fun broadcastStatus() {
broadcast(listener.currentStatus().put("type", "status"))
}
fun broadcastChannels() {
broadcast(channelsMessage())
}
fun broadcastToast(message: String) {
broadcast(JSONObject().put("type", "toast").put("message", message))
}
private fun channelsMessage(): JSONObject = JSONObject()
.put("type", "channels")
.put("playlistUrl", listener.currentPlaylistUrl())
.put("channels", Channel.listToJson(listener.currentChannels()))
private fun pingClients() {
clients.forEach { client ->
try {
client.ping(PING_PAYLOAD)
} catch (e: IOException) {
Log.d(TAG, "ping failed, dropping client", e)
clients.remove(client)
}
}
notifyClientsChanged(null)
}
private fun notifyClientsChanged(newestName: String?) {
val count = clients.count { it.authorized }
mainHandler.post { listener.onClientsChanged(count, newestName) }
}
inner class RemoteSocket(handshake: IHTTPSession) : WebSocket(handshake) {
@Volatile
var authorized = false
private set
private var deviceName: String = ""
fun trySend(payload: String) {
try {
send(payload)
} catch (e: IOException) {
Log.d(TAG, "send failed, dropping client", e)
clients.remove(this)
}
}
override fun onOpen() {
clients.add(this)
}
override fun onClose(code: WebSocketFrame.CloseCode?, reason: String?, initiatedByRemote: Boolean) {
clients.remove(this)
notifyClientsChanged(null)
}
override fun onMessage(message: WebSocketFrame) {
val msg = runCatching { JSONObject(message.textPayload) }.getOrNull() ?: return
val type = msg.optString("type")
if (!authorized) {
if (type == "hello") handleHello(msg)
return
}
when (type) {
"play" -> post {
listener.onPlay(
msg.optString("url"),
msg.optString("name"),
msg.optString("group"),
)
}
"toggle" -> post { listener.onTogglePlay() }
"pause" -> post { listener.onPause() }
"resume" -> post { listener.onResume() }
"stop" -> post { listener.onStopCast() }
"seek" -> post { listener.onSeek(msg.optLong("delta")) }
"volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) }
"set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
"get_state" -> {
trySend(listener.currentStatus().put("type", "status").toString())
trySend(channelsMessage().toString())
}
else -> Unit
}
}
override fun onPong(pong: WebSocketFrame?) = Unit
override fun onException(exception: IOException?) {
clients.remove(this)
}
private fun handleHello(msg: JSONObject) {
val token = msg.optString("token")
val tokenOk = token.isNotEmpty() && token == pairingToken
val codeOk = !tokenOk && msg.optString("code").let { code ->
code.isNotEmpty() && when {
!codeAttemptAllowed() -> {
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
return
}
else -> code == pairingCode
}
}
if (!tokenOk && !codeOk) {
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
return
}
authorized = true
deviceName = msg.optString("name").ifEmpty { "Handy" }
trySend(
JSONObject()
.put("type", "welcome")
.put("device", android.os.Build.MODEL)
.put("token", pairingToken)
.put("status", listener.currentStatus())
.put("playlistUrl", listener.currentPlaylistUrl())
.put("channels", Channel.listToJson(listener.currentChannels()))
.toString()
)
notifyClientsChanged(deviceName)
}
private fun post(action: () -> Unit) {
mainHandler.post(action)
}
}
private companion object {
const val TAG = "ControlServer"
const val PING_INTERVAL_MS = 8_000L
const val CODE_ATTEMPT_WINDOW_MS = 60_000L
const val CODE_ATTEMPT_MAX = 5
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/chip_bg" />
<stroke android:width="1dp" android:color="@color/chip_stroke" />
<corners android:radius="100dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/card" />
<corners android:radius="16dp" />
</shape>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/accent" />
</shape>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/live" />
</shape>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,6.5 A1.5,1.5 0 0 1 4.5,5 L19.5,5 A1.5,1.5 0 0 1 21,6.5 L21,16.5 A1.5,1.5 0 0 1 19.5,18 L4.5,18 A1.5,1.5 0 0 1 3,16.5 Z"
android:strokeColor="#5FD4C4"
android:strokeWidth="1.6"
android:strokeLineCap="round" />
<path
android:pathData="M8.5,21.5 L15.5,21.5"
android:strokeColor="#5FD4C4"
android:strokeWidth="1.6"
android:strokeLineCap="round" />
</vector>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="1.8"
android:scaleY="1.8"
android:pivotX="54"
android:pivotY="54">
<path
android:pathData="M42,48.5 A1.5,1.5 0 0 1 43.5,47 L64.5,47 A1.5,1.5 0 0 1 66,48.5 L66,59.5 A1.5,1.5 0 0 1 64.5,61 L43.5,61 A1.5,1.5 0 0 1 42,59.5 Z"
android:strokeColor="#5FD4C4"
android:strokeWidth="1.8"
android:strokeLineCap="round" />
<path
android:pathData="M50,64.5 L58,64.5"
android:strokeColor="#5FD4C4"
android:strokeWidth="1.8"
android:strokeLineCap="round" />
</group>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/accent" />
<corners android:radius="1dp" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient
android:angle="90"
android:startColor="#EB05060A"
android:endColor="#0005060A" />
</shape>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/bg" />
</shape>
</item>
<item android:gravity="center" android:width="80dp" android:height="80dp">
<vector
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,6.5 A1.5,1.5 0 0 1 4.5,5 L19.5,5 A1.5,1.5 0 0 1 21,6.5 L21,16.5 A1.5,1.5 0 0 1 19.5,18 L4.5,18 A1.5,1.5 0 0 1 3,16.5 Z"
android:strokeColor="#5FD4C4"
android:strokeWidth="1.6"
android:strokeLineCap="round" />
<path
android:pathData="M8.5,21.5 L15.5,21.5"
android:strokeColor="#5FD4C4"
android:strokeWidth="1.6"
android:strokeLineCap="round" />
</vector>
</item>
</layer-list>

Binary file not shown.

View File

@@ -0,0 +1,236 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/bg">
<!-- Pairing screen -->
<FrameLayout
android:id="@+id/pairing_screen"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:gravity="center_horizontal">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_cast" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:text="@string/app_name"
android:textAllCaps="true"
android:textColor="@color/fg"
android:textSize="16sp"
android:letterSpacing="0.22" />
</LinearLayout>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:padding="16dp"
android:background="@drawable/bg_qr_card">
<ImageView
android:id="@+id/qr_image"
android:layout_width="170dp"
android:layout_height="170dp"
android:importantForAccessibility="no" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:text="@string/pair_title"
android:textColor="@color/fg"
android:textSize="26sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:maxWidth="440dp"
android:gravity="center"
android:text="@string/pair_subtitle"
android:textColor="@color/muted"
android:textSize="14sp"
android:lineSpacingMultiplier="1.3" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="36dp"
android:paddingEnd="36dp"
android:paddingBottom="26dp">
<View
android:layout_width="7dp"
android:layout_height="7dp"
android:background="@drawable/dot_accent" />
<TextView
android:id="@+id/pairing_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="@string/ready_to_pair"
android:textColor="@color/faint"
android:textSize="12sp" />
<TextView
android:id="@+id/pairing_url"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textColor="@color/faint"
android:textSize="12sp" />
<View
android:layout_width="1dp"
android:layout_height="12dp"
android:layout_marginStart="14dp"
android:layout_marginEnd="14dp"
android:background="@color/line" />
<TextView
android:id="@+id/pairing_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/muted"
android:textSize="12sp" />
</LinearLayout>
</FrameLayout>
<!-- Player screen -->
<FrameLayout
android:id="@+id/player_screen"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:background="@color/bg_deep">
<androidx.media3.ui.PlayerView
android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/overlay"
android:layout_width="match_parent"
android:layout_height="match_parent">
<View
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_gravity="bottom"
android:background="@drawable/scrim_bottom" />
<LinearLayout
android:id="@+id/device_chip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_marginTop="22dp"
android:layout_marginEnd="30dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="14dp"
android:paddingEnd="14dp"
android:paddingTop="7dp"
android:paddingBottom="7dp"
android:background="@drawable/bg_chip"
android:visibility="gone">
<TextView
android:id="@+id/device_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/muted"
android:textSize="11sp" />
<View
android:layout_width="5dp"
android:layout_height="5dp"
android:layout_marginStart="8dp"
android:background="@drawable/dot_accent" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:orientation="vertical"
android:paddingStart="36dp"
android:paddingEnd="36dp"
android:paddingBottom="30dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<View
android:id="@+id/overlay_live_dot"
android:layout_width="6dp"
android:layout_height="6dp"
android:background="@drawable/dot_live" />
<TextView
android:id="@+id/overlay_live_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:textColor="@color/muted"
android:textSize="11sp"
android:letterSpacing="0.18" />
<TextView
android:id="@+id/overlay_state"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/muted"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/overlay_channel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/fg"
android:textSize="29sp" />
<View
android:id="@+id/overlay_progress"
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginTop="16dp"
android:background="@drawable/progress_line" />
</LinearLayout>
</FrameLayout>
</FrameLayout>
</FrameLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="pair_title">Mit dem Handy koppeln</string>
<string name="pair_subtitle">Richte die Kamera auf den Code — die Fernbedienung öffnet sich direkt im Browser.</string>
<string name="ready_to_pair">Bereit zum Koppeln</string>
<string name="paired_with">Gekoppelt mit %1$s</string>
<string name="pairing_code">Code %1$s</string>
<string name="no_network">Keine Netzwerkverbindung</string>
<string name="playing">Wird abgespielt</string>
<string name="paused">Pausiert</string>
<string name="buffering">Lädt…</string>
<string name="playback_error">Wiedergabefehler (%1$s)</string>
<string name="playlist_error">Playlist konnte nicht geladen werden</string>
</resources>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="bg">#0A0B0D</color>
<color name="bg_deep">#05060A</color>
<color name="fg">#F2F3F5</color>
<color name="muted">#9AA0A8</color>
<color name="faint">#6B717A</color>
<color name="accent">#5FD4C4</color>
<color name="card">#FBFCFD</color>
<color name="live">#E5484D</color>
<color name="line">#23262B</color>
<color name="chip_stroke">#14FFFFFF</color>
<color name="chip_bg">#8C0A0B0D</color>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">NodeCast</string>
<string name="pair_title">Pair with your phone</string>
<string name="pair_subtitle">Point your camera at the code — the remote opens right in your browser.</string>
<string name="ready_to_pair">Ready to pair</string>
<string name="paired_with">Paired with %1$s</string>
<string name="pairing_code">Code %1$s</string>
<string name="no_network">No network connection</string>
<string name="live">LIVE</string>
<string name="playing">Playing</string>
<string name="paused">Paused</string>
<string name="buffering">Buffering…</string>
<string name="playback_error">Playback error (%1$s)</string>
<string name="playlist_error">Could not load playlist</string>
</resources>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.NodeCast" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/bg</item>
<item name="android:colorBackground">@color/bg</item>
<item name="android:textColorPrimary">@color/fg</item>
<item name="android:textColorSecondary">@color/muted</item>
<item name="android:colorAccent">@color/accent</item>
<item name="android:fontFamily">@font/space_grotesk</item>
<item name="android:windowAllowReturnTransitionOverlap">true</item>
</style>
</resources>