Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f157323626 | ||
|
|
c0adce2b09 | ||
|
|
7abd4b2a48 | ||
|
|
f69ae2f437 | ||
|
|
01a76bffcd | ||
|
|
13352994e4 | ||
|
|
6acbe77200 | ||
|
|
7da6f5c69f | ||
|
|
e66c8a863b | ||
|
|
ca401e8ca3 | ||
|
|
844ea928ef | ||
|
|
95f8793938 | ||
|
|
1757d8015d | ||
|
|
7c1f8d2c8b | ||
|
|
3068eaea19 | ||
|
|
5b6cebd041 | ||
|
|
5de71193ad | ||
|
|
deca5bd0cb | ||
|
|
a4391740ec | ||
|
|
11a8056ce2 | ||
|
|
d98f277ec4 | ||
|
|
8d6958536d | ||
|
|
ee721708dc | ||
|
|
b04240ffde | ||
|
|
00762b6cbb | ||
|
|
b12bfdf6e8 | ||
|
|
0410eda9c9 | ||
|
|
e69c79061f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ local.properties
|
|||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
.kotlin/
|
.kotlin/
|
||||||
|
tests/runs/
|
||||||
|
|||||||
109
README.md
109
README.md
@@ -1,49 +1,82 @@
|
|||||||
# Castarr
|
# Castarr
|
||||||
|
|
||||||
Native Google TV client for a [Dispatcharr](https://github.com/be-nj/Dispatcharr)
|
**Live-TV auf dem Fernseher, eingerichtet in einer Minute — alles läuft übers Handy.**
|
||||||
backend: live IPTV with a full D-pad UI (Compose for TV), login via the OIDC
|
|
||||||
device flow (Authentik), per-user favorites, per-stream output profiles, and
|
|
||||||
an optional phone remote paired by QR code — served by the TV itself, no phone
|
|
||||||
app install.
|
|
||||||
|
|
||||||
## Features
|
Castarr ist eine App für Google TV / Android TV. Auf dem Fernseher muss nie
|
||||||
|
etwas getippt werden: Zum Einrichten scannt man einmal einen QR-Code mit dem
|
||||||
|
Handy, meldet sich dort an — fertig. Danach zappt man mit der normalen
|
||||||
|
TV-Fernbedienung oder steuert alles bequem vom Handy aus.
|
||||||
|
|
||||||
- **Live TV** channel list with Now/Next EPG and progress, zapping via
|
| Senderliste | Player |
|
||||||
channel/D-pad keys, fullscreen playback (ExoPlayer, HLS + MPEG-TS) with a
|
| --- | --- |
|
||||||
minimal auto-hiding overlay
|
|  |  |
|
||||||
- **Login without typing**: the TV shows a QR code and a user code; confirm on
|
|
||||||
your phone at your identity provider (OIDC device authorization grant). The
|
|
||||||
only thing entered on the TV is the Dispatcharr URL.
|
|
||||||
- **Dispatcharr source**: channels, groups and EPG via the fork's
|
|
||||||
Bearer-authenticated API; favorites are stored per user on the backend
|
|
||||||
(long-press a channel to star it, filter chip in the list)
|
|
||||||
- **Stream profiles**: pick Standard / Passthrough / audiofix / 720p (list
|
|
||||||
comes from the backend) — applied per stream request
|
|
||||||
- **Generic source fallback**: any M3U (+ optional XMLTV) URL works without a
|
|
||||||
login — ErsatzTV, Threadfin, Tunarr, plain playlists
|
|
||||||
- **Phone remote** (optional): scan the QR under Einstellungen; play/pause,
|
|
||||||
zapping, volume, channel list with Now/Next and favorite toggling in the
|
|
||||||
browser — no app install, rate-limited pairing code as fallback
|
|
||||||
- Physical remote keys and Google Assistant keep working (Media3
|
|
||||||
MediaSession, TV app quality guidelines TV-PC/TV-PP/TV-VC)
|
|
||||||
|
|
||||||
## Building
|
| Nummerntasten | Einrichtung |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
Requires JDK 17+ and the Android SDK (platform 35). A JDK toolchain is
|
## Was die App kann
|
||||||
resolved automatically (foojay) if the host only has a JRE.
|
|
||||||
|
- **Senderliste mit Logos und Programm** — was gerade läuft, wie lange noch,
|
||||||
|
was danach kommt. Gruppen und Favoriten links, Sender rechts.
|
||||||
|
- **Zappen wie früher** — Nummerntasten auf der Fernbedienung schalten direkt
|
||||||
|
um (mit großer Anzeige, welcher Sender gleich kommt), hoch/runter blättert
|
||||||
|
durch die Sender.
|
||||||
|
- **Aufgeräumter Player** — OK-Taste zeigt Sendung, Zeiten und Fortschritt,
|
||||||
|
dazu Pause/Stopp-Buttons und Tonspur-Wahl, wenn der Sender mehrere hat.
|
||||||
|
Blendet sich von selbst wieder aus.
|
||||||
|
- **Handy als Fernbedienung** — QR-Code in den Einstellungen scannen, schon
|
||||||
|
hat man Senderliste, Suche, Lautstärke und Favoriten im Handy-Browser.
|
||||||
|
Keine App-Installation nötig.
|
||||||
|
- **Favoriten** — OK auf einem Sender gedrückt halten, Stern gesetzt. Die
|
||||||
|
Favoriten gelten pro Person und sind auf jedem Gerät dieselben.
|
||||||
|
- **Updates aus der App heraus** — unter Einstellungen → App genügt ein
|
||||||
|
Klick, wenn eine neue Version bereitsteht.
|
||||||
|
|
||||||
|
## Installation auf dem Fernseher
|
||||||
|
|
||||||
|
1. Auf dem Google TV die App **Downloader** installieren (oder einen anderen
|
||||||
|
Weg nutzen, eine APK-Datei zu öffnen).
|
||||||
|
2. Diese Adresse eingeben:
|
||||||
|
`git.beckm4nn.net/benjamin/castarr/raw/branch/apk/castarr.apk`
|
||||||
|
3. Installation bestätigen („Unbekannte Quellen" für Downloader erlauben,
|
||||||
|
wenn der Fernseher fragt).
|
||||||
|
4. Castarr starten und den drei Schritten auf dem Bildschirm folgen:
|
||||||
|
QR-Code scannen, auf dem Handy Server angeben und anmelden — den Rest
|
||||||
|
macht der Fernseher.
|
||||||
|
|
||||||
|
Danach hält sich die App über den eingebauten Updater selbst aktuell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Für Technikinteressierte
|
||||||
|
|
||||||
|
Native Kotlin-App (Jetpack Compose for TV, Media3/ExoPlayer für HLS und
|
||||||
|
MPEG-TS). Als Backend dient ein [Dispatcharr](https://github.com/be-nj/Dispatcharr)-Fork
|
||||||
|
mit OIDC-Unterstützung: Anmeldung per Device Authorization Grant (der
|
||||||
|
Fernseher zeigt QR + Code, bestätigt wird am Handy beim Identity Provider),
|
||||||
|
API-Zugriff per Bearer-Token, Favoriten und Ausgabeprofile pro Nutzer.
|
||||||
|
Ohne Dispatcharr funktioniert auch jede M3U-Playlist (+ optionales XMLTV)
|
||||||
|
als generische Quelle — ErsatzTV, Threadfin, Tunarr, einfache Playlists.
|
||||||
|
|
||||||
|
Die Handy-Fernbedienung serviert der Fernseher selbst (eingebetteter
|
||||||
|
HTTP/WebSocket-Server, Kopplung per QR-Token mit ratenlimitiertem
|
||||||
|
Zahlencode als Fallback). Physische Mediatasten und Google Assistant
|
||||||
|
funktionieren über eine Media3 MediaSession.
|
||||||
|
|
||||||
|
### Selbst bauen
|
||||||
|
|
||||||
|
JDK 17+ und Android SDK (Platform 35) erforderlich; eine passende
|
||||||
|
JDK-Toolchain wird automatisch aufgelöst (foojay).
|
||||||
|
|
||||||
```
|
```
|
||||||
./gradlew assembleDebug # debug APK
|
./gradlew assembleDebug # Debug-APK
|
||||||
./gradlew assembleRelease # signed when CASTARR_STORE_* env vars are set
|
./gradlew assembleRelease # signiert, wenn CASTARR_STORE_*-Env gesetzt ist
|
||||||
```
|
```
|
||||||
|
|
||||||
APK output: `app/build/outputs/apk/<variant>/`. Releases on the
|
APK-Ausgabe: `app/build/outputs/apk/<variant>/`. Die Releases hier im Repo
|
||||||
[releases page](https://github.com/be-nj/castarr/releases) are signed; install
|
sind signiert; über einem Debug-Build muss einmal deinstalliert werden
|
||||||
over an existing debug build requires uninstalling first (signature change).
|
(Signaturwechsel). Updates bezieht die App über die Release-API dieses
|
||||||
|
Repos, die APK selbst liegt auf dem Branch `apk`.
|
||||||
|
|
||||||
## Backend
|
Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).
|
||||||
|
|
||||||
The full feature set needs the Dispatcharr fork with OIDC support
|
|
||||||
(be-nj/Dispatcharr): its `/api/accounts/oidc/status/` endpoint hands the app
|
|
||||||
the issuer and public device-flow client id. Configuration lives in
|
|
||||||
[CONTEXT.md](CONTEXT.md) and [docs/adr/](docs/adr/).
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "dev.castarr.tv"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 6
|
versionCode = 24
|
||||||
versionName = "0.3.0"
|
versionName = "0.9.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||||
@@ -51,6 +51,20 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Global layout rule: everything test-related lives under tests/.
|
||||||
|
sourceSets {
|
||||||
|
getByName("test") {
|
||||||
|
java.setSrcDirs(listOf("../tests/unit"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.all {
|
||||||
|
it.reports.junitXml.outputLocation.set(file("../tests/runs/junit"))
|
||||||
|
it.reports.html.outputLocation.set(file("../tests/runs/junit-html"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
@@ -81,6 +95,8 @@ dependencies {
|
|||||||
implementation("org.nanohttpd:nanohttpd:2.3.1")
|
implementation("org.nanohttpd:nanohttpd:2.3.1")
|
||||||
implementation("org.nanohttpd:nanohttpd-websocket:2.3.1")
|
implementation("org.nanohttpd:nanohttpd-websocket:2.3.1")
|
||||||
implementation("com.google.zxing:core:3.5.3")
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full JDK for javac via toolchain (host may only have a JRE); resolved by
|
// Full JDK for javac via toolchain (host may only have a JRE); resolved by
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:banner="@drawable/tv_banner"
|
android:banner="@drawable/tv_banner"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|||||||
@@ -71,6 +71,14 @@
|
|||||||
.btn-main:disabled { opacity: .35; box-shadow: none; }
|
.btn-main:disabled { opacity: .35; box-shadow: none; }
|
||||||
|
|
||||||
.volrow { display: flex; align-items: center; gap: 16px; padding: 0 8px; }
|
.volrow { display: flex; align-items: center; gap: 16px; padding: 0 8px; }
|
||||||
|
.audiorow { display: none; flex-wrap: wrap; align-items: center; gap: 8px; padding: 14px 8px 0; }
|
||||||
|
.audiorow.on { display: flex; }
|
||||||
|
.audiorow .lbl { color: #6b717a; font-size: 11px; letter-spacing: 2px; margin-right: 4px; }
|
||||||
|
.achip {
|
||||||
|
border: 1px solid #23262b; background: transparent; color: #9aa0a8;
|
||||||
|
border-radius: 999px; padding: 7px 14px; font-size: 13px; font-family: inherit;
|
||||||
|
}
|
||||||
|
.achip.sel { background: #5fd4c4; border-color: #5fd4c4; color: #07110f; font-weight: 600; }
|
||||||
input[type=range] { flex: 1; appearance: none; -webkit-appearance: none; height: 3px;
|
input[type=range] { flex: 1; appearance: none; -webkit-appearance: none; height: 3px;
|
||||||
border-radius: 2px; background: #1e2126; outline: none; }
|
border-radius: 2px; background: #1e2126; outline: none; }
|
||||||
input[type=range]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none;
|
input[type=range]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none;
|
||||||
@@ -210,6 +218,7 @@
|
|||||||
<input type="range" id="volume" min="0" max="100" value="50" aria-label="Lautstärke">
|
<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>
|
<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>
|
</div>
|
||||||
|
<div class="audiorow" id="audiorow"><span class="lbl">TON</span></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Sender -->
|
<!-- Sender -->
|
||||||
@@ -311,6 +320,7 @@
|
|||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
state.connected = false; state.authorized = false;
|
state.connected = false; state.authorized = false;
|
||||||
renderConnection();
|
renderConnection();
|
||||||
|
state.ws = null;
|
||||||
setTimeout(connect, state.retryDelay);
|
setTimeout(connect, state.retryDelay);
|
||||||
state.retryDelay = Math.min(state.retryDelay * 1.6, 10000);
|
state.retryDelay = Math.min(state.retryDelay * 1.6, 10000);
|
||||||
};
|
};
|
||||||
@@ -331,7 +341,7 @@
|
|||||||
if (msg.token) localStorage.setItem('castarr_token', msg.token);
|
if (msg.token) localStorage.setItem('castarr_token', msg.token);
|
||||||
$('tv-name').textContent = msg.device || 'TV';
|
$('tv-name').textContent = msg.device || 'TV';
|
||||||
if (msg.status) { state.status = msg.status; }
|
if (msg.status) { state.status = msg.status; }
|
||||||
if (msg.channels) { state.channels = msg.channels; }
|
if (msg.channels) { state.channels = msg.channels.map((c, i) => { c._idx = i; return c; }); }
|
||||||
if (msg.extras) { state.extras = msg.extras; }
|
if (msg.extras) { state.extras = msg.extras; }
|
||||||
state.playlistUrl = msg.playlistUrl || '';
|
state.playlistUrl = msg.playlistUrl || '';
|
||||||
$('pair-error').textContent = '';
|
$('pair-error').textContent = '';
|
||||||
@@ -349,10 +359,10 @@
|
|||||||
case 'status':
|
case 'status':
|
||||||
state.status = msg;
|
state.status = msg;
|
||||||
renderStatus();
|
renderStatus();
|
||||||
renderChannels();
|
renderChannelsIfChanged();
|
||||||
break;
|
break;
|
||||||
case 'channels':
|
case 'channels':
|
||||||
state.channels = msg.channels || [];
|
state.channels = (msg.channels || []).map((c, i) => { c._idx = i; return c; });
|
||||||
state.playlistUrl = msg.playlistUrl || '';
|
state.playlistUrl = msg.playlistUrl || '';
|
||||||
if (msg.extras) { state.extras = msg.extras; }
|
if (msg.extras) { state.extras = msg.extras; }
|
||||||
renderChannels();
|
renderChannels();
|
||||||
@@ -411,6 +421,19 @@
|
|||||||
if (!state.volumeDragging && typeof s.volume === 'number') {
|
if (!state.volumeDragging && typeof s.volume === 'number') {
|
||||||
$('volume').value = Math.round(s.volume * 100);
|
$('volume').value = Math.round(s.volume * 100);
|
||||||
}
|
}
|
||||||
|
const tracks = s.audioTracks || [];
|
||||||
|
const row = $('audiorow');
|
||||||
|
row.classList.toggle('on', hasMedia && tracks.length > 1);
|
||||||
|
while (row.children.length > 1) row.removeChild(row.lastChild);
|
||||||
|
if (tracks.length > 1) {
|
||||||
|
tracks.forEach((label, i) => {
|
||||||
|
const chip = document.createElement('button');
|
||||||
|
chip.className = 'achip' + (i === s.audioSelected ? ' sel' : '');
|
||||||
|
chip.textContent = label;
|
||||||
|
chip.addEventListener('click', () => send({ type: 'set_audio', index: i }));
|
||||||
|
row.appendChild(chip);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChannels() {
|
function renderChannels() {
|
||||||
@@ -424,6 +447,7 @@
|
|||||||
$('setup').classList.add('open');
|
$('setup').classList.add('open');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
list.innerHTML = '';
|
||||||
const favSet = new Set(state.extras.favorites || []);
|
const favSet = new Set(state.extras.favorites || []);
|
||||||
const favOn = state.extras.favoritesSupported && state.favOnly;
|
const favOn = state.extras.favoritesSupported && state.favOnly;
|
||||||
const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
|
const shown = favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
|
||||||
@@ -440,9 +464,18 @@
|
|||||||
});
|
});
|
||||||
list.appendChild(bar);
|
list.appendChild(bar);
|
||||||
}
|
}
|
||||||
|
if (!shown.length) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'empty';
|
||||||
|
empty.textContent = favOn
|
||||||
|
? 'Keine Favoriten — Stern auf einem Sender antippen.'
|
||||||
|
: 'Keine Sender gefunden.';
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
shown.slice(0, 500).forEach((c) => {
|
shown.slice(0, 500).forEach((c) => {
|
||||||
const idx = state.channels.indexOf(c);
|
const idx = c._idx;
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : '');
|
btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : '');
|
||||||
const num = document.createElement('span');
|
const num = document.createElement('span');
|
||||||
@@ -495,12 +528,27 @@
|
|||||||
btn.addEventListener('click', () => playChannel(c));
|
btn.addEventListener('click', () => playChannel(c));
|
||||||
frag.appendChild(btn);
|
frag.appendChild(btn);
|
||||||
});
|
});
|
||||||
list.innerHTML = '';
|
|
||||||
list.appendChild(frag);
|
list.appendChild(frag);
|
||||||
if (state.playlistUrl) $('playlist-input').value = state.playlistUrl;
|
// Never overwrite an input the user is typing in.
|
||||||
|
const pl = $('playlist-input');
|
||||||
|
if (state.playlistUrl && document.activeElement !== pl) pl.value = state.playlistUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAll() { renderStatus(); renderChannels(); }
|
// Rebuilding the list on every 2s status push swallowed taps that landed
|
||||||
|
// between touchstart and touchend. Only rebuild when something changed.
|
||||||
|
let listSignature = '';
|
||||||
|
function renderChannelsIfChanged() {
|
||||||
|
const sig = [
|
||||||
|
state.channels.length, state.searchTerm, state.favOnly,
|
||||||
|
(state.extras.favorites || []).join(','),
|
||||||
|
state.status.channel || '',
|
||||||
|
].join('|');
|
||||||
|
if (sig === listSignature) return;
|
||||||
|
listSignature = sig;
|
||||||
|
renderChannels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() { renderStatus(); listSignature = ''; renderChannels(); }
|
||||||
|
|
||||||
// --- actions ---
|
// --- actions ---
|
||||||
function playChannel(c) {
|
function playChannel(c) {
|
||||||
@@ -561,10 +609,17 @@
|
|||||||
|
|
||||||
$('pair-btn').addEventListener('click', () => {
|
$('pair-btn').addEventListener('click', () => {
|
||||||
const code = $('code-input').value.trim();
|
const code = $('code-input').value.trim();
|
||||||
if (code.length !== 4) { $('pair-error').textContent = 'Bitte 4 Ziffern eingeben.'; return; }
|
if (!/^[0-9]{4}$/.test(code)) {
|
||||||
|
$('pair-error').textContent = 'Bitte 4 Ziffern eingeben.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A fresh attempt must not inherit the backoff from earlier failures.
|
||||||
|
state.retryDelay = 1000;
|
||||||
|
$('pair-error').textContent = 'Verbinde…';
|
||||||
manualCode = code;
|
manualCode = code;
|
||||||
$('pair-error').textContent = '';
|
$('pair-error').textContent = '';
|
||||||
if (state.ws) state.ws.close(); else connect();
|
// A closed socket ignores close(), so the retry has to reconnect.
|
||||||
|
if (state.ws && state.ws.readyState <= 1) state.ws.close(); else connect();
|
||||||
});
|
});
|
||||||
|
|
||||||
let toastTimer = null;
|
let toastTimer = null;
|
||||||
|
|||||||
@@ -38,6 +38,39 @@ class AppState(
|
|||||||
var isLive by mutableStateOf(false)
|
var isLive by mutableStateOf(false)
|
||||||
var favoritesOnly by mutableStateOf(false)
|
var favoritesOnly by mutableStateOf(false)
|
||||||
var groupFilter by mutableStateOf<String?>(null)
|
var groupFilter by mutableStateOf<String?>(null)
|
||||||
|
var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
|
||||||
|
private set
|
||||||
|
private var lastNowTitle: String? = null
|
||||||
|
|
||||||
|
/** Digits typed on the remote's number pad (channel switching). */
|
||||||
|
var digitBuffer by mutableStateOf("")
|
||||||
|
|
||||||
|
/** False when the control server could not bind its port. */
|
||||||
|
var remoteAvailable by mutableStateOf(true)
|
||||||
|
|
||||||
|
/** Channel to restore focus to when the list comes back (#13). */
|
||||||
|
var lastWatched by mutableStateOf<Channel?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the list is being restored after playback. The group rail
|
||||||
|
* opens whatever gets focused, so without this the focus landing there
|
||||||
|
* would silently switch the view back to "Alle Sender".
|
||||||
|
*/
|
||||||
|
var restorePending by mutableStateOf(false)
|
||||||
|
|
||||||
|
private val reentryHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
|
private var lastStoppedUrl: String = ""
|
||||||
|
private var lastStoppedAt: Long = 0L
|
||||||
|
|
||||||
|
/** Bumped on every interaction with the visible overlay to restart the
|
||||||
|
* auto-hide timer. */
|
||||||
|
var overlayPing by mutableLongStateOf(0L)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun pingOverlay() {
|
||||||
|
overlayVisible = true
|
||||||
|
overlayPing++
|
||||||
|
}
|
||||||
|
|
||||||
/** Phone-first onboarding progress (ADR-0007). */
|
/** Phone-first onboarding progress (ADR-0007). */
|
||||||
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
|
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
|
||||||
@@ -123,6 +156,11 @@ class AppState(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Grace period before re-opening the channel just closed. */
|
||||||
|
const val REENTRY_GRACE_MS = 2_500L
|
||||||
|
}
|
||||||
|
|
||||||
fun isOnline(): Boolean {
|
fun isOnline(): Boolean {
|
||||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
|
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
|
||||||
@@ -138,17 +176,41 @@ class AppState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun play(channel: Channel) {
|
fun play(channel: Channel) {
|
||||||
|
lastWatched = channel
|
||||||
currentChannel = channel
|
currentChannel = channel
|
||||||
playerVisible = true
|
playerVisible = true
|
||||||
|
// Re-opening the very channel that was just closed can hit the
|
||||||
|
// provider before it released the previous session, which comes back
|
||||||
|
// as its "Stream Offline" still image. Give it a moment.
|
||||||
|
val sinceStop = System.currentTimeMillis() - lastStoppedAt
|
||||||
|
val sameChannel = channel.url == lastStoppedUrl
|
||||||
|
if (sameChannel && sinceStop in 0 until REENTRY_GRACE_MS) {
|
||||||
|
val wait = REENTRY_GRACE_MS - sinceStop
|
||||||
|
playerState = "reconnecting"
|
||||||
|
reentryHandler.removeCallbacksAndMessages(null)
|
||||||
|
reentryHandler.postDelayed({
|
||||||
|
player.play(channel.url, channel.name, channel.group)
|
||||||
|
}, wait)
|
||||||
|
return
|
||||||
|
}
|
||||||
player.play(channel.url, channel.name, channel.group)
|
player.play(channel.url, channel.name, channel.group)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopPlayback() {
|
fun stopPlayback() {
|
||||||
|
restorePending = lastWatched != null
|
||||||
|
reentryHandler.removeCallbacksAndMessages(null)
|
||||||
|
lastStoppedUrl = currentChannel?.url.orEmpty()
|
||||||
|
lastStoppedAt = System.currentTimeMillis()
|
||||||
player.stop()
|
player.stop()
|
||||||
playerVisible = false
|
playerVisible = false
|
||||||
currentChannel = null
|
currentChannel = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retry the failed channel without leaving the player. */
|
||||||
|
fun retryPlayback() {
|
||||||
|
player.retryNow()
|
||||||
|
}
|
||||||
|
|
||||||
fun zap(direction: Int) {
|
fun zap(direction: Int) {
|
||||||
val list = activeChannels()
|
val list = activeChannels()
|
||||||
if (list.isEmpty()) return
|
if (list.isEmpty()) return
|
||||||
@@ -158,13 +220,36 @@ class AppState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun syncFromPlayer() {
|
fun syncFromPlayer() {
|
||||||
|
val previous = playerState
|
||||||
playerState = player.state
|
playerState = player.state
|
||||||
|
if (playerState != previous) overlayVisible = true
|
||||||
isLive = player.player.isCurrentMediaItemLive
|
isLive = player.player.isCurrentMediaItemLive
|
||||||
positionMs = player.player.currentPosition.coerceAtLeast(0)
|
positionMs = player.player.currentPosition.coerceAtLeast(0)
|
||||||
durationMs = player.player.duration.coerceAtLeast(0)
|
durationMs = player.player.duration.coerceAtLeast(0)
|
||||||
if (!player.hasMedia && playerVisible) {
|
audioTracks = player.audioTracks()
|
||||||
|
// Briefly resurface the overlay when the running programme changes.
|
||||||
|
val nowTitle = currentChannel?.let { nowNext(it).now?.title }
|
||||||
|
if (playerVisible && nowTitle != null && lastNowTitle != null && nowTitle != lastNowTitle) {
|
||||||
|
overlayVisible = true
|
||||||
|
}
|
||||||
|
lastNowTitle = nowTitle
|
||||||
|
// Keep the player on screen while an error or reconnect is pending —
|
||||||
|
// otherwise the failure silently drops the viewer back to the list.
|
||||||
|
if (!player.hasMedia && playerVisible &&
|
||||||
|
player.errorMessage == null && !player.reconnecting
|
||||||
|
) {
|
||||||
playerVisible = false
|
playerVisible = false
|
||||||
currentChannel = null
|
currentChannel = null
|
||||||
}
|
}
|
||||||
|
if (player.errorMessage != null || player.reconnecting) overlayVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Steps to the next audio track (D-pad friendly: one key, cycles). */
|
||||||
|
fun cycleAudio(direction: Int) {
|
||||||
|
val tracks = audioTracks
|
||||||
|
if (tracks.size < 2) return
|
||||||
|
val current = tracks.indexOfFirst { it.selected }.coerceAtLeast(0)
|
||||||
|
player.selectAudio((current + direction + tracks.size) % tracks.size)
|
||||||
|
audioTracks = player.audioTracks()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
)
|
)
|
||||||
server = ControlServer(this, this)
|
server = ControlServer(this, this)
|
||||||
server.startServer()
|
server.startServer()
|
||||||
|
state.remoteAvailable = server.running
|
||||||
setContent { CastarrApp(state) }
|
setContent { CastarrApp(state) }
|
||||||
lifecycleScope.launch { UpdateChecker.check(state) }
|
lifecycleScope.launch { UpdateChecker.check(state) }
|
||||||
}
|
}
|
||||||
@@ -115,6 +116,11 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
.put("duration", player.player.duration.coerceAtLeast(0))
|
.put("duration", player.player.duration.coerceAtLeast(0))
|
||||||
.put("volume", if (max > 0) vol.toDouble() / max else 0.0)
|
.put("volume", if (max > 0) vol.toDouble() / max else 0.0)
|
||||||
.put("error", player.errorMessage ?: "")
|
.put("error", player.errorMessage ?: "")
|
||||||
|
.put("reconnecting", player.reconnecting)
|
||||||
|
.put("audioTracks", org.json.JSONArray().also { arr ->
|
||||||
|
state.audioTracks.forEach { arr.put(it.label) }
|
||||||
|
})
|
||||||
|
.put("audioSelected", state.audioTracks.indexOfFirst { it.selected })
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun currentChannels(): List<Channel> = state.activeChannels()
|
override fun currentChannels(): List<Channel> = state.activeChannels()
|
||||||
@@ -194,10 +200,26 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
// --- TV remote keys during playback (TV-PC / TV-PP) ---
|
// --- TV remote keys during playback (TV-PC / TV-PP) ---
|
||||||
|
|
||||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||||
|
// Number pad: type the channel number shown in the list (position in
|
||||||
|
// "Alle Sender"), switch after a short pause or as soon as no longer
|
||||||
|
// number can match. Ignored on screens with text fields.
|
||||||
|
if (keyCode in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 &&
|
||||||
|
state.screen != AppState.Screen.WELCOME && state.screen != AppState.Screen.ADVANCED
|
||||||
|
) {
|
||||||
|
onDigit(keyCode - KeyEvent.KEYCODE_0)
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (state.playerVisible) {
|
if (state.playerVisible) {
|
||||||
when (keyCode) {
|
when (keyCode) {
|
||||||
|
// OK opens the control overlay (buttons take over from
|
||||||
|
// there via normal Compose focus); with the overlay open
|
||||||
|
// this key is consumed by the focused button and never
|
||||||
|
// reaches the activity.
|
||||||
KeyEvent.KEYCODE_DPAD_CENTER,
|
KeyEvent.KEYCODE_DPAD_CENTER,
|
||||||
KeyEvent.KEYCODE_ENTER,
|
KeyEvent.KEYCODE_ENTER -> {
|
||||||
|
state.pingOverlay()
|
||||||
|
return true
|
||||||
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||||
state.player.toggle()
|
state.player.toggle()
|
||||||
return true
|
return true
|
||||||
@@ -224,18 +246,24 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
state.zap(1)
|
state.zap(1)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// With the overlay open, left/right moves button focus and
|
||||||
|
// never reaches the activity. Hidden overlay: seek on
|
||||||
|
// seekable media, otherwise just bring the controls up.
|
||||||
KeyEvent.KEYCODE_DPAD_LEFT,
|
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||||
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||||
onSeek(-SEEK_STEP_SECONDS)
|
if (state.player.player.isCurrentMediaItemSeekable) onSeek(-SEEK_STEP_SECONDS)
|
||||||
|
else state.pingOverlay()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_DPAD_RIGHT,
|
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||||
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||||
onSeek(SEEK_STEP_SECONDS)
|
if (state.player.player.isCurrentMediaItemSeekable) onSeek(SEEK_STEP_SECONDS)
|
||||||
|
else state.pingOverlay()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_BACK -> {
|
KeyEvent.KEYCODE_BACK -> {
|
||||||
state.stopPlayback()
|
if (state.overlayVisible) state.overlayVisible = false
|
||||||
|
else state.stopPlayback()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
@@ -244,6 +272,40 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
return super.onKeyDown(keyCode, event)
|
return super.onKeyDown(keyCode, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val digitCommit = Runnable { commitDigits() }
|
||||||
|
|
||||||
|
private fun onDigit(digit: Int) {
|
||||||
|
if (state.digitBuffer.length >= 4) return
|
||||||
|
state.digitBuffer += digit.toString()
|
||||||
|
mainHandler.removeCallbacks(digitCommit)
|
||||||
|
val number = state.digitBuffer.toIntOrNull() ?: return commitDigits()
|
||||||
|
// Another digit could still form a valid number? Then wait for it.
|
||||||
|
if (number * 10 > state.activeChannels().size) commitDigits()
|
||||||
|
else mainHandler.postDelayed(digitCommit, DIGIT_COMMIT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun commitDigits() {
|
||||||
|
mainHandler.removeCallbacks(digitCommit)
|
||||||
|
val number = state.digitBuffer.toIntOrNull()
|
||||||
|
state.digitBuffer = ""
|
||||||
|
val channel = number?.let { state.activeChannels().getOrNull(it - 1) } ?: return
|
||||||
|
state.play(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cycleAudioWithOverlay(direction: Int) {
|
||||||
|
state.overlayVisible = true
|
||||||
|
state.cycleAudio(direction)
|
||||||
|
server.broadcastStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSetAudio(index: Int) {
|
||||||
|
if (index < 0) return
|
||||||
|
state.player.selectAudio(index)
|
||||||
|
state.syncFromPlayer()
|
||||||
|
state.overlayVisible = true
|
||||||
|
server.broadcastStatus()
|
||||||
|
}
|
||||||
|
|
||||||
// --- lifecycle ---
|
// --- lifecycle ---
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
@@ -262,5 +324,6 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val TICK_INTERVAL_MS = 2_000L
|
const val TICK_INTERVAL_MS = 2_000L
|
||||||
const val SEEK_STEP_SECONDS = 10L
|
const val SEEK_STEP_SECONDS = 10L
|
||||||
|
const val DIGIT_COMMIT_MS = 1_800L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
|||||||
name = obj.optString("effective_name").ifEmpty { obj.optString("name") },
|
name = obj.optString("effective_name").ifEmpty { obj.optString("name") },
|
||||||
url = streamUrl(uuid),
|
url = streamUrl(uuid),
|
||||||
group = groups[obj.optInt("channel_group_id")].orEmpty(),
|
group = groups[obj.optInt("channel_group_id")].orEmpty(),
|
||||||
logo = "",
|
// The list serializer only carries logo ids; the
|
||||||
|
// cache endpoint serves the image without auth.
|
||||||
|
logo = (obj.optInt("effective_logo_id").takeIf { it > 0 }
|
||||||
|
?: obj.optInt("logo_id").takeIf { it > 0 })
|
||||||
|
?.let { "${auth.serverUrl.trimEnd('/')}/api/channels/logos/$it/cache/" }
|
||||||
|
.orEmpty(),
|
||||||
tvgId = obj.optString("effective_tvg_id").ifEmpty { obj.optString("tvg_id") },
|
tvgId = obj.optString("effective_tvg_id").ifEmpty { obj.optString("tvg_id") },
|
||||||
backendId = id,
|
backendId = id,
|
||||||
streamKey = uuid,
|
streamKey = uuid,
|
||||||
|
|||||||
@@ -96,17 +96,44 @@ class SourceRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun open(url: String): HttpURLConnection =
|
/**
|
||||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
* Only plain HTTP(S) is fetched, and redirects are followed manually so
|
||||||
|
* every hop can be checked again — a paired phone must not be able to
|
||||||
|
* point the TV at arbitrary internal services (SSRF).
|
||||||
|
*/
|
||||||
|
private fun open(url: String): HttpURLConnection {
|
||||||
|
val parsed = URL(url)
|
||||||
|
require(parsed.protocol.equals("http", true) || parsed.protocol.equals("https", true)) {
|
||||||
|
"unsupported scheme"
|
||||||
|
}
|
||||||
|
return (parsed.openConnection() as HttpURLConnection).apply {
|
||||||
connectTimeout = 15_000
|
connectTimeout = 15_000
|
||||||
readTimeout = 60_000
|
readTimeout = 60_000
|
||||||
instanceFollowRedirects = true
|
instanceFollowRedirects = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads at most [MAX_BYTES] and gives up after [MAX_TRANSFER_MS] overall.
|
||||||
|
* `readTimeout` alone only bounds a single read, so a server trickling
|
||||||
|
* bytes could grow the buffer until the app died.
|
||||||
|
*/
|
||||||
private fun download(url: String): String {
|
private fun download(url: String): String {
|
||||||
val connection = open(url)
|
val connection = open(url)
|
||||||
|
val deadline = System.currentTimeMillis() + MAX_TRANSFER_MS
|
||||||
return try {
|
return try {
|
||||||
connection.inputStream.bufferedReader().use { it.readText() }
|
val builder = StringBuilder()
|
||||||
|
connection.inputStream.bufferedReader().use { reader ->
|
||||||
|
val buffer = CharArray(8 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val read = reader.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
builder.append(buffer, 0, read)
|
||||||
|
require(builder.length <= MAX_BYTES) { "playlist too large" }
|
||||||
|
require(System.currentTimeMillis() < deadline) { "download timed out" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.toString()
|
||||||
} finally {
|
} finally {
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
}
|
}
|
||||||
@@ -114,5 +141,7 @@ class SourceRepository(context: Context) {
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "SourceRepository"
|
const val TAG = "SourceRepository"
|
||||||
|
const val MAX_BYTES = 24 * 1024 * 1024
|
||||||
|
const val MAX_TRANSFER_MS = 120_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import java.util.Locale
|
|||||||
|
|
||||||
data class Programme(val start: Long, val stop: Long, val title: String)
|
data class Programme(val start: Long, val stop: Long, val title: String)
|
||||||
|
|
||||||
|
/** Provider EPGs pad gaps with placeholder programmes — treat as no data. */
|
||||||
|
fun isEpgPlaceholder(title: String): Boolean {
|
||||||
|
val t = title.lowercase()
|
||||||
|
return "kein programm" in t || "keine information" in t || "no information" in t
|
||||||
|
}
|
||||||
|
|
||||||
data class NowNext(val now: Programme?, val next: Programme?)
|
data class NowNext(val now: Programme?, val next: Programme?)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,35 +3,84 @@ package dev.castarr.tv.pairing
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
import java.net.NetworkInterface
|
import java.net.NetworkInterface
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
|
|
||||||
object Pairing {
|
object Pairing {
|
||||||
|
|
||||||
const val PORT = 8765
|
const val PORT = 8765
|
||||||
|
|
||||||
|
private const val PREFS = "pairing"
|
||||||
|
private const val KEY_CODE = "code"
|
||||||
|
private const val KEY_TOKEN = "token"
|
||||||
|
private const val KEY_SESSIONS = "session_tokens"
|
||||||
|
private const val MAX_SESSIONS = 8
|
||||||
|
|
||||||
|
private fun prefs(context: Context) =
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Four-digit pairing code, generated once and kept stable so an already
|
* Four-digit pairing code, generated once and kept stable so an already
|
||||||
* paired phone survives app restarts. Human fallback only — the QR code
|
* paired phone survives app restarts. Human fallback only — the QR code
|
||||||
* carries the long token below.
|
* carries the long token below.
|
||||||
*/
|
*/
|
||||||
fun code(context: Context): String {
|
fun code(context: Context): String {
|
||||||
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE)
|
prefs(context).getString(KEY_CODE, null)?.let { return it }
|
||||||
prefs.getString("code", null)?.let { return it }
|
|
||||||
val code = "%04d".format(SecureRandom().nextInt(10_000))
|
val code = "%04d".format(SecureRandom().nextInt(10_000))
|
||||||
prefs.edit().putString("code", code).apply()
|
prefs(context).edit().putString(KEY_CODE, code).apply()
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 128-bit random token embedded in the QR code; not brute-forceable. */
|
/** 128-bit random token embedded in the QR code; not brute-forceable. */
|
||||||
fun token(context: Context): String {
|
fun token(context: Context): String {
|
||||||
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE)
|
prefs(context).getString(KEY_TOKEN, null)?.let { return it }
|
||||||
prefs.getString("token", null)?.let { return it }
|
val token = randomToken()
|
||||||
val bytes = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
prefs(context).edit().putString(KEY_TOKEN, token).apply()
|
||||||
val token = bytes.joinToString("") { "%02x".format(it) }
|
|
||||||
prefs.edit().putString("token", token).apply()
|
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues a separate token for a client that authenticated with the
|
||||||
|
* four-digit code. Handing out the QR token there would make the
|
||||||
|
* brute-forceable path yield the un-rotating master credential.
|
||||||
|
*/
|
||||||
|
fun newSessionToken(context: Context): String {
|
||||||
|
val token = randomToken()
|
||||||
|
val sessions = sessionTokens(context).toMutableList()
|
||||||
|
sessions.add(token)
|
||||||
|
while (sessions.size > MAX_SESSIONS) sessions.removeAt(0)
|
||||||
|
prefs(context).edit().putStringSet(KEY_SESSIONS, sessions.toSet()).apply()
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time check against the QR token and all session tokens. */
|
||||||
|
fun isValidToken(context: Context, candidate: String): Boolean {
|
||||||
|
if (candidate.isEmpty()) return false
|
||||||
|
if (constantTimeEquals(candidate, token(context))) return true
|
||||||
|
return sessionTokens(context).any { constantTimeEquals(candidate, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValidCode(context: Context, candidate: String): Boolean =
|
||||||
|
candidate.isNotEmpty() && constantTimeEquals(candidate, code(context))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops every credential: the QR token, the code and all session tokens.
|
||||||
|
* Paired phones must scan again — the way to revoke a leaked token.
|
||||||
|
*/
|
||||||
|
fun reset(context: Context) {
|
||||||
|
prefs(context).edit().clear().apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sessionTokens(context: Context): List<String> =
|
||||||
|
prefs(context).getStringSet(KEY_SESSIONS, emptySet())?.toList().orEmpty()
|
||||||
|
|
||||||
|
private fun randomToken(): String =
|
||||||
|
ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
private fun constantTimeEquals(a: String, b: String): Boolean =
|
||||||
|
MessageDigest.isEqual(a.toByteArray(), b.toByteArray())
|
||||||
|
|
||||||
/** Best-guess LAN IPv4 address of this device, or null when offline. */
|
/** Best-guess LAN IPv4 address of this device, or null when offline. */
|
||||||
fun lanAddress(): String? {
|
fun lanAddress(): String? {
|
||||||
val candidates = runCatching {
|
val candidates = runCatching {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package dev.castarr.tv.player
|
package dev.castarr.tv.player
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.TrackSelectionOverride
|
||||||
|
import androidx.media3.common.Tracks
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause
|
* Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause
|
||||||
@@ -19,7 +23,10 @@ class PlayerController(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
val player: ExoPlayer = ExoPlayer.Builder(context).build()
|
val player: ExoPlayer = ExoPlayer.Builder(context).build()
|
||||||
private val mediaSession: MediaSession = MediaSession.Builder(context, player).build()
|
// Explicit id: Media3 keeps a process-wide registry and throws when a
|
||||||
|
// session with the same (default, empty) id is still registered.
|
||||||
|
private val mediaSession: MediaSession =
|
||||||
|
MediaSession.Builder(context, player).setId("castarr").build()
|
||||||
|
|
||||||
var channelName: String = ""
|
var channelName: String = ""
|
||||||
private set
|
private set
|
||||||
@@ -29,6 +36,19 @@ class PlayerController(
|
|||||||
private set
|
private set
|
||||||
private var retryCount = 0
|
private var retryCount = 0
|
||||||
private var lastUrl: String = ""
|
private var lastUrl: String = ""
|
||||||
|
private val retryHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
|
private var pendingRetry: Runnable? = null
|
||||||
|
|
||||||
|
/** True while a reconnect attempt is scheduled or running. */
|
||||||
|
var reconnecting: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** When the current channel was opened — used to pace re-entry. */
|
||||||
|
var startedAt: Long = 0L
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** One selectable audio track of the current stream. */
|
||||||
|
data class AudioTrack(val label: String, val selected: Boolean)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
player.addListener(object : Player.Listener {
|
player.addListener(object : Player.Listener {
|
||||||
@@ -36,15 +56,21 @@ class PlayerController(
|
|||||||
|
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()
|
override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()
|
||||||
|
|
||||||
|
override fun onTracksChanged(tracks: Tracks) = onChanged()
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
// Self-heal: silently reconnect twice before surfacing (#13).
|
// Self-heal with spacing: providers rate-limit reconnects, so
|
||||||
if (retryCount < 2 && lastUrl.isNotEmpty()) {
|
// three back-to-back attempts all land in the same blocked
|
||||||
|
// window and burn the budget in under a second.
|
||||||
|
if (retryCount < RETRY_DELAYS_MS.size && lastUrl.isNotEmpty()) {
|
||||||
|
val delay = RETRY_DELAYS_MS[retryCount]
|
||||||
retryCount++
|
retryCount++
|
||||||
player.setMediaItem(MediaItem.fromUri(lastUrl))
|
reconnecting = true
|
||||||
player.prepare()
|
onChanged()
|
||||||
player.play()
|
scheduleRetry(delay)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
reconnecting = false
|
||||||
errorMessage = "unreachable"
|
errorMessage = "unreachable"
|
||||||
onChanged()
|
onChanged()
|
||||||
}
|
}
|
||||||
@@ -54,6 +80,7 @@ class PlayerController(
|
|||||||
val state: String
|
val state: String
|
||||||
get() = when {
|
get() = when {
|
||||||
errorMessage != null -> "error"
|
errorMessage != null -> "error"
|
||||||
|
reconnecting -> "reconnecting"
|
||||||
player.playbackState == Player.STATE_BUFFERING -> "buffering"
|
player.playbackState == Player.STATE_BUFFERING -> "buffering"
|
||||||
player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing"
|
player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing"
|
||||||
player.playbackState == Player.STATE_READY -> "paused"
|
player.playbackState == Player.STATE_READY -> "paused"
|
||||||
@@ -65,9 +92,12 @@ class PlayerController(
|
|||||||
|
|
||||||
fun play(url: String, name: String, group: String) {
|
fun play(url: String, name: String, group: String) {
|
||||||
if (url.isEmpty()) return
|
if (url.isEmpty()) return
|
||||||
|
cancelRetry()
|
||||||
errorMessage = null
|
errorMessage = null
|
||||||
|
reconnecting = false
|
||||||
retryCount = 0
|
retryCount = 0
|
||||||
lastUrl = url
|
lastUrl = url
|
||||||
|
startedAt = System.currentTimeMillis()
|
||||||
channelName = name.ifEmpty { url }
|
channelName = name.ifEmpty { url }
|
||||||
channelGroup = group
|
channelGroup = group
|
||||||
val item = MediaItem.Builder()
|
val item = MediaItem.Builder()
|
||||||
@@ -79,13 +109,45 @@ class PlayerController(
|
|||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun scheduleRetry(delayMs: Long) {
|
||||||
|
cancelRetry()
|
||||||
|
val runnable = Runnable {
|
||||||
|
pendingRetry = null
|
||||||
|
player.setMediaItem(MediaItem.fromUri(lastUrl))
|
||||||
|
player.prepare()
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
pendingRetry = runnable
|
||||||
|
retryHandler.postDelayed(runnable, delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRetry() {
|
||||||
|
pendingRetry?.let { retryHandler.removeCallbacks(it) }
|
||||||
|
pendingRetry = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retries the current channel immediately, resetting the backoff. */
|
||||||
|
fun retryNow() {
|
||||||
|
if (lastUrl.isEmpty()) return
|
||||||
|
cancelRetry()
|
||||||
|
retryCount = 0
|
||||||
|
errorMessage = null
|
||||||
|
reconnecting = true
|
||||||
|
onChanged()
|
||||||
|
player.setMediaItem(MediaItem.fromUri(lastUrl))
|
||||||
|
player.prepare()
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
|
||||||
fun toggle() {
|
fun toggle() {
|
||||||
if (!hasMedia) return
|
if (!hasMedia) return
|
||||||
if (player.isPlaying) player.pause() else resume()
|
// playWhenReady, not isPlaying: the latter is false while buffering,
|
||||||
|
// which made pausing during a stall silently do nothing.
|
||||||
|
if (player.playWhenReady) pause() else resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
if (player.isPlaying) player.pause()
|
player.playWhenReady = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resume() {
|
fun resume() {
|
||||||
@@ -95,6 +157,8 @@ class PlayerController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
cancelRetry()
|
||||||
|
reconnecting = false
|
||||||
player.stop()
|
player.stop()
|
||||||
player.clearMediaItems()
|
player.clearMediaItems()
|
||||||
channelName = ""
|
channelName = ""
|
||||||
@@ -105,15 +169,66 @@ class PlayerController(
|
|||||||
onChanged()
|
onChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Audio tracks of the current stream, in group order. */
|
||||||
|
fun audioTracks(): List<AudioTrack> {
|
||||||
|
val result = mutableListOf<AudioTrack>()
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
player.currentTracks.groups
|
||||||
|
.filter { it.type == C.TRACK_TYPE_AUDIO }
|
||||||
|
.forEach { group ->
|
||||||
|
for (i in 0 until group.length) {
|
||||||
|
if (!group.isTrackSupported(i)) continue
|
||||||
|
val format = group.getTrackFormat(i)
|
||||||
|
var label = format.label
|
||||||
|
?: format.language?.let { Locale(it).getDisplayLanguage(Locale.GERMAN) }
|
||||||
|
?.replaceFirstChar { c -> c.uppercase() }
|
||||||
|
?: "Ton ${result.size + 1}"
|
||||||
|
if (!seen.add(label)) label = "$label ${result.size + 1}"
|
||||||
|
result.add(AudioTrack(label, group.isTrackSelected(i)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selects the audio track at [index] of [audioTracks]'s order. */
|
||||||
|
fun selectAudio(index: Int) {
|
||||||
|
var flat = 0
|
||||||
|
player.currentTracks.groups
|
||||||
|
.filter { it.type == C.TRACK_TYPE_AUDIO }
|
||||||
|
.forEach { group ->
|
||||||
|
for (i in 0 until group.length) {
|
||||||
|
if (!group.isTrackSupported(i)) continue
|
||||||
|
if (flat == index) {
|
||||||
|
player.trackSelectionParameters = player.trackSelectionParameters
|
||||||
|
.buildUpon()
|
||||||
|
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, i))
|
||||||
|
.build()
|
||||||
|
onChanged()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flat++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun seekBy(deltaSeconds: Long) {
|
fun seekBy(deltaSeconds: Long) {
|
||||||
if (!hasMedia || !player.isCurrentMediaItemSeekable) return
|
if (!hasMedia || !player.isCurrentMediaItemSeekable) return
|
||||||
val target = (player.currentPosition + deltaSeconds * 1000)
|
// An unknown duration is C.TIME_UNSET (large negative). Clamping the
|
||||||
.coerceIn(0, player.duration.coerceAtLeast(0))
|
// upper bound to it collapsed every seek to position 0.
|
||||||
|
val duration = player.duration
|
||||||
|
var target = (player.currentPosition + deltaSeconds * 1000).coerceAtLeast(0)
|
||||||
|
if (duration > 0) target = target.coerceAtMost(duration)
|
||||||
player.seekTo(target)
|
player.seekTo(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
|
cancelRetry()
|
||||||
mediaSession.release()
|
mediaSession.release()
|
||||||
player.release()
|
player.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Spacing between reconnect attempts after a stream drops. */
|
||||||
|
val RETRY_DELAYS_MS = longArrayOf(1_000L, 3_000L, 8_000L)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,24 @@ package dev.castarr.tv.playlist
|
|||||||
object M3uParser {
|
object M3uParser {
|
||||||
|
|
||||||
private const val MAX_CHANNELS = 5000
|
private const val MAX_CHANNELS = 5000
|
||||||
|
private const val BOM = ''
|
||||||
|
|
||||||
private val attrRegex = Regex("""([\w-]+)="([^"]*)"""")
|
private val attrRegex = Regex("""([\w-]+)="([^"]*)"""")
|
||||||
|
|
||||||
|
/** Thrown when the payload is not a playlist at all (HTML, JSON, …). */
|
||||||
|
class NotAPlaylistException : IllegalArgumentException("not an M3U playlist")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an M3U playlist. Rejects payloads that carry no playlist markers
|
||||||
|
* at all — pointing the app at a 404 page used to silently replace the
|
||||||
|
* channel list with HTML fragments.
|
||||||
|
*/
|
||||||
fun parse(content: String): List<Channel> {
|
fun parse(content: String): List<Channel> {
|
||||||
|
// A UTF-8 BOM is not whitespace, so trim() leaves it in place and the
|
||||||
|
// first line stops looking like a comment.
|
||||||
|
val text = content.trimStart(BOM)
|
||||||
|
if (!looksLikePlaylist(text)) throw NotAPlaylistException()
|
||||||
|
|
||||||
val channels = mutableListOf<Channel>()
|
val channels = mutableListOf<Channel>()
|
||||||
var name = ""
|
var name = ""
|
||||||
var group = ""
|
var group = ""
|
||||||
@@ -14,15 +28,15 @@ object M3uParser {
|
|||||||
var tvgId = ""
|
var tvgId = ""
|
||||||
var pendingInfo = false
|
var pendingInfo = false
|
||||||
|
|
||||||
for (rawLine in content.lineSequence()) {
|
for (rawLine in text.lineSequence()) {
|
||||||
val line = rawLine.trim()
|
val line = rawLine.trim().trimStart(BOM)
|
||||||
when {
|
when {
|
||||||
line.startsWith("#EXTINF", ignoreCase = true) -> {
|
line.startsWith("#EXTINF", ignoreCase = true) -> {
|
||||||
val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] }
|
val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] }
|
||||||
group = attrs["group-title"].orEmpty()
|
group = attrs["group-title"].orEmpty()
|
||||||
logo = attrs["tvg-logo"].orEmpty()
|
logo = attrs["tvg-logo"].orEmpty()
|
||||||
tvgId = attrs["tvg-id"].orEmpty()
|
tvgId = attrs["tvg-id"].orEmpty()
|
||||||
name = line.substringAfterLast(',', "").trim()
|
name = displayName(line)
|
||||||
if (name.isEmpty()) name = attrs["tvg-name"].orEmpty()
|
if (name.isEmpty()) name = attrs["tvg-name"].orEmpty()
|
||||||
pendingInfo = true
|
pendingInfo = true
|
||||||
}
|
}
|
||||||
@@ -41,11 +55,43 @@ object M3uParser {
|
|||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Bare URL without #EXTINF — still a playable entry.
|
// Bare URL without #EXTINF — still a playable entry.
|
||||||
channels.add(Channel(line, line, "", ""))
|
channels.add(Channel(line, line, group, ""))
|
||||||
if (channels.size >= MAX_CHANNELS) return channels
|
if (channels.size >= MAX_CHANNELS) return channels
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return channels
|
return channels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun looksLikePlaylist(text: String): Boolean {
|
||||||
|
val head = text.lineSequence().take(50)
|
||||||
|
return head.any {
|
||||||
|
val line = it.trim().trimStart(BOM)
|
||||||
|
line.startsWith("#EXTM3U", ignoreCase = true) ||
|
||||||
|
line.startsWith("#EXTINF", ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display name is everything after the FIRST comma that follows the
|
||||||
|
* duration field — `substringAfterLast` swallowed every name containing
|
||||||
|
* a comma ("Comedy Central, HD" became "HD").
|
||||||
|
*/
|
||||||
|
private fun displayName(extinf: String): String {
|
||||||
|
val payload = extinf.substringAfter(':', "")
|
||||||
|
val comma = firstUnquotedComma(payload)
|
||||||
|
return if (comma < 0) "" else payload.substring(comma + 1).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commas inside attribute values (group-title="A, B") do not separate. */
|
||||||
|
private fun firstUnquotedComma(payload: String): Int {
|
||||||
|
var inQuotes = false
|
||||||
|
payload.forEachIndexed { index, c ->
|
||||||
|
when {
|
||||||
|
c == '"' -> inQuotes = !inQuotes
|
||||||
|
c == ',' && !inQuotes -> return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import java.util.concurrent.Executors
|
|||||||
/**
|
/**
|
||||||
* Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
|
* Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
|
||||||
* page) over HTTP and takes playback commands over a WebSocket. A client
|
* page) over HTTP and takes playback commands over a WebSocket. A client
|
||||||
* authorizes itself with the four-digit pairing code from the QR code.
|
* authorizes itself with the token from the QR code or the four-digit
|
||||||
|
* pairing code.
|
||||||
*/
|
*/
|
||||||
class ControlServer(
|
class ControlServer(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
@@ -32,6 +33,7 @@ class ControlServer(
|
|||||||
fun onResumeCast()
|
fun onResumeCast()
|
||||||
fun onStopCast()
|
fun onStopCast()
|
||||||
fun onSeek(deltaSeconds: Long)
|
fun onSeek(deltaSeconds: Long)
|
||||||
|
fun onSetAudio(index: Int)
|
||||||
fun onVolume(value: Float)
|
fun onVolume(value: Float)
|
||||||
fun onSetPlaylist(url: String)
|
fun onSetPlaylist(url: String)
|
||||||
fun onToggleFavorite(channelId: Int)
|
fun onToggleFavorite(channelId: Int)
|
||||||
@@ -50,30 +52,43 @@ class ControlServer(
|
|||||||
// sends are serialized through this worker.
|
// sends are serialized through this worker.
|
||||||
private val sendExecutor = Executors.newSingleThreadExecutor()
|
private val sendExecutor = Executors.newSingleThreadExecutor()
|
||||||
private val clients = CopyOnWriteArrayList<RemoteSocket>()
|
private val clients = CopyOnWriteArrayList<RemoteSocket>()
|
||||||
private val pairingCode = Pairing.code(context)
|
|
||||||
private val pairingToken = Pairing.token(context)
|
|
||||||
private var pingTimer: Timer? = null
|
private var pingTimer: Timer? = null
|
||||||
|
|
||||||
// Rate limit for the human-typable 4-digit code (the QR token is not
|
/** False when the port could not be bound; the app stays usable. */
|
||||||
// brute-forceable and stays exempt).
|
@Volatile
|
||||||
private val codeAttempts = ArrayDeque<Long>()
|
var running = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
// Failed authentication attempts per remote address. Counting every
|
||||||
|
// failure (not just the ones carrying a code) keeps a hostile client
|
||||||
|
// from spending someone else's budget.
|
||||||
|
private val attempts = HashMap<String, ArrayDeque<Long>>()
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun codeAttemptAllowed(): Boolean {
|
private fun attemptAllowed(address: String): Boolean {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
while (codeAttempts.isNotEmpty() && now - codeAttempts.first() > CODE_ATTEMPT_WINDOW_MS) {
|
val queue = attempts.getOrPut(address) { ArrayDeque() }
|
||||||
codeAttempts.removeFirst()
|
while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) {
|
||||||
|
queue.removeFirst()
|
||||||
}
|
}
|
||||||
if (codeAttempts.size >= CODE_ATTEMPT_MAX) return false
|
if (attempts.size > MAX_TRACKED_ADDRESSES) {
|
||||||
codeAttempts.addLast(now)
|
attempts.entries.removeAll { it.value.isEmpty() }
|
||||||
|
}
|
||||||
|
if (queue.size >= ATTEMPT_MAX) return false
|
||||||
|
queue.addLast(now)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startServer() {
|
fun startServer() {
|
||||||
start(0, true)
|
// A busy port must not take the whole app down — the remote is
|
||||||
|
// optional, everything else keeps working.
|
||||||
|
running = runCatching { start(NanoHTTPD.SOCKET_READ_TIMEOUT, true) }
|
||||||
|
.onFailure { Log.w(TAG, "control server unavailable: ${it.javaClass.simpleName}") }
|
||||||
|
.isSuccess
|
||||||
|
if (!running) return
|
||||||
pingTimer = Timer("ws-ping", true).also {
|
pingTimer = Timer("ws-ping", true).also {
|
||||||
it.schedule(object : TimerTask() {
|
it.schedule(object : TimerTask() {
|
||||||
override fun run() = pingClients()
|
override fun run() = maintainClients()
|
||||||
}, PING_INTERVAL_MS, PING_INTERVAL_MS)
|
}, PING_INTERVAL_MS, PING_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,17 +97,41 @@ class ControlServer(
|
|||||||
pingTimer?.cancel()
|
pingTimer?.cancel()
|
||||||
pingTimer = null
|
pingTimer = null
|
||||||
sendExecutor.shutdown()
|
sendExecutor.shutdown()
|
||||||
stop()
|
if (running) runCatching { stop() }
|
||||||
|
running = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HTTP ---
|
// --- HTTP ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects cross-origin upgrades. NanoWSD itself never looks at `Origin`,
|
||||||
|
* and WebSockets are exempt from the same-origin policy, so without this
|
||||||
|
* any page on the LAN could talk to the control socket.
|
||||||
|
*/
|
||||||
|
override fun serve(session: IHTTPSession): Response {
|
||||||
|
val origin = session.headers["origin"]
|
||||||
|
if (origin != null && !isOwnOrigin(origin, session.headers["host"])) {
|
||||||
|
Log.w(TAG, "rejected cross-origin request")
|
||||||
|
return newFixedLengthResponse(
|
||||||
|
Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "forbidden",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return super.serve(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isOwnOrigin(origin: String, host: String?): Boolean {
|
||||||
|
if (host.isNullOrEmpty()) return false
|
||||||
|
return origin.equals("http://$host", ignoreCase = true) ||
|
||||||
|
origin.equals("https://$host", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
override fun serveHttp(session: IHTTPSession): Response {
|
override fun serveHttp(session: IHTTPSession): Response {
|
||||||
return when (session.uri) {
|
return when (session.uri) {
|
||||||
"/", "/index.html" -> {
|
"/", "/index.html" -> {
|
||||||
val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() }
|
val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() }
|
||||||
newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply {
|
newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply {
|
||||||
addHeader("Cache-Control", "no-store")
|
addHeader("Cache-Control", "no-store")
|
||||||
|
addHeader("Referrer-Policy", "no-referrer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found")
|
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found")
|
||||||
@@ -101,7 +140,8 @@ class ControlServer(
|
|||||||
|
|
||||||
// --- WebSocket ---
|
// --- WebSocket ---
|
||||||
|
|
||||||
override fun openWebSocket(handshake: IHTTPSession): WebSocket = RemoteSocket(handshake)
|
override fun openWebSocket(handshake: IHTTPSession): WebSocket =
|
||||||
|
RemoteSocket(handshake, handshake.remoteIpAddress.orEmpty())
|
||||||
|
|
||||||
fun broadcast(message: JSONObject) {
|
fun broadcast(message: JSONObject) {
|
||||||
val payload = message.toString()
|
val payload = message.toString()
|
||||||
@@ -138,8 +178,18 @@ class ControlServer(
|
|||||||
.put("channels", Channel.listToJson(listener.currentChannels()))
|
.put("channels", Channel.listToJson(listener.currentChannels()))
|
||||||
.put("extras", listener.channelsExtras())
|
.put("extras", listener.channelsExtras())
|
||||||
|
|
||||||
private fun pingClients() {
|
/**
|
||||||
|
* Pings live clients and drops sockets that never authenticated. Without
|
||||||
|
* this an unauthenticated connection would pin a thread forever.
|
||||||
|
*/
|
||||||
|
private fun maintainClients() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
clients.forEach { client ->
|
clients.forEach { client ->
|
||||||
|
if (!client.authorized && now - client.openedAt > HANDSHAKE_TIMEOUT_MS) {
|
||||||
|
Log.d(TAG, "dropping client that never authenticated")
|
||||||
|
client.dropSilently()
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
client.ping(PING_PAYLOAD)
|
client.ping(PING_PAYLOAD)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
@@ -155,12 +205,16 @@ class ControlServer(
|
|||||||
mainHandler.post { listener.onClientsChanged(count, newestName) }
|
mainHandler.post { listener.onClientsChanged(count, newestName) }
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class RemoteSocket(handshake: IHTTPSession) : WebSocket(handshake) {
|
inner class RemoteSocket(
|
||||||
|
handshake: IHTTPSession,
|
||||||
|
private val remoteAddress: String,
|
||||||
|
) : WebSocket(handshake) {
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var authorized = false
|
var authorized = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
val openedAt = System.currentTimeMillis()
|
||||||
private var deviceName: String = ""
|
private var deviceName: String = ""
|
||||||
|
|
||||||
fun trySend(payload: String) {
|
fun trySend(payload: String) {
|
||||||
@@ -172,7 +226,19 @@ class ControlServer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dropSilently() {
|
||||||
|
clients.remove(this)
|
||||||
|
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "timeout", false) }
|
||||||
|
}
|
||||||
|
|
||||||
override fun onOpen() {
|
override fun onOpen() {
|
||||||
|
// Cap concurrent sockets — a TV remote needs a handful, and an
|
||||||
|
// unbounded count is a free resource-exhaustion vector.
|
||||||
|
if (clients.size >= MAX_CLIENTS) {
|
||||||
|
Log.w(TAG, "client limit reached, refusing connection")
|
||||||
|
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "too many clients", false) }
|
||||||
|
return
|
||||||
|
}
|
||||||
clients.add(this)
|
clients.add(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +267,7 @@ class ControlServer(
|
|||||||
"resume" -> post { listener.onResumeCast() }
|
"resume" -> post { listener.onResumeCast() }
|
||||||
"stop" -> post { listener.onStopCast() }
|
"stop" -> post { listener.onStopCast() }
|
||||||
"seek" -> post { listener.onSeek(msg.optLong("delta")) }
|
"seek" -> post { listener.onSeek(msg.optLong("delta")) }
|
||||||
|
"set_audio" -> post { listener.onSetAudio(msg.optInt("index", -1)) }
|
||||||
"volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) }
|
"volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) }
|
||||||
"set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
|
"set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
|
||||||
"set_favorite" -> post { listener.onToggleFavorite(msg.optInt("id")) }
|
"set_favorite" -> post { listener.onToggleFavorite(msg.optInt("id")) }
|
||||||
@@ -225,18 +292,15 @@ class ControlServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleHello(msg: JSONObject) {
|
private fun handleHello(msg: JSONObject) {
|
||||||
val token = msg.optString("token")
|
// Every failed attempt counts against this address, whether it
|
||||||
val tokenOk = token.isNotEmpty() && token == pairingToken
|
// carried a token or a code.
|
||||||
val codeOk = !tokenOk && msg.optString("code").let { code ->
|
if (!attemptAllowed(remoteAddress)) {
|
||||||
code.isNotEmpty() && when {
|
|
||||||
!codeAttemptAllowed() -> {
|
|
||||||
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
|
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
|
||||||
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
|
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
else -> code == pairingCode
|
val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
|
||||||
}
|
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
|
||||||
}
|
|
||||||
if (!tokenOk && !codeOk) {
|
if (!tokenOk && !codeOk) {
|
||||||
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
|
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
|
||||||
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
|
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
|
||||||
@@ -244,6 +308,11 @@ class ControlServer(
|
|||||||
}
|
}
|
||||||
authorized = true
|
authorized = true
|
||||||
deviceName = msg.optString("name").ifEmpty { "Handy" }
|
deviceName = msg.optString("name").ifEmpty { "Handy" }
|
||||||
|
// A code-authenticated client gets its own revocable token, never
|
||||||
|
// the QR token — otherwise the guessable path would yield the
|
||||||
|
// master credential.
|
||||||
|
val grantedToken =
|
||||||
|
if (tokenOk) msg.optString("token") else Pairing.newSessionToken(context)
|
||||||
// Build the welcome payload on the main thread — it reads the
|
// Build the welcome payload on the main thread — it reads the
|
||||||
// player and UI state, which must not happen on the WS thread.
|
// player and UI state, which must not happen on the WS thread.
|
||||||
post {
|
post {
|
||||||
@@ -251,7 +320,7 @@ class ControlServer(
|
|||||||
.put("type", "welcome")
|
.put("type", "welcome")
|
||||||
.put("setup", listener.setupNeeded())
|
.put("setup", listener.setupNeeded())
|
||||||
.put("device", android.os.Build.MODEL)
|
.put("device", android.os.Build.MODEL)
|
||||||
.put("token", pairingToken)
|
.put("token", grantedToken)
|
||||||
.put("status", listener.currentStatus())
|
.put("status", listener.currentStatus())
|
||||||
.put("playlistUrl", listener.currentPlaylistUrl())
|
.put("playlistUrl", listener.currentPlaylistUrl())
|
||||||
.put("channels", Channel.listToJson(listener.currentChannels()))
|
.put("channels", Channel.listToJson(listener.currentChannels()))
|
||||||
@@ -270,8 +339,11 @@ class ControlServer(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "ControlServer"
|
const val TAG = "ControlServer"
|
||||||
const val PING_INTERVAL_MS = 8_000L
|
const val PING_INTERVAL_MS = 8_000L
|
||||||
const val CODE_ATTEMPT_WINDOW_MS = 60_000L
|
const val ATTEMPT_WINDOW_MS = 60_000L
|
||||||
const val CODE_ATTEMPT_MAX = 5
|
const val ATTEMPT_MAX = 5
|
||||||
|
const val MAX_TRACKED_ADDRESSES = 64
|
||||||
|
const val MAX_CLIENTS = 8
|
||||||
|
const val HANDSHAKE_TIMEOUT_MS = 10_000L
|
||||||
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
|
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,18 +46,18 @@ fun AdvancedScreen(state: AppState) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 40.dp, vertical = 20.dp)
|
.padding(horizontal = 40.dp, vertical = 20.dp)
|
||||||
) {
|
) {
|
||||||
Text("Erweitert", color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 22.sp)
|
Text("Erweitert", color = CastarrColors.fg, fontFamily = AppFont, fontSize = 22.sp)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
"Eigene M3U-Quelle (ErsatzTV, Threadfin, Tunarr …) — optionales XMLTV-EPG. " +
|
"Eigene M3U-Quelle (ErsatzTV, Threadfin, Tunarr …) — optionales XMLTV-EPG. " +
|
||||||
"Server: ${state.auth.serverUrl.ifEmpty { "—" }}",
|
"Server: ${state.auth.serverUrl.ifEmpty { "—" }}",
|
||||||
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp,
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(18.dp))
|
Spacer(Modifier.height(18.dp))
|
||||||
TvTextField(
|
TvTextField(
|
||||||
value = m3u,
|
value = m3u,
|
||||||
onValueChange = { m3u = it },
|
onValueChange = { m3u = it },
|
||||||
label = { Text("M3U-URL", fontFamily = SpaceGrotesk) },
|
label = { Text("M3U-URL", fontFamily = AppFont) },
|
||||||
colors = fieldColors,
|
colors = fieldColors,
|
||||||
textStyle = TextStyle(fontSize = 14.sp),
|
textStyle = TextStyle(fontSize = 14.sp),
|
||||||
modifier = Modifier.width(600.dp),
|
modifier = Modifier.width(600.dp),
|
||||||
@@ -66,7 +66,7 @@ fun AdvancedScreen(state: AppState) {
|
|||||||
TvTextField(
|
TvTextField(
|
||||||
value = epg,
|
value = epg,
|
||||||
onValueChange = { epg = it },
|
onValueChange = { epg = it },
|
||||||
label = { Text("XMLTV-EPG-URL (optional)", fontFamily = SpaceGrotesk) },
|
label = { Text("XMLTV-EPG-URL (optional)", fontFamily = AppFont) },
|
||||||
colors = fieldColors,
|
colors = fieldColors,
|
||||||
textStyle = TextStyle(fontSize = 14.sp),
|
textStyle = TextStyle(fontSize = 14.sp),
|
||||||
modifier = Modifier.width(600.dp),
|
modifier = Modifier.width(600.dp),
|
||||||
@@ -92,7 +92,7 @@ fun AdvancedScreen(state: AppState) {
|
|||||||
else -> message
|
else -> message
|
||||||
}
|
}
|
||||||
if (statusText.isNotEmpty()) {
|
if (statusText.isNotEmpty()) {
|
||||||
Text(statusText, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp)
|
Text(statusText, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 13.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,17 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -22,11 +28,23 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
|
import androidx.tv.material3.LocalContentColor
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import dev.castarr.tv.AppState
|
import dev.castarr.tv.AppState
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CastarrApp(state: AppState) {
|
fun CastarrApp(state: AppState) {
|
||||||
|
// Back always leads home instead of quitting the app by accident.
|
||||||
|
androidx.activity.compose.BackHandler(
|
||||||
|
enabled = !state.playerVisible && state.screen != AppState.Screen.LIVE
|
||||||
|
&& state.screen != AppState.Screen.WELCOME
|
||||||
|
) {
|
||||||
|
state.screen = if (state.screen == AppState.Screen.ADVANCED) AppState.Screen.SETTINGS
|
||||||
|
else AppState.Screen.LIVE
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -48,6 +66,41 @@ fun CastarrApp(state: AppState) {
|
|||||||
if (state.playerVisible) {
|
if (state.playerVisible) {
|
||||||
PlayerScreen(state)
|
PlayerScreen(state)
|
||||||
}
|
}
|
||||||
|
if (state.digitBuffer.isNotEmpty()) {
|
||||||
|
DigitBadge(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Big feedback badge while typing a channel number on the remote. */
|
||||||
|
@Composable
|
||||||
|
private fun DigitBadge(state: AppState) {
|
||||||
|
val target = state.digitBuffer.toIntOrNull()
|
||||||
|
?.let { state.activeChannels().getOrNull(it - 1) }
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 60.dp, end = 48.dp)
|
||||||
|
.fillMaxSize()
|
||||||
|
.wrapContentSize(Alignment.TopEnd)
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(CastarrColors.surface)
|
||||||
|
.padding(horizontal = 22.dp, vertical = 14.dp),
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
state.digitBuffer,
|
||||||
|
color = CastarrColors.fg,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 34.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
target?.name ?: "kein Sender",
|
||||||
|
color = if (target != null) CastarrColors.muted else CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,14 +118,6 @@ private fun TopBar(state: AppState) {
|
|||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
letterSpacing = 4.sp,
|
letterSpacing = 4.sp,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(36.dp))
|
|
||||||
TabItem("Live", state.screen == AppState.Screen.LIVE) {
|
|
||||||
state.screen = AppState.Screen.LIVE
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
TabItem("Einstellungen", state.screen == AppState.Screen.SETTINGS) {
|
|
||||||
state.screen = AppState.Screen.SETTINGS
|
|
||||||
}
|
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
state.connectedRemote?.let { name ->
|
state.connectedRemote?.let { name ->
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -83,30 +128,59 @@ private fun TopBar(state: AppState) {
|
|||||||
.background(CastarrColors.accent)
|
.background(CastarrColors.accent)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(name, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp)
|
Text(name, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 13.sp)
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.width(18.dp))
|
||||||
|
}
|
||||||
|
GearButton {
|
||||||
|
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
|
||||||
|
else AppState.Screen.LIVE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Round settings button with a drawn gear (glyphs render as emoji). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun TabItem(label: String, selected: Boolean, onClick: () -> Unit) {
|
private fun GearButton(onClick: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
modifier = Modifier.semantics { contentDescription = "Einstellungen" },
|
||||||
|
shape = ClickableSurfaceDefaults.shape(CircleShape),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = if (selected) CastarrColors.accentDim else Color.Transparent,
|
containerColor = CastarrColors.surface,
|
||||||
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
|
contentColor = CastarrColors.muted,
|
||||||
focusedContainerColor = CastarrColors.accent,
|
focusedContainerColor = CastarrColors.accent,
|
||||||
focusedContentColor = CastarrColors.onAccent,
|
focusedContentColor = CastarrColors.onAccent,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text(
|
val color = LocalContentColor.current
|
||||||
label,
|
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
||||||
fontFamily = SpaceGrotesk,
|
Canvas(Modifier.size(18.dp)) {
|
||||||
fontSize = 14.sp,
|
val center = Offset(size.width / 2f, size.height / 2f)
|
||||||
fontWeight = FontWeight.Medium,
|
// Thick ring with a hole plus stubby teeth — thin lines
|
||||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),
|
// read as a sun, not a gear.
|
||||||
|
val ringWidth = size.minDimension * 0.20f
|
||||||
|
drawCircle(
|
||||||
|
color,
|
||||||
|
radius = size.minDimension * 0.27f,
|
||||||
|
center = center,
|
||||||
|
style = Stroke(ringWidth),
|
||||||
|
)
|
||||||
|
val toothStart = size.minDimension * 0.34f
|
||||||
|
val toothEnd = size.minDimension * 0.50f
|
||||||
|
for (i in 0 until 8) {
|
||||||
|
val angle = (i + 0.5) * (PI / 4)
|
||||||
|
val dx = cos(angle).toFloat()
|
||||||
|
val dy = sin(angle).toFloat()
|
||||||
|
drawLine(
|
||||||
|
color,
|
||||||
|
start = center + Offset(dx * toothStart, dy * toothStart),
|
||||||
|
end = center + Offset(dx * toothEnd, dy * toothEnd),
|
||||||
|
strokeWidth = size.minDimension * 0.17f,
|
||||||
|
cap = StrokeCap.Butt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ fun ErrorScreen(state: AppState) {
|
|||||||
"Neu anmelden",
|
"Neu anmelden",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(title, color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 30.sp)
|
Text(title, color = CastarrColors.fg, fontFamily = AppFont, fontSize = 30.sp)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 15.sp,
|
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 15.sp,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(26.dp))
|
Spacer(Modifier.height(26.dp))
|
||||||
@@ -78,7 +78,7 @@ fun ErrorScreen(state: AppState) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
buttonLabel,
|
buttonLabel,
|
||||||
fontFamily = SpaceGrotesk, fontSize = 16.sp,
|
fontFamily = AppFont, fontSize = 16.sp,
|
||||||
modifier = Modifier.padding(horizontal = 26.dp, vertical = 13.dp),
|
modifier = Modifier.padding(horizontal = 26.dp, vertical = 13.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package dev.castarr.tv.ui
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -13,29 +15,48 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coil.compose.SubcomposeAsyncImage
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.tv.material3.Border
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import dev.castarr.tv.AppState
|
import dev.castarr.tv.AppState
|
||||||
import dev.castarr.tv.data.NowNext
|
import dev.castarr.tv.data.NowNext
|
||||||
|
import dev.castarr.tv.data.isEpgPlaceholder
|
||||||
import dev.castarr.tv.playlist.Channel
|
import dev.castarr.tv.playlist.Channel
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live tab: group rail on the left (focusing a group opens it — exclusive,
|
||||||
|
* like a menu), channels of the open group with Now/Next on the right.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LiveScreen(state: AppState) {
|
fun LiveScreen(state: AppState) {
|
||||||
val genericChannels by state.source.channels.collectAsStateWithLifecycle()
|
val genericChannels by state.source.channels.collectAsStateWithLifecycle()
|
||||||
@@ -50,61 +71,160 @@ fun LiveScreen(state: AppState) {
|
|||||||
val groups = remember(allChannels) {
|
val groups = remember(allChannels) {
|
||||||
allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted()
|
allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted()
|
||||||
}
|
}
|
||||||
val channels = allChannels
|
val channels = when {
|
||||||
.let { list -> if (isDispatcharr && state.favoritesOnly) list.filter { it.backendId in favorites } else list }
|
state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
|
||||||
.let { list -> state.groupFilter?.let { g -> list.filter { it.group == g } } ?: list }
|
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
|
||||||
|
else -> allChannels
|
||||||
|
}
|
||||||
|
|
||||||
if (allChannels.isEmpty()) {
|
if (allChannels.isEmpty()) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
"Noch keine Sender — richte unter Einstellungen eine Quelle ein.",
|
"Noch keine Sender — richte unter Einstellungen eine Quelle ein.",
|
||||||
color = CastarrColors.faint,
|
color = CastarrColors.faint,
|
||||||
fontFamily = SpaceGrotesk,
|
fontFamily = AppFont,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
// D-pad choreography: left from any channel row lands on the SELECTED
|
||||||
LazyRow(
|
// group (not the spatially nearest one), right from the rail enters the
|
||||||
modifier = Modifier.padding(bottom = 6.dp),
|
// list at the top — which also gets scrolled up on every group change.
|
||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 40.dp),
|
val railFocus = remember { FocusRequester() }
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
val listFocus = remember { FocusRequester() }
|
||||||
|
val restoreFocus = remember { FocusRequester() }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
// Index of the channel the viewer last watched, so leaving playback
|
||||||
|
// returns them to where they were instead of the top of the list.
|
||||||
|
val restoreIndex = state.lastWatched?.let { watched ->
|
||||||
|
channels.indexOfFirst { it.url == watched.url }.takeIf { it >= 0 }
|
||||||
|
}
|
||||||
|
var restored by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(state.groupFilter, state.favoritesOnly) {
|
||||||
|
// A fresh group starts at the top; a return from playback does not.
|
||||||
|
if (restoreIndex == null) listState.scrollToItem(0)
|
||||||
|
}
|
||||||
|
LaunchedEffect(restoreIndex, channels.size, state.restorePending) {
|
||||||
|
if (restoreIndex != null && (state.restorePending || !restored)) {
|
||||||
|
listState.scrollToItem(restoreIndex)
|
||||||
|
runCatching { restoreFocus.requestFocus() }
|
||||||
|
restored = true
|
||||||
|
state.restorePending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val intoList = if (channels.isEmpty()) Modifier
|
||||||
|
else Modifier.focusProperties { right = listFocus }
|
||||||
|
|
||||||
|
Row(Modifier.fillMaxSize()) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(264.dp)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||||
|
start = 28.dp, end = 12.dp, top = 4.dp, bottom = 32.dp
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
FilterChip("Alle", !state.favoritesOnly && state.groupFilter == null) {
|
val selected = !state.favoritesOnly && state.groupFilter == null
|
||||||
|
GroupItem(
|
||||||
|
label = "Alle Sender",
|
||||||
|
count = allChannels.size,
|
||||||
|
selected = selected,
|
||||||
|
modifier = intoList.then(
|
||||||
|
if (selected) Modifier.focusRequester(railFocus) else Modifier
|
||||||
|
),
|
||||||
|
suppressAutoSelect = { state.restorePending },
|
||||||
|
) {
|
||||||
state.favoritesOnly = false
|
state.favoritesOnly = false
|
||||||
state.groupFilter = null
|
state.groupFilter = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isDispatcharr) {
|
if (isDispatcharr) {
|
||||||
item {
|
item {
|
||||||
FilterChip("★ Favoriten", state.favoritesOnly) {
|
GroupItem(
|
||||||
state.favoritesOnly = !state.favoritesOnly
|
label = "★ Favoriten",
|
||||||
|
count = favorites.size,
|
||||||
|
selected = state.favoritesOnly,
|
||||||
|
modifier = intoList.then(
|
||||||
|
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
|
||||||
|
),
|
||||||
|
suppressAutoSelect = { state.restorePending },
|
||||||
|
) {
|
||||||
|
state.favoritesOnly = true
|
||||||
|
state.groupFilter = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 14.dp, vertical = 8.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(1.dp)
|
||||||
|
.background(CastarrColors.line)
|
||||||
|
)
|
||||||
|
}
|
||||||
items(groups, key = { it }) { group ->
|
items(groups, key = { it }) { group ->
|
||||||
FilterChip(group, state.groupFilter == group) {
|
GroupItem(
|
||||||
state.groupFilter = if (state.groupFilter == group) null else group
|
label = group,
|
||||||
}
|
count = remember(allChannels, group) { allChannels.count { it.group == group } },
|
||||||
}
|
selected = state.groupFilter == group,
|
||||||
}
|
modifier = intoList.then(
|
||||||
LazyColumn(
|
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
|
||||||
start = 40.dp, end = 40.dp, top = 8.dp, bottom = 32.dp
|
|
||||||
),
|
),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
suppressAutoSelect = { state.restorePending },
|
||||||
) {
|
) {
|
||||||
items(channels, key = { it.url + it.name }) { channel ->
|
state.favoritesOnly = false
|
||||||
|
state.groupFilter = group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||||
|
start = 8.dp, end = 40.dp, top = 4.dp, bottom = 32.dp
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
if (channels.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
if (state.favoritesOnly)
|
||||||
|
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
||||||
|
else "Diese Gruppe ist leer.",
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
itemsIndexed(channels, key = { _, c -> c.url + c.name }) { listIndex, channel ->
|
||||||
ChannelRow(
|
ChannelRow(
|
||||||
channel = channel,
|
channel = channel,
|
||||||
index = channels.indexOf(channel),
|
// Stable channel number = position in "Alle Sender",
|
||||||
|
// matching the remote's number pad in every view.
|
||||||
|
number = allChannels.indexOf(channel) + 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.focusProperties { left = railFocus }
|
||||||
|
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier)
|
||||||
|
.then(
|
||||||
|
if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus)
|
||||||
|
else Modifier
|
||||||
|
),
|
||||||
nowNext = state.nowNext(channel),
|
nowNext = state.nowNext(channel),
|
||||||
playing = state.currentChannel?.url == channel.url,
|
playing = state.currentChannel?.url == channel.url,
|
||||||
favorite = isDispatcharr && channel.backendId in favorites,
|
// All rows are favorites in the favorites view — the
|
||||||
|
// star only carries meaning elsewhere.
|
||||||
|
favorite = isDispatcharr && !state.favoritesOnly &&
|
||||||
|
channel.backendId in favorites,
|
||||||
epgStamp = epgStamp,
|
epgStamp = epgStamp,
|
||||||
onLongClick = if (isDispatcharr) {
|
onLongClick = if (isDispatcharr) {
|
||||||
{ state.dispatcharr.toggleFavorite(channel) }
|
{ state.dispatcharr.toggleFavorite(channel) }
|
||||||
@@ -116,30 +236,58 @@ fun LiveScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FilterChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
private fun GroupItem(
|
||||||
|
label: String,
|
||||||
|
count: Int,
|
||||||
|
selected: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
suppressAutoSelect: () -> Boolean = { false },
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
// Focusing a group opens it right away (browse like a menu);
|
||||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
// clicking is not required, but harmless.
|
||||||
|
onClick = onSelect,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onFocusChanged { if (it.isFocused && !suppressAutoSelect()) onSelect() },
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface,
|
containerColor = if (selected) CastarrColors.accentDim else Color.Transparent,
|
||||||
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
|
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
|
||||||
focusedContainerColor = CastarrColors.accent,
|
focusedContainerColor = CastarrColors.accent,
|
||||||
focusedContentColor = CastarrColors.onAccent,
|
focusedContentColor = CastarrColors.onAccent,
|
||||||
),
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
fontFamily = SpaceGrotesk,
|
fontFamily = AppFont,
|
||||||
fontSize = 12.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"$count",
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChannelRow(
|
private fun ChannelRow(
|
||||||
channel: Channel,
|
channel: Channel,
|
||||||
index: Int,
|
number: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
nowNext: NowNext,
|
nowNext: NowNext,
|
||||||
playing: Boolean,
|
playing: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
@@ -150,57 +298,58 @@ private fun ChannelRow(
|
|||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
|
||||||
|
// No focus scale — grown rows get clipped at the pane edges.
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = if (playing) CastarrColors.accentDim else Color.Transparent,
|
containerColor = if (playing) CastarrColors.accentDim else CastarrColors.surface,
|
||||||
contentColor = CastarrColors.fg,
|
contentColor = CastarrColors.fg,
|
||||||
focusedContainerColor = CastarrColors.surfaceFocused,
|
focusedContainerColor = CastarrColors.surfaceFocused,
|
||||||
focusedContentColor = CastarrColors.fg,
|
focusedContentColor = CastarrColors.fg,
|
||||||
),
|
),
|
||||||
|
border = ClickableSurfaceDefaults.border(
|
||||||
|
focusedBorder = Border(
|
||||||
|
border = BorderStroke(2.dp, CastarrColors.accent),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"${index + 1}",
|
"$number",
|
||||||
color = if (playing) CastarrColors.accent else CastarrColors.faint,
|
color = if (playing) CastarrColors.accent else CastarrColors.faint,
|
||||||
fontFamily = SpaceGrotesk,
|
fontFamily = AppFont,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
modifier = Modifier.width(44.dp),
|
modifier = Modifier.width(40.dp),
|
||||||
)
|
)
|
||||||
if (favorite) {
|
LogoTile(channel)
|
||||||
Text(
|
Spacer(Modifier.width(14.dp))
|
||||||
"★",
|
Row(
|
||||||
color = CastarrColors.accent,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontSize = 13.sp,
|
modifier = Modifier.width(230.dp),
|
||||||
modifier = Modifier.width(20.dp),
|
) {
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(
|
Text(
|
||||||
channel.name,
|
channel.name,
|
||||||
color = if (playing) CastarrColors.accent else CastarrColors.fg,
|
color = if (playing) CastarrColors.accent else CastarrColors.fg,
|
||||||
fontFamily = SpaceGrotesk,
|
fontFamily = AppFont,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
|
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
)
|
)
|
||||||
if (channel.group.isNotEmpty()) {
|
if (favorite) {
|
||||||
Text(
|
Spacer(Modifier.width(8.dp))
|
||||||
channel.group,
|
Text("★", color = CastarrColors.accent, fontSize = 13.sp)
|
||||||
color = CastarrColors.faint,
|
|
||||||
fontFamily = SpaceGrotesk,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NowNextCell(nowNext)
|
Spacer(Modifier.width(24.dp))
|
||||||
|
EpgCell(nowNext, Modifier.weight(1f))
|
||||||
if (playing) {
|
if (playing) {
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Box(
|
Box(
|
||||||
@@ -214,25 +363,76 @@ private fun ChannelRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bare channel logo (they ship transparent); initials as fallback. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun NowNextCell(nowNext: NowNext) {
|
private fun LogoTile(channel: Channel) {
|
||||||
val now = nowNext.now
|
val initials = channel.name.split(" ").filter { it.isNotBlank() }
|
||||||
Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(320.dp)) {
|
.take(2).map { it.first() }.joinToString("").uppercase()
|
||||||
|
Box(Modifier.size(width = 52.dp, height = 40.dp), contentAlignment = Alignment.Center) {
|
||||||
|
if (channel.logo.isNotEmpty()) {
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = channel.logo,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
loading = { LogoInitials(initials) },
|
||||||
|
error = { LogoInitials(initials) },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LogoInitials(initials)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LogoInitials(initials: String) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
initials,
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatClock(millis: Long): String =
|
||||||
|
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left-aligned EPG column with a fixed leading edge: title + times, progress
|
||||||
|
* under the title, next programme only when it differs (design review P1/P2).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) {
|
||||||
|
val now = nowNext.now?.takeUnless { isEpgPlaceholder(it.title) }
|
||||||
|
Column(modifier) {
|
||||||
if (now != null) {
|
if (now != null) {
|
||||||
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
Text(
|
Text(
|
||||||
now.title,
|
now.title,
|
||||||
color = CastarrColors.muted,
|
color = CastarrColors.fg,
|
||||||
fontFamily = SpaceGrotesk,
|
fontFamily = AppFont,
|
||||||
fontSize = 13.sp,
|
fontSize = 14.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
"${formatClock(now.start)}–${formatClock(now.stop)}",
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
val fraction = ((System.currentTimeMillis() - now.start).toFloat() /
|
val fraction = ((System.currentTimeMillis() - now.start).toFloat() /
|
||||||
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
|
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
|
||||||
Spacer(Modifier.height(5.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.width(180.dp)
|
.fillMaxWidth()
|
||||||
.height(2.dp)
|
.height(2.dp)
|
||||||
.clip(RoundedCornerShape(1.dp))
|
.clip(RoundedCornerShape(1.dp))
|
||||||
.background(CastarrColors.line)
|
.background(CastarrColors.line)
|
||||||
@@ -244,13 +444,16 @@ private fun NowNextCell(nowNext: NowNext) {
|
|||||||
.background(CastarrColors.accent)
|
.background(CastarrColors.accent)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
nowNext.next?.let { next ->
|
val next = nowNext.next?.takeUnless {
|
||||||
Spacer(Modifier.height(4.dp))
|
isEpgPlaceholder(it.title) || it.title == now.title
|
||||||
|
}
|
||||||
|
if (next != null) {
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
Text(
|
Text(
|
||||||
"danach: ${next.title}",
|
"danach: ${next.title}",
|
||||||
color = CastarrColors.faint,
|
color = CastarrColors.faint,
|
||||||
fontFamily = SpaceGrotesk,
|
fontFamily = AppFont,
|
||||||
fontSize = 11.sp,
|
fontSize = 12.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package dev.castarr.tv.ui
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
import android.view.KeyEvent
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -17,7 +17,10 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -25,29 +28,51 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.Shadow
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
|
import androidx.tv.material3.LocalContentColor
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import dev.castarr.tv.AppState
|
import dev.castarr.tv.AppState
|
||||||
|
import dev.castarr.tv.data.isEpgPlaceholder
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fullscreen playback with the minimal auto-hiding overlay. Key handling for
|
* Fullscreen playback. OK opens the auto-hiding overlay whose transport
|
||||||
* play/pause/zapping lives in MainActivity.onKeyDown; this composable only
|
* buttons are ordinary focusable controls; media/channel keys are handled
|
||||||
* grabs focus so list items underneath stop reacting.
|
* in MainActivity.onKeyDown.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerScreen(state: AppState) {
|
fun PlayerScreen(state: AppState) {
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
LaunchedEffect(state.overlayVisible) {
|
||||||
LaunchedEffect(state.playerVisible, state.playerState) {
|
// While the overlay is hidden the root grabs focus so list items
|
||||||
state.overlayVisible = true
|
// underneath stop reacting; the overlay's play button takes over
|
||||||
if (state.playerState == "playing") {
|
// when it appears.
|
||||||
kotlinx.coroutines.delay(4000)
|
if (!state.overlayVisible) focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
LaunchedEffect(state.playerVisible, state.playerState, state.overlayVisible, state.overlayPing) {
|
||||||
|
if (state.overlayVisible && state.playerState == "playing") {
|
||||||
|
kotlinx.coroutines.delay(5000)
|
||||||
state.overlayVisible = false
|
state.overlayVisible = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,17 +101,49 @@ fun PlayerScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Soft drop shadow so overlay text stays readable on bright video. */
|
||||||
|
private val OverlayText = TextStyle(
|
||||||
|
shadow = Shadow(Color(0xB3000000), Offset(0f, 2f), blurRadius = 8f),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun formatClock(millis: Long): String =
|
||||||
|
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Overlay(state: AppState) {
|
private fun Overlay(state: AppState) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
val playFocus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { playFocus.requestFocus() }
|
||||||
|
// The key-UP of the very press that opened this overlay arrives after
|
||||||
|
// the play button grabbed focus and would click it (instant pause).
|
||||||
|
// Swallow select-releases whose press the overlay never saw.
|
||||||
|
var sawSelectDown by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// Any key while the overlay is open keeps it open a while longer.
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
state.pingOverlay()
|
||||||
|
val select = event.key == Key.DirectionCenter ||
|
||||||
|
event.key == Key.Enter || event.key == Key.NumPadEnter
|
||||||
|
when {
|
||||||
|
select && event.type == KeyEventType.KeyDown -> {
|
||||||
|
sawSelectDown = true
|
||||||
|
false
|
||||||
|
}
|
||||||
|
select && event.type == KeyEventType.KeyUp && !sawSelectDown -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(200.dp)
|
.height(280.dp)
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
listOf(Color.Transparent, CastarrColors.bgDeep.copy(alpha = 0.92f))
|
listOf(Color.Transparent, CastarrColors.bgDeep.copy(alpha = 0.94f))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -97,7 +154,8 @@ private fun Overlay(state: AppState) {
|
|||||||
.padding(top = 22.dp, end = 30.dp),
|
.padding(top = 22.dp, end = 30.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(name, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp)
|
Text(name, color = CastarrColors.muted, fontFamily = AppFont,
|
||||||
|
style = OverlayText, fontSize = 12.sp)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -112,63 +170,234 @@ private fun Overlay(state: AppState) {
|
|||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 36.dp)
|
.padding(horizontal = 36.dp)
|
||||||
.padding(bottom = 30.dp),
|
.padding(bottom = 24.dp),
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
// No status word while simply playing, and no LIVE badge — a
|
||||||
if (state.isLive) {
|
// red dot reads as "recording" (user feedback).
|
||||||
Box(
|
val statusWord = when (state.playerState) {
|
||||||
Modifier
|
|
||||||
.size(6.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(CastarrColors.live)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
"LIVE",
|
|
||||||
color = CastarrColors.muted,
|
|
||||||
fontFamily = SpaceGrotesk,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
letterSpacing = 2.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
Text(
|
|
||||||
when (state.playerState) {
|
|
||||||
"paused" -> "Pausiert"
|
"paused" -> "Pausiert"
|
||||||
"buffering" -> "Lädt…"
|
"buffering" -> "Lädt…"
|
||||||
"error" -> "Wiedergabefehler"
|
"reconnecting" -> "Verbindung wird wiederhergestellt…"
|
||||||
else -> "Wird abgespielt"
|
"error" -> "Sender gerade nicht erreichbar"
|
||||||
},
|
else -> null
|
||||||
|
}
|
||||||
|
if (statusWord != null) {
|
||||||
|
Row {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
statusWord,
|
||||||
color = CastarrColors.muted,
|
color = CastarrColors.muted,
|
||||||
fontFamily = SpaceGrotesk,
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
|
// Channel number inline before the name (matches the list
|
||||||
|
// and the number-pad zapping).
|
||||||
|
val number = state.currentChannel?.let { current ->
|
||||||
|
state.activeChannels().indexOfFirst { it.url == current.url } + 1
|
||||||
|
} ?: 0
|
||||||
|
if (number > 0) {
|
||||||
|
Text(
|
||||||
|
"$number",
|
||||||
|
color = CastarrColors.accent,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
|
fontSize = 30.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
state.currentChannel?.name.orEmpty(),
|
state.currentChannel?.name.orEmpty(),
|
||||||
color = CastarrColors.fg,
|
color = CastarrColors.fg,
|
||||||
fontFamily = SpaceGrotesk,
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
fontSize = 30.sp,
|
fontSize = 30.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
}
|
||||||
val fraction = if (state.isLive || state.durationMs <= 0) 1f
|
|
||||||
else (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f)
|
val info = state.currentChannel?.let { state.nowNext(it) }
|
||||||
|
val now = info?.now?.takeUnless { isEpgPlaceholder(it.title) }
|
||||||
|
if (now != null) {
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
|
Text(
|
||||||
|
now.title,
|
||||||
|
color = CastarrColors.fg,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Text(
|
||||||
|
"${formatClock(now.start)} – ${formatClock(now.stop)}",
|
||||||
|
color = Color(0xFFD7DBE0),
|
||||||
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info?.next?.takeUnless {
|
||||||
|
isEpgPlaceholder(it.title) || it.title == now?.title
|
||||||
|
}?.let { next ->
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
"Danach: ${next.title} (${formatClock(next.start)})",
|
||||||
|
color = CastarrColors.muted,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
// Live with EPG: progress of the running programme. Otherwise
|
||||||
|
// playback position; unknown duration fills the bar.
|
||||||
|
val fraction = when {
|
||||||
|
now != null && now.stop > now.start ->
|
||||||
|
((System.currentTimeMillis() - now.start).toFloat() / (now.stop - now.start))
|
||||||
|
.coerceIn(0f, 1f)
|
||||||
|
state.isLive || state.durationMs <= 0 -> 1f
|
||||||
|
else -> (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(2.dp)
|
.height(3.dp)
|
||||||
.clip(RoundedCornerShape(1.dp))
|
.clip(RoundedCornerShape(1.5.dp))
|
||||||
.background(CastarrColors.line)
|
.background(Color(0x59FFFFFF))
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth(fraction)
|
.fillMaxWidth(fraction)
|
||||||
.height(2.dp)
|
.height(3.dp)
|
||||||
.background(CastarrColors.accent)
|
.background(CastarrColors.accent)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (state.playerState == "error") {
|
||||||
|
// One clear action instead of silently dropping back to
|
||||||
|
// the channel list.
|
||||||
|
PillButton("Erneut versuchen", focusRequester = playFocus) {
|
||||||
|
state.retryPlayback()
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") {
|
||||||
|
state.stopPlayback()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(
|
||||||
|
icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE,
|
||||||
|
contentDescription = if (state.playerState == "paused") "Weiter" else "Pause",
|
||||||
|
focusRequester = playFocus,
|
||||||
|
) { state.player.toggle() }
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") {
|
||||||
|
state.stopPlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.audioTracks.size > 1) {
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
val current = state.audioTracks.firstOrNull { it.selected }
|
||||||
|
PillButton(label = "Ton: ${current?.label ?: "–"}") { state.cycleAudio(1) }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
"▲ ▼ Sender wechseln",
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum class PillIcon { PLAY, PAUSE, STOP }
|
||||||
|
|
||||||
|
/** Round icon-only transport button; the icons are drawn, not typed —
|
||||||
|
* glyphs like U+23F8 render as emoji on Android. */
|
||||||
|
@Composable
|
||||||
|
private fun IconButton(
|
||||||
|
icon: PillIcon,
|
||||||
|
contentDescription: String,
|
||||||
|
focusRequester: FocusRequester? = null,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = (if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
|
.semantics { this.contentDescription = contentDescription },
|
||||||
|
shape = ClickableSurfaceDefaults.shape(CircleShape),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = CastarrColors.surface.copy(alpha = 0.75f),
|
||||||
|
contentColor = CastarrColors.fg,
|
||||||
|
focusedContainerColor = CastarrColors.accent,
|
||||||
|
focusedContentColor = CastarrColors.onAccent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val color = LocalContentColor.current
|
||||||
|
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Canvas(Modifier.size(15.dp)) {
|
||||||
|
when (icon) {
|
||||||
|
PillIcon.PLAY -> drawPath(
|
||||||
|
Path().apply {
|
||||||
|
moveTo(0f, 0f)
|
||||||
|
lineTo(size.width, size.height / 2f)
|
||||||
|
lineTo(0f, size.height)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
color,
|
||||||
|
)
|
||||||
|
PillIcon.PAUSE -> {
|
||||||
|
val bar = size.width * 0.3f
|
||||||
|
drawRect(color, size = androidx.compose.ui.geometry.Size(bar, size.height))
|
||||||
|
drawRect(
|
||||||
|
color,
|
||||||
|
topLeft = androidx.compose.ui.geometry.Offset(size.width - bar, 0f),
|
||||||
|
size = androidx.compose.ui.geometry.Size(bar, size.height),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PillIcon.STOP -> drawRect(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PillButton(
|
||||||
|
label: String,
|
||||||
|
focusRequester: FocusRequester? = null,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = CastarrColors.surface.copy(alpha = 0.75f),
|
||||||
|
contentColor = CastarrColors.fg,
|
||||||
|
focusedContainerColor = CastarrColors.accent,
|
||||||
|
focusedContentColor = CastarrColors.onAccent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
style = OverlayText,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
package dev.castarr.tv.ui
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -23,10 +30,18 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.tv.material3.Border
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
@@ -37,9 +52,18 @@ import dev.castarr.tv.pairing.Qr
|
|||||||
import dev.castarr.tv.update.UpdateChecker
|
import dev.castarr.tv.update.UpdateChecker
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** One open picker dialog: which setting, its options, what happens on pick. */
|
||||||
|
private data class Picker(
|
||||||
|
val title: String,
|
||||||
|
val options: List<String>,
|
||||||
|
val selected: Int,
|
||||||
|
val onPick: (Int) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ten-Foot-Regel (#14): one screen, focusable buttons/chips only — no text
|
* Google-TV-style settings: cards with full-width focusable rows; choices
|
||||||
* fields, no scrolling. Everything text-heavy lives in the Erweitert screen.
|
* open a picker dialog (Konto / Wiedergabe / App / Handy-Fernbedienung).
|
||||||
|
* Ten-Foot-Regel (#14): no text fields, no scrolling.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(state: AppState) {
|
fun SettingsScreen(state: AppState) {
|
||||||
@@ -47,134 +71,402 @@ fun SettingsScreen(state: AppState) {
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val profiles by state.dispatcharr.profiles.collectAsState()
|
val profiles by state.dispatcharr.profiles.collectAsState()
|
||||||
var updateStatus by remember { mutableStateOf("") }
|
var updateStatus by remember { mutableStateOf("") }
|
||||||
|
var picker by remember { mutableStateOf<Picker?>(null) }
|
||||||
|
// Bumped on reset so the QR code and the four-digit code redraw.
|
||||||
|
var pairingEpoch by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 40.dp, vertical = 20.dp)
|
.padding(horizontal = 40.dp, vertical = 12.dp)
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1.2f)) {
|
Column(Modifier.weight(1.25f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
SectionTitle("Konto")
|
SettingsCard("Konto") {
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
if (state.auth.isLoggedIn) {
|
if (state.auth.isLoggedIn) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(34.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(CastarrColors.accentDim),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Angemeldet als ${state.auth.username}",
|
state.auth.username.take(1).uppercase().ifEmpty { "?" },
|
||||||
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp,
|
color = CastarrColors.accent,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(14.dp))
|
|
||||||
if (state.source.m3uUrl.isNotEmpty()) {
|
|
||||||
Row {
|
|
||||||
Chip("Dispatcharr", state.sourceMode == AppState.SourceMode.DISPATCHARR) {
|
|
||||||
state.setMode(AppState.SourceMode.DISPATCHARR)
|
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(14.dp))
|
||||||
Chip("Eigene M3U", state.sourceMode == AppState.SourceMode.GENERIC) {
|
Column {
|
||||||
state.setMode(AppState.SourceMode.GENERIC)
|
Text(
|
||||||
|
state.auth.username,
|
||||||
|
color = CastarrColors.fg, fontFamily = AppFont, fontSize = 15.sp,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Angemeldet über SSO",
|
||||||
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(14.dp))
|
SettingRow("Abmelden", danger = true) { state.startOnboarding() }
|
||||||
}
|
|
||||||
Text("Stream-Qualität", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Row {
|
|
||||||
Chip("Standard", state.outputProfile.isEmpty()) { state.outputProfile = "" }
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Chip("Original", state.outputProfile == "raw") { state.outputProfile = "raw" }
|
|
||||||
profiles.forEach { name ->
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Chip(name, state.outputProfile == name) { state.outputProfile = name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(18.dp))
|
|
||||||
ActionButton("Abmelden", danger = true) { state.startOnboarding() }
|
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Nicht angemeldet.",
|
"Nicht angemeldet.",
|
||||||
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp,
|
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
SettingRow("Jetzt anmelden") { state.startOnboarding() }
|
||||||
ActionButton("Jetzt anmelden") { state.startOnboarding() }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(28.dp))
|
SettingsCard("Wiedergabe") {
|
||||||
SectionTitle("App")
|
if (state.auth.isLoggedIn && state.source.m3uUrl.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(8.dp))
|
val modes = listOf("Dispatcharr", "Eigene M3U")
|
||||||
Text(
|
val modeIndex = if (state.sourceMode == AppState.SourceMode.DISPATCHARR) 0 else 1
|
||||||
"Version ${BuildConfig.VERSION_NAME}" + (state.updateAvailable?.let { " · $it verfügbar" } ?: ""),
|
SettingRow(
|
||||||
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp,
|
"Quelle",
|
||||||
|
subtitle = "Woher die Senderliste kommt",
|
||||||
|
trailing = { ValueWithCaret(modes[modeIndex]) },
|
||||||
|
) {
|
||||||
|
picker = Picker("Quelle", modes, modeIndex) { index ->
|
||||||
|
state.setMode(
|
||||||
|
if (index == 0) AppState.SourceMode.DISPATCHARR
|
||||||
|
else AppState.SourceMode.GENERIC
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
}
|
||||||
Row {
|
}
|
||||||
ActionButton(
|
}
|
||||||
if (state.updateAvailable != null) "Update installieren" else "Nach Update suchen"
|
val qualities = listOf("Standard", "Original") + profiles
|
||||||
|
val qualityIndex = when (state.outputProfile) {
|
||||||
|
"" -> 0
|
||||||
|
"raw" -> 1
|
||||||
|
else -> (profiles.indexOf(state.outputProfile) + 2).coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
SettingRow(
|
||||||
|
"Stream-Qualität",
|
||||||
|
subtitle = "Standard remuxt nur den Ton",
|
||||||
|
trailing = { ValueWithCaret(qualities[qualityIndex]) },
|
||||||
|
) {
|
||||||
|
picker = Picker("Stream-Qualität", qualities, qualityIndex) { index ->
|
||||||
|
state.outputProfile = when (index) {
|
||||||
|
0 -> ""
|
||||||
|
1 -> "raw"
|
||||||
|
else -> profiles.getOrElse(index - 2) { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard("App") {
|
||||||
|
SettingRow(
|
||||||
|
"Version ${BuildConfig.VERSION_NAME}",
|
||||||
|
subtitle = updateStatus.ifEmpty { "Klicken prüft auf Updates" },
|
||||||
|
trailing = state.updateAvailable?.let { version ->
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(999.dp))
|
||||||
|
.background(CastarrColors.accentDim)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"$version verfügbar",
|
||||||
|
color = CastarrColors.accent,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
updateStatus = if (state.updateAvailable != null) {
|
updateStatus = when (val v = UpdateChecker.check(state)) {
|
||||||
UpdateChecker.downloadAndInstall(context, state) ?: "Update wird geöffnet…"
|
|
||||||
} else {
|
|
||||||
when (val v = UpdateChecker.check(state)) {
|
|
||||||
null -> "App ist aktuell"
|
null -> "App ist aktuell"
|
||||||
else -> "$v verfügbar — nochmal drücken zum Installieren"
|
else -> "$v verfügbar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (state.updateAvailable != null) {
|
||||||
|
SettingRow("Update installieren") {
|
||||||
|
scope.launch {
|
||||||
|
updateStatus = "Update wird heruntergeladen…"
|
||||||
|
updateStatus =
|
||||||
|
UpdateChecker.downloadAndInstall(context, state)
|
||||||
|
?: "Installation wird geöffnet…"
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
ActionButton("Erweitert") { state.screen = AppState.Screen.ADVANCED }
|
|
||||||
}
|
}
|
||||||
if (updateStatus.isNotEmpty()) {
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
SettingRow(
|
||||||
Text(updateStatus, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp)
|
"Erweitert",
|
||||||
|
subtitle = "M3U/EPG-Adressen von Hand eintragen",
|
||||||
|
) { state.screen = AppState.Screen.ADVANCED }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.width(40.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(Modifier.weight(0.75f)) {
|
||||||
SectionTitle("Handy-Fernbedienung")
|
SettingsCard("Handy-Fernbedienung") {
|
||||||
Spacer(Modifier.height(10.dp))
|
val address = remember(pairingEpoch) { Pairing.lanAddress() }
|
||||||
val address = remember { Pairing.lanAddress() }
|
if (!state.remoteAvailable) {
|
||||||
if (address != null) {
|
Text(
|
||||||
val qr = remember(address) {
|
"Fernbedienung nicht verfügbar — Port belegt. Neustart des Fernsehers hilft meistens.",
|
||||||
Qr.encode(Pairing.pairingUrl(context, address), 400, android.graphics.Color.parseColor("#101216"))
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
} else if (address != null) {
|
||||||
|
val qr = remember(address, pairingEpoch) {
|
||||||
|
Qr.encode(
|
||||||
|
Pairing.pairingUrl(context, address), 400,
|
||||||
|
android.graphics.Color.parseColor("#101216"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
QrCard(qr)
|
QrCard(qr)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text(
|
Text(
|
||||||
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
|
"Mit der Handy-Kamera scannen",
|
||||||
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp,
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SectionTitle(text: String) {
|
|
||||||
Text(text, color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Chip(label: String, selected: Boolean, onClick: () -> Unit) {
|
|
||||||
Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
|
||||||
containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface,
|
|
||||||
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
|
|
||||||
focusedContainerColor = CastarrColors.accent,
|
|
||||||
focusedContentColor = CastarrColors.onAccent,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
label,
|
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
|
||||||
fontFamily = SpaceGrotesk, fontSize = 12.sp,
|
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
}
|
||||||
|
SettingRow(
|
||||||
|
"Kopplung zurücksetzen",
|
||||||
|
subtitle = "Neuer Code, alle Handys müssen neu scannen",
|
||||||
|
) {
|
||||||
|
Pairing.reset(context)
|
||||||
|
pairingEpoch++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"Keine Netzwerkverbindung",
|
||||||
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
picker?.let { current ->
|
||||||
|
PickerDialog(current) { picker = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TV-friendly dropdown replacement: fullscreen scrim, options centered. */
|
||||||
|
@Composable
|
||||||
|
private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
||||||
|
val selectedFocus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { selectedFocus.requestFocus() }
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onClose,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// Own heavy scrim — the platform default is too light and
|
||||||
|
// lets the white QR card fight the panel.
|
||||||
|
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.width(360.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(CastarrColors.surface)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 14.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
picker.title.uppercase(),
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
|
||||||
|
)
|
||||||
|
picker.options.forEachIndexed { index, option ->
|
||||||
|
val selected = index == picker.selected
|
||||||
|
Surface(
|
||||||
|
onClick = {
|
||||||
|
picker.onPick(index)
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (selected) Modifier.focusRequester(selectedFocus) else Modifier),
|
||||||
|
shape = ClickableSurfaceDefaults.shape(rowShape),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = androidx.compose.ui.graphics.Color.Transparent,
|
||||||
|
contentColor = CastarrColors.fg,
|
||||||
|
focusedContainerColor = CastarrColors.surfaceFocused,
|
||||||
|
focusedContentColor = CastarrColors.fg,
|
||||||
|
),
|
||||||
|
border = ClickableSurfaceDefaults.border(
|
||||||
|
focusedBorder = Border(
|
||||||
|
border = BorderStroke(2.dp, CastarrColors.accent),
|
||||||
|
shape = rowShape,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 11.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
option,
|
||||||
|
color = if (selected) CastarrColors.accent else CastarrColors.fg,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (selected) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(7.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(CastarrColors.accent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current value plus a small drawn caret, shown at a row's trailing edge. */
|
||||||
|
@Composable
|
||||||
|
private fun ValueWithCaret(value: String) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
color = CastarrColors.muted,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
val color = CastarrColors.faint
|
||||||
|
Canvas(Modifier.size(9.dp)) {
|
||||||
|
drawPath(
|
||||||
|
Path().apply {
|
||||||
|
moveTo(0f, size.height * 0.3f)
|
||||||
|
lineTo(size.width, size.height * 0.3f)
|
||||||
|
lineTo(size.width / 2f, size.height * 0.75f)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsCard(title: String, content: @Composable () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(CastarrColors.surface)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 14.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
title.uppercase(),
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
modifier = Modifier.padding(start = 14.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val rowShape = RoundedCornerShape(10.dp)
|
||||||
|
|
||||||
|
/** One full-width focusable settings row: label + subtitle, value right. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingRow(
|
||||||
|
label: String,
|
||||||
|
subtitle: String? = null,
|
||||||
|
danger: Boolean = false,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = ClickableSurfaceDefaults.shape(rowShape),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = androidx.compose.ui.graphics.Color.Transparent,
|
||||||
|
contentColor = CastarrColors.fg,
|
||||||
|
focusedContainerColor = CastarrColors.surfaceFocused,
|
||||||
|
focusedContentColor = CastarrColors.fg,
|
||||||
|
),
|
||||||
|
border = ClickableSurfaceDefaults.border(
|
||||||
|
focusedBorder = Border(
|
||||||
|
border = BorderStroke(2.dp, if (danger) CastarrColors.live else CastarrColors.accent),
|
||||||
|
shape = rowShape,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
color = if (danger) CastarrColors.live else CastarrColors.fg,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trailing != null) {
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
trailing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
|
fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
|
||||||
@@ -190,7 +482,7 @@ fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
fontFamily = SpaceGrotesk, fontSize = 13.sp,
|
fontFamily = AppFont, fontSize = 13.sp,
|
||||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -198,17 +490,27 @@ fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun QrCard(bitmap: Bitmap) {
|
fun QrCard(bitmap: Bitmap) {
|
||||||
|
// Quiet zone stays white for scannability, but the tile sits in a
|
||||||
|
// bordered frame on the card instead of floating as a bare white block.
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.background(androidx.compose.ui.graphics.Color(0xFFFBFCFD))
|
.background(CastarrColors.bg)
|
||||||
.padding(14.dp),
|
.border(1.dp, CastarrColors.line, RoundedCornerShape(18.dp))
|
||||||
|
.padding(8.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(androidx.compose.ui.graphics.Color(0xFFF4F6F7))
|
||||||
|
.padding(10.dp),
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
bitmap = bitmap.asImageBitmap(),
|
bitmap = bitmap.asImageBitmap(),
|
||||||
contentDescription = "QR-Code zum Koppeln",
|
contentDescription = "QR-Code zum Koppeln",
|
||||||
modifier = Modifier.size(170.dp),
|
modifier = Modifier.size(164.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ object CastarrColors {
|
|||||||
val onAccent = Color(0xFF07110F)
|
val onAccent = Color(0xFF07110F)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Display face — only for the CASTARR wordmark. */
|
||||||
val SpaceGrotesk = FontFamily(
|
val SpaceGrotesk = FontFamily(
|
||||||
Font(R.font.space_grotesk, FontWeight.Normal),
|
Font(R.font.space_grotesk, FontWeight.Normal),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** UI face for everything else (Inter). */
|
||||||
|
val AppFont = FontFamily(
|
||||||
|
Font(R.font.inter_regular, FontWeight.Normal),
|
||||||
|
Font(R.font.inter_medium, FontWeight.Medium),
|
||||||
|
Font(R.font.inter_semibold, FontWeight.SemiBold),
|
||||||
|
Font(R.font.inter_semibold, FontWeight.Bold),
|
||||||
|
)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ fun WelcomeScreen(state: AppState) {
|
|||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
"Willkommen bei Castarr",
|
"Willkommen bei Castarr",
|
||||||
color = CastarrColors.fg, fontFamily = SpaceGrotesk,
|
color = CastarrColors.fg, fontFamily = AppFont,
|
||||||
fontSize = 32.sp, fontWeight = FontWeight.Medium,
|
fontSize = 32.sp, fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
@@ -90,11 +90,11 @@ fun WelcomeScreen(state: AppState) {
|
|||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
Pairing.remoteUrl(address),
|
Pairing.remoteUrl(address),
|
||||||
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp,
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 15.sp)
|
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 15.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,19 +116,19 @@ private fun Step(number: Int, text: String, active: Boolean, done: Boolean) {
|
|||||||
Text(
|
Text(
|
||||||
if (done) "✓" else "$number",
|
if (done) "✓" else "$number",
|
||||||
color = if (active || done) CastarrColors.onAccent else CastarrColors.faint,
|
color = if (active || done) CastarrColors.onAccent else CastarrColors.faint,
|
||||||
fontFamily = SpaceGrotesk, fontSize = 14.sp, fontWeight = FontWeight.Medium,
|
fontFamily = AppFont, fontSize = 14.sp, fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(14.dp))
|
Spacer(Modifier.width(14.dp))
|
||||||
Text(
|
Text(
|
||||||
text,
|
text,
|
||||||
color = if (active) CastarrColors.fg else CastarrColors.muted,
|
color = if (active) CastarrColors.fg else CastarrColors.muted,
|
||||||
fontFamily = SpaceGrotesk, fontSize = 17.sp,
|
fontFamily = AppFont, fontSize = 17.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Status(text: String) {
|
private fun Status(text: String) {
|
||||||
Text(text, color = CastarrColors.accent, fontFamily = SpaceGrotesk, fontSize = 14.sp)
|
Text(text, color = CastarrColors.accent, fontFamily = AppFont, fontSize = 14.sp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,17 @@ import java.io.File
|
|||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
|
||||||
/** In-app updater over GitHub releases (#12). Explicit, never automatic. */
|
/** In-app updater over Gitea releases (#12). Explicit, never automatic. */
|
||||||
object UpdateChecker {
|
object UpdateChecker {
|
||||||
|
|
||||||
private const val TAG = "UpdateChecker"
|
private const val TAG = "UpdateChecker"
|
||||||
private const val LATEST = "https://api.github.com/repos/be-nj/castarr/releases/latest"
|
private const val LATEST =
|
||||||
|
"https://git.beckm4nn.net/api/v1/repos/benjamin/castarr/releases/latest"
|
||||||
|
|
||||||
|
// Release assets need an API token to upload, so the APK is served from
|
||||||
|
// an orphan branch instead — anonymously fetchable on a public repo.
|
||||||
|
private const val APK_FALLBACK =
|
||||||
|
"https://git.beckm4nn.net/benjamin/castarr/raw/branch/apk/castarr.apk"
|
||||||
|
|
||||||
private var apkUrl: String = ""
|
private var apkUrl: String = ""
|
||||||
|
|
||||||
@@ -35,6 +41,7 @@ object UpdateChecker {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (url.isEmpty() && tag.isNotEmpty()) url = APK_FALLBACK
|
||||||
if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) {
|
if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) {
|
||||||
apkUrl = url
|
apkUrl = url
|
||||||
val version = "v$tag"
|
val version = "v$tag"
|
||||||
@@ -69,7 +76,9 @@ object UpdateChecker {
|
|||||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
context.startActivity(intent)
|
// Launching from the IO dispatcher stalled the installer on
|
||||||
|
// the first attempt — activities start from the main thread.
|
||||||
|
withContext(Dispatchers.Main) { context.startActivity(intent) }
|
||||||
null
|
null
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
Log.w(TAG, "install failed: ${it.javaClass.simpleName}")
|
Log.w(TAG, "install failed: ${it.javaClass.simpleName}")
|
||||||
|
|||||||
BIN
app/src/main/res/font/inter_medium.ttf
Normal file
BIN
app/src/main/res/font/inter_medium.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/inter_regular.ttf
Normal file
BIN
app/src/main/res/font/inter_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/inter_semibold.ttf
Normal file
BIN
app/src/main/res/font/inter_semibold.ttf
Normal file
Binary file not shown.
7
app/src/main/res/xml/backup_rules.xml
Normal file
7
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Credentials must not travel to Google Drive: the pairing token grants
|
||||||
|
full control of the TV and the OIDC tokens are account credentials. -->
|
||||||
|
<full-backup-content>
|
||||||
|
<exclude domain="sharedpref" path="pairing.xml" />
|
||||||
|
<exclude domain="sharedpref" path="auth.xml" />
|
||||||
|
</full-backup-content>
|
||||||
11
app/src/main/res/xml/data_extraction_rules.xml
Normal file
11
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="sharedpref" path="pairing.xml" />
|
||||||
|
<exclude domain="sharedpref" path="auth.xml" />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="sharedpref" path="pairing.xml" />
|
||||||
|
<exclude domain="sharedpref" path="auth.xml" />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
BIN
docs/screenshots/nummerntasten.png
Normal file
BIN
docs/screenshots/nummerntasten.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
BIN
docs/screenshots/player.png
Normal file
BIN
docs/screenshots/player.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/screenshots/senderliste.png
Normal file
BIN
docs/screenshots/senderliste.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
BIN
docs/screenshots/willkommen.png
Normal file
BIN
docs/screenshots/willkommen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
80
tests/unit/M3uParserTest.kt
Normal file
80
tests/unit/M3uParserTest.kt
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package dev.castarr.tv.playlist
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class M3uParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps commas inside display names`() {
|
||||||
|
val channels = M3uParser.parse(
|
||||||
|
"""
|
||||||
|
#EXTM3U
|
||||||
|
#EXTINF:-1 group-title="Doku",Comedy Central, HD
|
||||||
|
http://example.com/a.ts
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
assertEquals(1, channels.size)
|
||||||
|
assertEquals("Comedy Central, HD", channels[0].name)
|
||||||
|
assertEquals("Doku", channels[0].group)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `commas inside attributes do not split the name`() {
|
||||||
|
val channels = M3uParser.parse(
|
||||||
|
"""
|
||||||
|
#EXTM3U
|
||||||
|
#EXTINF:-1 group-title="News, Sport" tvg-id="x",Das Erste HD
|
||||||
|
http://example.com/b.ts
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
assertEquals("Das Erste HD", channels[0].name)
|
||||||
|
assertEquals("News, Sport", channels[0].group)
|
||||||
|
assertEquals("x", channels[0].tvgId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `strips a UTF-8 BOM instead of inventing a channel`() {
|
||||||
|
val channels = M3uParser.parse(
|
||||||
|
"#EXTM3U\n#EXTINF:-1,Channel A\nhttp://example.com/a.ts\n"
|
||||||
|
)
|
||||||
|
assertEquals(1, channels.size)
|
||||||
|
assertEquals("Channel A", channels[0].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects HTML instead of turning it into channels`() {
|
||||||
|
assertThrows(M3uParser.NotAPlaylistException::class.java) {
|
||||||
|
M3uParser.parse("<!DOCTYPE html>\n<html>\n<body>404</body>\n</html>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects an empty payload`() {
|
||||||
|
assertThrows(M3uParser.NotAPlaylistException::class.java) {
|
||||||
|
M3uParser.parse("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `accepts a playlist whose header is missing but has entries`() {
|
||||||
|
val channels = M3uParser.parse("#EXTINF:-1,Nur ein Sender\nhttp://example.com/c.ts")
|
||||||
|
assertEquals(1, channels.size)
|
||||||
|
assertEquals("Nur ein Sender", channels[0].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to the url when the name is empty`() {
|
||||||
|
val channels = M3uParser.parse("#EXTM3U\n#EXTINF:-1,\nhttp://example.com/d.ts")
|
||||||
|
assertEquals("http://example.com/d.ts", channels[0].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `carries EXTGRP over to a bare url`() {
|
||||||
|
val channels = M3uParser.parse("#EXTM3U\n#EXTGRP:Sport\nhttp://example.com/e.ts")
|
||||||
|
assertTrue(channels.isNotEmpty())
|
||||||
|
assertEquals("Sport", channels[0].group)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user