Compare commits
29 Commits
753ab3a3ab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fc7a3887e | ||
|
|
80b4c0c6cd | ||
|
|
06b31f0b9d | ||
|
|
660837c116 | ||
|
|
d269618ebf | ||
|
|
de6d3a921f | ||
|
|
4cb7a6efdb | ||
|
|
ac90898a46 | ||
|
|
bb6af51788 | ||
|
|
a7d8fcd089 | ||
|
|
e35500fa2f | ||
|
|
b52ef79c01 | ||
|
|
f1a6600e42 | ||
|
|
637679fd90 | ||
|
|
cbdac7ddb2 | ||
|
|
8bf1ed3128 | ||
|
|
3b6a8d7d0a | ||
|
|
e491f8e50f | ||
|
|
56b5d3f0b0 | ||
|
|
b0b8f8801d | ||
|
|
9a0fa37e55 | ||
|
|
73f6dbba75 | ||
|
|
f6f6a2499f | ||
|
|
8aa3a4da02 | ||
|
|
b7ee0523d1 | ||
|
|
a3105925bb | ||
|
|
7f1dff5661 | ||
|
|
869060c56f | ||
|
|
7ad7f5172e |
73
.github/workflows/build.yml
vendored
73
.github/workflows/build.yml
vendored
@@ -4,24 +4,71 @@ on:
|
|||||||
push:
|
push:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# No marketplace actions: this Gitea runner resolves bare action names
|
||||||
|
# against the local instance, and even with full URLs it cannot resolve
|
||||||
|
# annotated tags ("unsupported object type"). Everything below is plain
|
||||||
|
# shell, which also keeps the job readable.
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: docker.gitea.com/runner-images:ubuntu-latest
|
||||||
|
# A directory on the runner host, whitelisted in the runner's
|
||||||
|
# valid_volumes. Everything the job would otherwise download on every
|
||||||
|
# push — JDK, Android SDK, Gradle distribution and dependencies —
|
||||||
|
# lives here and survives the container.
|
||||||
|
options: -v /etc/komodo/stacks/gitea/runner-cache/castarr:/cache
|
||||||
|
env:
|
||||||
|
CACHE: /cache
|
||||||
|
JAVA_HOME: /cache/jdk17
|
||||||
|
ANDROID_SDK_ROOT: /cache/android-sdk
|
||||||
|
GRADLE_USER_HOME: /cache/gradle
|
||||||
|
JDK_URL: https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse
|
||||||
|
CMDLINE_TOOLS: https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch -q --depth 1 origin "${GITHUB_SHA}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
- name: JDK 17
|
||||||
with:
|
run: |
|
||||||
distribution: temurin
|
if [ ! -x "$JAVA_HOME/bin/java" ]; then
|
||||||
java-version: "17"
|
echo "JDK nicht im Cache — einmalig herunterladen"
|
||||||
|
mkdir -p "$JAVA_HOME"
|
||||||
|
curl -sSL "$JDK_URL" | tar -xz -C "$JAVA_HOME" --strip-components=1
|
||||||
|
fi
|
||||||
|
"$JAVA_HOME/bin/java" -version
|
||||||
|
|
||||||
- uses: gradle/actions/setup-gradle@v4
|
- name: Android SDK
|
||||||
|
run: |
|
||||||
|
if [ ! -d "$ANDROID_SDK_ROOT/platforms/android-35" ]; then
|
||||||
|
echo "SDK nicht im Cache — einmalig einrichten"
|
||||||
|
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||||
|
curl -sSL -o /tmp/tools.zip "$CMDLINE_TOOLS"
|
||||||
|
unzip -q -o /tmp/tools.zip -d "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||||
|
rm -rf "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||||
|
mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||||
|
# Accept licences by writing the hashes: piping "yes" into
|
||||||
|
# sdkmanager dies of SIGPIPE (exit 141) under pipefail.
|
||||||
|
mkdir -p "$ANDROID_SDK_ROOT/licenses"
|
||||||
|
echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_ROOT/licenses/android-sdk-license"
|
||||||
|
echo "84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_ROOT/licenses/android-sdk-preview-license"
|
||||||
|
JAVA_HOME="$JAVA_HOME" "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
|
||||||
|
"platforms;android-35" "build-tools;35.0.0" "platform-tools" > /dev/null
|
||||||
|
fi
|
||||||
|
echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
|
||||||
|
|
||||||
- name: Build debug APK
|
# One invocation, not two: a second --no-daemon run pays for another JVM
|
||||||
run: ./gradlew assembleDebug --stacktrace
|
# start and another configuration phase to redo work it just did.
|
||||||
|
- name: Tests und Debug-APK
|
||||||
|
run: ./gradlew testDebugUnitTest assembleDebug --no-daemon --stacktrace
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Summary
|
||||||
uses: actions/upload-artifact@v4
|
if: always()
|
||||||
with:
|
run: |
|
||||||
name: castarr-debug-apk
|
echo "APK:"; ls -la app/build/outputs/apk/debug/ 2>/dev/null || echo " (kein Build)"
|
||||||
path: app/build/outputs/apk/debug/app-debug.apk
|
echo "Tests:"; ls tests/runs/junit/ 2>/dev/null || echo " (keine Reports)"
|
||||||
|
echo "Cache:"; du -sh "$CACHE"/* 2>/dev/null || echo " (leer)"
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,3 +5,6 @@ local.properties
|
|||||||
*.iml
|
*.iml
|
||||||
.kotlin/
|
.kotlin/
|
||||||
tests/runs/
|
tests/runs/
|
||||||
|
app/src/main/assets/crests/
|
||||||
|
tests/demo/playlist.m3u
|
||||||
|
tests/demo/epg.xml
|
||||||
|
|||||||
@@ -73,9 +73,10 @@ Channel, no full guide timeline.
|
|||||||
## Example dialogue
|
## Example dialogue
|
||||||
|
|
||||||
> **Dev:** "Does the **Remote** need Backend credentials?"
|
> **Dev:** "Does the **Remote** need Backend credentials?"
|
||||||
> **Domain expert:** "No — the user enters the Xtream credentials of a
|
> **Domain expert:** "No — the Remote is where **Onboarding** happens, not
|
||||||
> **Quelle** once via the Remote, the **TV-App** stores them and is the only
|
> where credentials live: the user types the server URL there and finishes the
|
||||||
> one talking to the **Backend**."
|
> IdP login on the phone. The **TV-App** holds the tokens and is the only one
|
||||||
|
> talking to the **Backend**. A generic M3U **Quelle** needs no login at all."
|
||||||
|
|
||||||
## Flagged ambiguities
|
## Flagged ambiguities
|
||||||
|
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -58,6 +58,13 @@ Fernseher zeigt QR + Code, bestätigt wird am Handy beim Identity Provider),
|
|||||||
API-Zugriff per Bearer-Token, Favoriten und Ausgabeprofile pro Nutzer.
|
API-Zugriff per Bearer-Token, Favoriten und Ausgabeprofile pro Nutzer.
|
||||||
Ohne Dispatcharr funktioniert auch jede M3U-Playlist (+ optionales XMLTV)
|
Ohne Dispatcharr funktioniert auch jede M3U-Playlist (+ optionales XMLTV)
|
||||||
als generische Quelle — ErsatzTV, Threadfin, Tunarr, einfache Playlists.
|
als generische Quelle — ErsatzTV, Threadfin, Tunarr, einfache Playlists.
|
||||||
|
Diese Quelle braucht überhaupt keine Anmeldung.
|
||||||
|
|
||||||
|
An einen bestimmten Identity Provider ist nichts gebunden: der Server nennt
|
||||||
|
der App seinen Issuer und die Client-ID, alles Weitere ist Standard (OIDC
|
||||||
|
Discovery, Device Authorization Grant nach RFC 8628). Authentik ist hier nur
|
||||||
|
das, was im Haushalt läuft — Keycloak, Zitadel oder Authelia tun es genauso,
|
||||||
|
solange sie den Device-Flow können.
|
||||||
|
|
||||||
Die Handy-Fernbedienung serviert der Fernseher selbst (eingebetteter
|
Die Handy-Fernbedienung serviert der Fernseher selbst (eingebetteter
|
||||||
HTTP/WebSocket-Server, Kopplung per QR-Token mit ratenlimitiertem
|
HTTP/WebSocket-Server, Kopplung per QR-Token mit ratenlimitiertem
|
||||||
@@ -79,4 +86,41 @@ sind signiert; über einem Debug-Build muss einmal deinstalliert werden
|
|||||||
(Signaturwechsel). Updates bezieht die App über die Release-API dieses
|
(Signaturwechsel). Updates bezieht die App über die Release-API dieses
|
||||||
Repos, die APK selbst liegt auf dem Branch `apk`.
|
Repos, die APK selbst liegt auf dem Branch `apk`.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew test # Unit-Tests, Reports unter tests/runs/
|
||||||
|
tests/smoke.sh # Emulator: bauen, starten, durchklicken (~40 s)
|
||||||
|
tests/smoke.sh --stop # dasselbe, Emulator danach herunterfahren
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/smoke.sh` baut das Debug-APK, setzt die Demo-Playlist als Quelle,
|
||||||
|
läuft mit dem D-Pad von der Senderliste bis in den Player und schlägt fehl
|
||||||
|
bei Absturz, ANR oder leerer Senderliste. Screenshots und Log landen unter
|
||||||
|
`tests/runs/`. Nicht abgedeckt: echte Wiedergabe, Onboarding am Handy,
|
||||||
|
Kopplung.
|
||||||
|
|
||||||
|
Der Emulator dahinter lässt sich auch einzeln steuern:
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/helpers/emulator.sh start | stop | status
|
||||||
|
tests/helpers/emulator.sh install <apk>
|
||||||
|
tests/helpers/emulator.sh shot <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Er startet aus einem Snapshot und ist in wenigen Sekunden oben; `stop`
|
||||||
|
schreibt den Snapshot vorher neu. Ein Kaltstart entsteht nur, wenn der
|
||||||
|
Snapshot fehlt (AVD `castarr-googletv`, API 34).
|
||||||
|
|
||||||
|
### Release
|
||||||
|
|
||||||
|
```
|
||||||
|
tools/release.sh 0.11.6 --notes notes.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Bumpt die Version, testet, baut signiert, taggt, schiebt die APK auf den
|
||||||
|
Branch `apk` und legt das Gitea-Release an. Ohne `--notes` schreibt es einen
|
||||||
|
Entwurf aus den Commit-Betreffs, den du selbst ausformulierst — Release-Notes
|
||||||
|
erfindet das Skript nicht.
|
||||||
|
|
||||||
Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [docs/adr/](docs/adr/).
|
Architektur-Notizen: [CONTEXT.md](CONTEXT.md) und [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 = 25
|
versionCode = 38
|
||||||
versionName = "0.9.2"
|
versionName = "0.11.5"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||||
@@ -97,6 +97,9 @@ dependencies {
|
|||||||
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")
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
// Android ships a stub org.json for unit tests that throws on every call;
|
||||||
|
// the real one lets the backend parsing be tested without a device.
|
||||||
|
testImplementation("org.json:json:20240303")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -168,7 +168,7 @@
|
|||||||
<!-- Pairing -->
|
<!-- Pairing -->
|
||||||
<section class="view" id="view-pair">
|
<section class="view" id="view-pair">
|
||||||
<h1>Mit dem TV koppeln</h1>
|
<h1>Mit dem TV koppeln</h1>
|
||||||
<p>Gib den 4-stelligen Code ein, der unten auf dem TV-Bildschirm steht.</p>
|
<p>Gib den 4-stelligen Code ein, der auf dem Fernseher unter dem QR-Code steht.</p>
|
||||||
<input id="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="4" placeholder="····">
|
<input id="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="4" placeholder="····">
|
||||||
<button class="btn-accent" id="pair-btn">Verbinden</button>
|
<button class="btn-accent" id="pair-btn">Verbinden</button>
|
||||||
<div id="pair-error"></div>
|
<div id="pair-error"></div>
|
||||||
@@ -267,7 +267,7 @@
|
|||||||
ws: null, connected: false, authorized: false,
|
ws: null, connected: false, authorized: false,
|
||||||
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
|
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
|
||||||
channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false,
|
channels: [], playlistUrl: '', extras: { nowNext: [], favorites: [], favoritesSupported: false }, favOnly: false,
|
||||||
retryDelay: 1000, volumeDragging: false, searchTerm: '',
|
retryDelay: 1000, volumeDragging: false, searchTerm: '', teamKey: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- pairing credentials ---
|
// --- pairing credentials ---
|
||||||
@@ -449,17 +449,35 @@
|
|||||||
}
|
}
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
const favSet = new Set(state.extras.favorites || []);
|
const favSet = new Set(state.extras.favorites || []);
|
||||||
|
// Same identity the TV uses: the backend id where there is one, the
|
||||||
|
// position in the full list where there is not (plain M3U playlists).
|
||||||
|
const favId = (c) => (c.backendId ? c.backendId : -(state.channels.indexOf(c) + 1));
|
||||||
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 teams = state.extras.teams || [];
|
||||||
if (state.extras.favoritesSupported) {
|
const club = teams.find((t) => t.key === state.teamKey);
|
||||||
|
// Club view: the TV's club menu, mirrored onto the phone.
|
||||||
|
const clubHits = club ? new Map(club.entries.map((e) => [e.url, e])) : null;
|
||||||
|
const shown = club
|
||||||
|
? filtered.filter((c) => clubHits.has(c.url))
|
||||||
|
: favOn ? filtered.filter((c) => favSet.has(favId(c))) : filtered;
|
||||||
|
if (state.extras.favoritesSupported || teams.length) {
|
||||||
const bar = document.createElement('div');
|
const bar = document.createElement('div');
|
||||||
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px';
|
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px;overflow-x:auto';
|
||||||
[['Alle', false], ['★ Favoriten', true]].forEach(([label, val]) => {
|
const views = [['Alle', null, false]];
|
||||||
|
if (state.extras.favoritesSupported) views.push(['★ Favoriten', null, true]);
|
||||||
|
teams.forEach((t) => views.push([t.label + ' ' + t.entries.length, t.key, false]));
|
||||||
|
views.forEach(([label, key, fav]) => {
|
||||||
|
const active = key ? state.teamKey === key : (!state.teamKey && state.favOnly === fav);
|
||||||
const chip = document.createElement('button');
|
const chip = document.createElement('button');
|
||||||
chip.textContent = label;
|
chip.textContent = label;
|
||||||
chip.style.cssText = 'padding:7px 14px;border-radius:999px;font-size:12px;background:' +
|
chip.style.cssText = 'flex:none;padding:7px 14px;border-radius:999px;font-size:12px;background:' +
|
||||||
(state.favOnly === val ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
|
(active ? 'rgba(95,212,196,.12);color:#5fd4c4' : '#121418;color:#9aa0a8');
|
||||||
chip.addEventListener('click', () => { state.favOnly = val; renderChannels(); });
|
chip.addEventListener('click', () => {
|
||||||
|
state.teamKey = key;
|
||||||
|
state.favOnly = fav;
|
||||||
|
listSignature = '';
|
||||||
|
renderChannels();
|
||||||
|
});
|
||||||
bar.appendChild(chip);
|
bar.appendChild(chip);
|
||||||
});
|
});
|
||||||
list.appendChild(bar);
|
list.appendChild(bar);
|
||||||
@@ -467,8 +485,9 @@
|
|||||||
if (!shown.length) {
|
if (!shown.length) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'empty';
|
empty.className = 'empty';
|
||||||
empty.textContent = favOn
|
empty.textContent = club
|
||||||
? 'Keine Favoriten — Stern auf einem Sender antippen.'
|
? ('Heute läuft nichts mehr mit ' + club.name + '.')
|
||||||
|
: favOn ? 'Keine Favoriten — Stern auf einem Sender antippen.'
|
||||||
: 'Keine Sender gefunden.';
|
: 'Keine Sender gefunden.';
|
||||||
list.appendChild(empty);
|
list.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
@@ -493,6 +512,18 @@
|
|||||||
const name = document.createElement('span');
|
const name = document.createElement('span');
|
||||||
name.className = 'name'; name.textContent = c.name;
|
name.className = 'name'; name.textContent = c.name;
|
||||||
meta.appendChild(name);
|
meta.appendChild(name);
|
||||||
|
const hit = clubHits ? clubHits.get(c.url) : null;
|
||||||
|
if (hit) {
|
||||||
|
const line = document.createElement('span');
|
||||||
|
line.className = 'grp';
|
||||||
|
const t = new Date(hit.start);
|
||||||
|
const hh = String(t.getHours()).padStart(2, '0') + ':' + String(t.getMinutes()).padStart(2, '0');
|
||||||
|
const running = Date.now() >= hit.start && Date.now() < hit.stop;
|
||||||
|
line.textContent = (running ? 'läuft · ' : 'ab ' + hh + ' · ') + hit.title +
|
||||||
|
(hit.further > 0 ? ' · +' + hit.further + ' weitere' : '');
|
||||||
|
line.style.color = running ? '#5fd4c4' : '#9aa0a8';
|
||||||
|
meta.appendChild(line);
|
||||||
|
} else {
|
||||||
const info = (state.extras.nowNext || [])[idx];
|
const info = (state.extras.nowNext || [])[idx];
|
||||||
if (info && info.now) {
|
if (info && info.now) {
|
||||||
const now = document.createElement('span');
|
const now = document.createElement('span');
|
||||||
@@ -513,15 +544,16 @@
|
|||||||
grp.className = 'grp'; grp.textContent = c.group;
|
grp.className = 'grp'; grp.textContent = c.group;
|
||||||
meta.appendChild(grp);
|
meta.appendChild(grp);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
btn.appendChild(num); btn.appendChild(meta);
|
btn.appendChild(num); btn.appendChild(meta);
|
||||||
if (state.extras.favoritesSupported && c.backendId) {
|
if (state.extras.favoritesSupported) {
|
||||||
const star = document.createElement('span');
|
const star = document.createElement('span');
|
||||||
const isFav = favSet.has(c.backendId);
|
const isFav = favSet.has(favId(c));
|
||||||
star.textContent = isFav ? '★' : '☆';
|
star.textContent = isFav ? '★' : '☆';
|
||||||
star.style.cssText = 'font-size:20px;padding:8px;color:' + (isFav ? '#5fd4c4' : '#6b717a');
|
star.style.cssText = 'font-size:20px;padding:8px;color:' + (isFav ? '#5fd4c4' : '#6b717a');
|
||||||
star.addEventListener('click', (e) => {
|
star.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
send({ type: 'set_favorite', id: c.backendId });
|
send({ type: 'set_favorite', id: favId(c) });
|
||||||
});
|
});
|
||||||
btn.appendChild(star);
|
btn.appendChild(star);
|
||||||
}
|
}
|
||||||
@@ -539,7 +571,8 @@
|
|||||||
let listSignature = '';
|
let listSignature = '';
|
||||||
function renderChannelsIfChanged() {
|
function renderChannelsIfChanged() {
|
||||||
const sig = [
|
const sig = [
|
||||||
state.channels.length, state.searchTerm, state.favOnly,
|
state.channels.length, state.searchTerm, state.favOnly, state.teamKey,
|
||||||
|
JSON.stringify((state.extras.teams || []).map((t) => [t.key, t.entries.length])),
|
||||||
(state.extras.favorites || []).join(','),
|
(state.extras.favorites || []).join(','),
|
||||||
state.status.channel || '',
|
state.status.channel || '',
|
||||||
].join('|');
|
].join('|');
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import android.content.Context
|
|||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import dev.castarr.tv.auth.DeviceAuth
|
import dev.castarr.tv.auth.DeviceAuth
|
||||||
import dev.castarr.tv.data.DispatcharrRepository
|
import dev.castarr.tv.data.DispatcharrRepository
|
||||||
import dev.castarr.tv.data.NowNext
|
import dev.castarr.tv.data.NowNext
|
||||||
|
import dev.castarr.tv.data.isEpgPlaceholder
|
||||||
import dev.castarr.tv.data.SourceRepository
|
import dev.castarr.tv.data.SourceRepository
|
||||||
import dev.castarr.tv.player.PlayerController
|
import dev.castarr.tv.player.PlayerController
|
||||||
import dev.castarr.tv.playlist.Channel
|
import dev.castarr.tv.playlist.Channel
|
||||||
@@ -26,6 +28,7 @@ class AppState(
|
|||||||
val dispatcharr: DispatcharrRepository,
|
val dispatcharr: DispatcharrRepository,
|
||||||
) {
|
) {
|
||||||
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
|
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
|
||||||
|
val crests = dev.castarr.tv.data.Crests(context)
|
||||||
|
|
||||||
var screen by mutableStateOf(Screen.LIVE)
|
var screen by mutableStateOf(Screen.LIVE)
|
||||||
var playerVisible by mutableStateOf(false)
|
var playerVisible by mutableStateOf(false)
|
||||||
@@ -38,19 +41,122 @@ 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)
|
||||||
|
|
||||||
|
/** Club shortcut currently open in the rail, if any. */
|
||||||
|
var activeTeam by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Club menus switched on for this TV. Seeded from the signed-in viewer's
|
||||||
|
* own club; every club can be toggled on in the settings, so a household
|
||||||
|
* can watch for more than one.
|
||||||
|
*/
|
||||||
|
var enabledTeams by mutableStateOf(loadEnabledTeams())
|
||||||
|
private set
|
||||||
|
|
||||||
|
private fun loadEnabledTeams(): Set<String> {
|
||||||
|
val stored = prefs.getStringSet("teams", null)
|
||||||
|
if (stored != null) return stored.toSet()
|
||||||
|
return dev.castarr.tv.data.TeamFilters.defaultKeysFor(auth.username).toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns false when the cap of [TeamFilters.MAX_ACTIVE] is reached. */
|
||||||
|
fun toggleTeam(key: String): Boolean {
|
||||||
|
if (key !in enabledTeams &&
|
||||||
|
enabledTeams.size >= dev.castarr.tv.data.TeamFilters.MAX_ACTIVE
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
enabledTeams =
|
||||||
|
if (key in enabledTeams) enabledTeams - key else enabledTeams + key
|
||||||
|
prefs.edit().putStringSet("teams", enabledTeams).apply()
|
||||||
|
if (activeTeam == key && key !in enabledTeams) activeTeam = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
|
var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
|
||||||
private set
|
private set
|
||||||
private var lastNowTitle: String? = null
|
private var lastNowTitle: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bumped by the remote's channel keys while the list is open: jumps to
|
||||||
|
* the next or previous initial letter. Right is taken by the day plan,
|
||||||
|
* and a text field is out of the question (ten-foot rule).
|
||||||
|
*/
|
||||||
|
var letterJump by mutableIntStateOf(0)
|
||||||
|
private set
|
||||||
|
var letterJumpDirection = 1
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun requestLetterJump(direction: Int) {
|
||||||
|
letterJumpDirection = direction
|
||||||
|
letterJump++
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything this channel still shows today, for the day plan. */
|
||||||
|
fun upcomingToday(channel: Channel): List<dev.castarr.tv.data.Programme> {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val window = dev.castarr.tv.data.TeamFilters.windowEnd(now) - now
|
||||||
|
return dev.castarr.tv.data.XmltvParser.collapseDuplicates(upcoming(channel, window))
|
||||||
|
}
|
||||||
|
|
||||||
/** Digits typed on the remote's number pad (channel switching). */
|
/** Digits typed on the remote's number pad (channel switching). */
|
||||||
var digitBuffer by mutableStateOf("")
|
var digitBuffer by mutableStateOf("")
|
||||||
|
|
||||||
/** False when the control server could not bind its port. */
|
/** False when the control server could not bind its port. */
|
||||||
var remoteAvailable by mutableStateOf(true)
|
var remoteAvailable by mutableStateOf(true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Favourites for a generic M3U source. The Dispatcharr fork keeps them
|
||||||
|
* per user on the backend; a plain playlist has nowhere to put them, and
|
||||||
|
* "no way to mark a channel" is not an acceptable answer for the second
|
||||||
|
* source type the app officially supports.
|
||||||
|
*/
|
||||||
|
private var localFavorites by mutableStateOf(
|
||||||
|
prefs.getStringSet("favorites", emptySet())?.toSet().orEmpty()
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Marked channels of whichever source is active. */
|
||||||
|
fun favoriteCount(): Int = when (sourceMode) {
|
||||||
|
SourceMode.GENERIC -> localFavorites.size
|
||||||
|
SourceMode.DISPATCHARR -> dispatcharr.favorites.value.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isFavorite(channel: Channel): Boolean = when (sourceMode) {
|
||||||
|
SourceMode.GENERIC -> channel.url in localFavorites
|
||||||
|
SourceMode.DISPATCHARR -> channel.backendId in dispatcharr.favorites.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleFavorite(channel: Channel) {
|
||||||
|
when (sourceMode) {
|
||||||
|
SourceMode.GENERIC -> {
|
||||||
|
localFavorites =
|
||||||
|
if (channel.url in localFavorites) localFavorites - channel.url
|
||||||
|
else localFavorites + channel.url
|
||||||
|
prefs.edit().putStringSet("favorites", localFavorites).apply()
|
||||||
|
}
|
||||||
|
SourceMode.DISPATCHARR -> dispatcharr.toggleFavorite(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Channel to restore focus to when the list comes back (#13). */
|
/** Channel to restore focus to when the list comes back (#13). */
|
||||||
var lastWatched by mutableStateOf<Channel?>(null)
|
var lastWatched by mutableStateOf<Channel?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same channel across restarts, by URL. Without it the app opens
|
||||||
|
* with the focus on the settings gear, which is not what anyone reaches
|
||||||
|
* for when they switch the TV on.
|
||||||
|
*/
|
||||||
|
private var lastWatchedUrl: String = prefs.getString("last_watched", "").orEmpty()
|
||||||
|
|
||||||
|
/** URL the channel list should focus on, or empty for "no idea". */
|
||||||
|
fun restoreTargetUrl(): String = lastWatched?.url ?: lastWatchedUrl
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The channel before the current one, for the jump back that every
|
||||||
|
* remote has. Only set once a second channel has been watched.
|
||||||
|
*/
|
||||||
|
var previousChannel by mutableStateOf<Channel?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True while the list is being restored after playback. The group rail
|
* True while the list is being restored after playback. The group rail
|
||||||
* opens whatever gets focused, so without this the focus landing there
|
* opens whatever gets focused, so without this the focus landing there
|
||||||
@@ -76,6 +182,21 @@ class AppState(
|
|||||||
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
|
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
|
||||||
var welcomeUserCode by mutableStateOf("")
|
var welcomeUserCode by mutableStateOf("")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True once the login code has been replaced because the old one ran out.
|
||||||
|
* The identity provider hands out short-lived codes; swapping the digits
|
||||||
|
* on screen without a word leaves whoever is typing them wondering why
|
||||||
|
* the phone says no.
|
||||||
|
*/
|
||||||
|
var welcomeCodeRenewed by mutableStateOf(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity provider's confirmation link, complete with the code.
|
||||||
|
* Shown as a QR while the login is pending: scanning it beats typing
|
||||||
|
* nine digits against a code that lives a minute.
|
||||||
|
*/
|
||||||
|
var welcomeLoginUrl by mutableStateOf("")
|
||||||
|
|
||||||
/** Plain-language fullscreen error state (issue #13). */
|
/** Plain-language fullscreen error state (issue #13). */
|
||||||
var appError by mutableStateOf(AppError.NONE)
|
var appError by mutableStateOf(AppError.NONE)
|
||||||
|
|
||||||
@@ -132,6 +253,115 @@ class AppState(
|
|||||||
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
|
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun upcoming(channel: Channel, windowMs: Long) = when (sourceMode) {
|
||||||
|
SourceMode.GENERIC -> source.upcoming(channel, windowMs)
|
||||||
|
SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One channel's club broadcasts: the next one plus how many follow. */
|
||||||
|
data class TeamHit(
|
||||||
|
val programme: dev.castarr.tv.data.Programme,
|
||||||
|
val further: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** How urgent a club menu is right now — drives the rail highlight. */
|
||||||
|
enum class TeamUrgency { NONE, SOON, LIVE }
|
||||||
|
|
||||||
|
/** The urgent state plus the kick-off it refers to, for the rail label. */
|
||||||
|
data class TeamSignal(val urgency: TeamUrgency, val kickOff: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LIVE while a match is running, SOON within half an hour of kick-off —
|
||||||
|
* the window in which someone actually wants to be nudged.
|
||||||
|
*/
|
||||||
|
fun teamSignal(matches: List<Pair<Channel, TeamHit>>): TeamSignal {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
var soonAt = 0L
|
||||||
|
matches.forEach { (_, hit) ->
|
||||||
|
val p = hit.programme
|
||||||
|
if (now in p.start until p.stop) return TeamSignal(TeamUrgency.LIVE, p.start)
|
||||||
|
if (p.start in now..(now + SOON_WINDOW_MS) && (soonAt == 0L || p.start < soonAt)) {
|
||||||
|
soonAt = p.start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (soonAt > 0) TeamSignal(TeamUrgency.SOON, soonAt)
|
||||||
|
else TeamSignal(TeamUrgency.NONE, 0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channels showing the viewer's club within the scanned window, paired
|
||||||
|
* with the programme that matched — earliest kick-off first, so whatever
|
||||||
|
* is running right now sits on top.
|
||||||
|
*/
|
||||||
|
fun teamMatches(key: String): List<Pair<Channel, TeamHit>> {
|
||||||
|
val filter = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return emptyList()
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val window = dev.castarr.tv.data.TeamFilters.windowEnd(now) - now
|
||||||
|
return activeChannels()
|
||||||
|
.filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
|
||||||
|
.mapNotNull { channel ->
|
||||||
|
// A channel can carry several of the club's broadcasts in one
|
||||||
|
// evening; the row names the next and counts the rest.
|
||||||
|
val hits = upcoming(channel, window)
|
||||||
|
.filter { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
|
||||||
|
hits.firstOrNull()?.let { channel to TeamHit(it, hits.size - 1) }
|
||||||
|
}
|
||||||
|
.sortedBy { it.second.programme.start }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A club match that has just started somewhere else while the viewer is
|
||||||
|
* watching something. The app knew the kick-off all along and used to
|
||||||
|
* keep it to itself.
|
||||||
|
*/
|
||||||
|
data class ClubNudge(
|
||||||
|
val clubName: String,
|
||||||
|
val channel: Channel,
|
||||||
|
val programmeTitle: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
var clubNudge by mutableStateOf<ClubNudge?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Programme already offered, so a dismissed nudge stays dismissed. */
|
||||||
|
private var nudgedProgramme: String = ""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offers the first club match that started within the last few minutes
|
||||||
|
* on a channel other than the current one. Called on a timer while
|
||||||
|
* something is playing.
|
||||||
|
*/
|
||||||
|
fun checkClubNudge() {
|
||||||
|
if (!playerVisible) { clubNudge = null; return }
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
enabledTeams.forEach { key ->
|
||||||
|
val club = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return@forEach
|
||||||
|
teamMatches(key).forEach { (channel, hit) ->
|
||||||
|
val p = hit.programme
|
||||||
|
val justStarted = now - p.start in 0 until NUDGE_GRACE_MS
|
||||||
|
val elsewhere = channel.url != currentChannel?.url
|
||||||
|
val stamp = "${p.start}:${channel.url}"
|
||||||
|
if (justStarted && elsewhere && stamp != nudgedProgramme) {
|
||||||
|
nudgedProgramme = stamp
|
||||||
|
clubNudge = ClubNudge(club.shortName, channel, p.title)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissClubNudge() {
|
||||||
|
clubNudge = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switches to the offered match. */
|
||||||
|
fun acceptClubNudge(): Boolean {
|
||||||
|
val nudge = clubNudge ?: return false
|
||||||
|
clubNudge = null
|
||||||
|
play(nudge.channel)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
fun refreshActive() {
|
fun refreshActive() {
|
||||||
when (sourceMode) {
|
when (sourceMode) {
|
||||||
SourceMode.GENERIC -> source.refresh()
|
SourceMode.GENERIC -> source.refresh()
|
||||||
@@ -144,8 +374,14 @@ class AppState(
|
|||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { appError = AppError.NONE },
|
onSuccess = { appError = AppError.NONE },
|
||||||
onFailure = { throwable ->
|
onFailure = { throwable ->
|
||||||
// Cached channels keep the app usable; only surface a
|
// An expired login must always surface: cached channels
|
||||||
// fullscreen state when there is nothing to show.
|
// would otherwise sit there without EPG or favourites and
|
||||||
|
// nothing would say why.
|
||||||
|
if (throwable.message == "not logged in") {
|
||||||
|
appError = AppError.RELOGIN
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Otherwise cached channels keep the app usable.
|
||||||
if (activeChannels().isNotEmpty()) return
|
if (activeChannels().isNotEmpty()) return
|
||||||
appError = when {
|
appError = when {
|
||||||
throwable.message == "not logged in" -> AppError.RELOGIN
|
throwable.message == "not logged in" -> AppError.RELOGIN
|
||||||
@@ -159,6 +395,12 @@ class AppState(
|
|||||||
private companion object {
|
private companion object {
|
||||||
/** Grace period before re-opening the channel just closed. */
|
/** Grace period before re-opening the channel just closed. */
|
||||||
const val REENTRY_GRACE_MS = 2_500L
|
const val REENTRY_GRACE_MS = 2_500L
|
||||||
|
|
||||||
|
/** How far ahead a kick-off counts as "about to start". */
|
||||||
|
const val SOON_WINDOW_MS = 30 * 60 * 1000L
|
||||||
|
|
||||||
|
/** How long after kick-off the nudge is still worth showing. */
|
||||||
|
const val NUDGE_GRACE_MS = 5 * 60 * 1000L
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isOnline(): Boolean {
|
fun isOnline(): Boolean {
|
||||||
@@ -168,15 +410,22 @@ class AppState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun startOnboarding() {
|
fun startOnboarding() {
|
||||||
|
prefs.edit().remove("teams").apply()
|
||||||
|
activeTeam = null
|
||||||
auth.logout()
|
auth.logout()
|
||||||
appError = AppError.NONE
|
appError = AppError.NONE
|
||||||
welcomePhase = WelcomePhase.WAIT_PHONE
|
welcomePhase = WelcomePhase.WAIT_PHONE
|
||||||
welcomeUserCode = ""
|
welcomeUserCode = ""
|
||||||
|
welcomeCodeRenewed = false
|
||||||
|
welcomeLoginUrl = ""
|
||||||
screen = Screen.WELCOME
|
screen = Screen.WELCOME
|
||||||
}
|
}
|
||||||
|
|
||||||
fun play(channel: Channel) {
|
fun play(channel: Channel) {
|
||||||
|
currentChannel?.takeIf { it.url != channel.url }?.let { previousChannel = it }
|
||||||
lastWatched = channel
|
lastWatched = channel
|
||||||
|
lastWatchedUrl = channel.url
|
||||||
|
prefs.edit().putString("last_watched", channel.url).apply()
|
||||||
currentChannel = channel
|
currentChannel = channel
|
||||||
playerVisible = true
|
playerVisible = true
|
||||||
// Re-opening the very channel that was just closed can hit the
|
// Re-opening the very channel that was just closed can hit the
|
||||||
@@ -197,6 +446,7 @@ class AppState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stopPlayback() {
|
fun stopPlayback() {
|
||||||
|
currentChannel?.let { lastWatched = it }
|
||||||
restorePending = lastWatched != null
|
restorePending = lastWatched != null
|
||||||
reentryHandler.removeCallbacksAndMessages(null)
|
reentryHandler.removeCallbacksAndMessages(null)
|
||||||
lastStoppedUrl = currentChannel?.url.orEmpty()
|
lastStoppedUrl = currentChannel?.url.orEmpty()
|
||||||
@@ -211,6 +461,19 @@ class AppState(
|
|||||||
player.retryNow()
|
player.retryNow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back to the channel watched before this one — the button every remote
|
||||||
|
* has and this app did not. Falls back to the last channel of a finished
|
||||||
|
* session, so it also works right after the app starts.
|
||||||
|
*/
|
||||||
|
fun zapBack() {
|
||||||
|
val target = previousChannel
|
||||||
|
?: lastWatched?.takeIf { it.url != currentChannel?.url }
|
||||||
|
?: return
|
||||||
|
val known = activeChannels().firstOrNull { it.url == target.url } ?: target
|
||||||
|
play(known)
|
||||||
|
}
|
||||||
|
|
||||||
fun zap(direction: Int) {
|
fun zap(direction: Int) {
|
||||||
val list = activeChannels()
|
val list = activeChannels()
|
||||||
if (list.isEmpty()) return
|
if (list.isEmpty()) return
|
||||||
|
|||||||
70
app/src/main/java/dev/castarr/tv/CrashLog.kt
Normal file
70
app/src/main/java/dev/castarr/tv/CrashLog.kt
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package dev.castarr.tv
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a short record of crashes on the device.
|
||||||
|
*
|
||||||
|
* "Two weeks in the living room without a crash" is only a criterion if a
|
||||||
|
* crash leaves a trace. Nobody reads logcat on a TV, and by the time someone
|
||||||
|
* mentions "it was gone yesterday" the log has long rotated.
|
||||||
|
*
|
||||||
|
* Deliberately local: no reporting service, nothing leaves the device.
|
||||||
|
*/
|
||||||
|
object CrashLog {
|
||||||
|
|
||||||
|
private const val FILE = "crashes.log"
|
||||||
|
private const val SEPARATOR = "\n---\n"
|
||||||
|
|
||||||
|
/** How many crashes are kept; older ones fall off the front. */
|
||||||
|
const val KEEP = 10
|
||||||
|
|
||||||
|
/** Lines of stack trace per entry — enough to place it, not a dump. */
|
||||||
|
const val TRACE_LINES = 12
|
||||||
|
|
||||||
|
fun install(context: Context, version: String) {
|
||||||
|
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
|
||||||
|
runCatching { append(context, render(System.currentTimeMillis(), version, error)) }
|
||||||
|
previous?.uncaughtException(thread, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry: when, which version, and the top of the stack. */
|
||||||
|
fun render(at: Long, version: String, error: Throwable): String {
|
||||||
|
val stamp = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMANY).format(Date(at))
|
||||||
|
val trace = error.stackTraceToString()
|
||||||
|
.lineSequence()
|
||||||
|
.take(TRACE_LINES)
|
||||||
|
.joinToString("\n")
|
||||||
|
return "$stamp · $version\n$trace"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest last, at most [KEEP] entries. */
|
||||||
|
fun trim(entries: List<String>, keep: Int = KEEP): List<String> =
|
||||||
|
entries.filter { it.isNotBlank() }.takeLast(keep)
|
||||||
|
|
||||||
|
fun entries(context: Context): List<String> = runCatching {
|
||||||
|
val file = File(context.filesDir, FILE)
|
||||||
|
if (!file.exists()) return emptyList()
|
||||||
|
file.readText().split(SEPARATOR).filter { it.isNotBlank() }
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
/** First line of the newest entry, or null when nothing ever crashed. */
|
||||||
|
fun lastSummary(context: Context): String? =
|
||||||
|
entries(context).lastOrNull()?.lineSequence()?.firstOrNull()
|
||||||
|
|
||||||
|
fun clear(context: Context) {
|
||||||
|
runCatching { File(context.filesDir, FILE).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun append(context: Context, entry: String) {
|
||||||
|
val file = File(context.filesDir, FILE)
|
||||||
|
val kept = trim(entries(context) + entry)
|
||||||
|
file.writeText(kept.joinToString(SEPARATOR))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
// Before anything else, so a crash during start-up is recorded too.
|
||||||
|
CrashLog.install(applicationContext, BuildConfig.VERSION_NAME)
|
||||||
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
||||||
val auth = DeviceAuth(this)
|
val auth = DeviceAuth(this)
|
||||||
state = AppState(
|
state = AppState(
|
||||||
@@ -50,7 +52,14 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
server.startServer()
|
server.startServer()
|
||||||
state.remoteAvailable = server.running
|
state.remoteAvailable = server.running
|
||||||
setContent { CastarrApp(state) }
|
setContent { CastarrApp(state) }
|
||||||
lifecycleScope.launch { UpdateChecker.check(state) }
|
lifecycleScope.launch {
|
||||||
|
// At start-up and then occasionally: a TV often keeps the same
|
||||||
|
// app process alive for days.
|
||||||
|
while (true) {
|
||||||
|
UpdateChecker.check(state)
|
||||||
|
delay(UPDATE_CHECK_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onPlaybackChanged() {
|
private fun onPlaybackChanged() {
|
||||||
@@ -138,12 +147,52 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
.put("next", info.next?.title ?: "")
|
.put("next", info.next?.title ?: "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// The phone marks a channel by backend id where there is one, and by
|
||||||
|
// its position in the list where there is not (plain M3U).
|
||||||
val favorites = org.json.JSONArray()
|
val favorites = org.json.JSONArray()
|
||||||
state.dispatcharr.favorites.value.forEach { favorites.put(it) }
|
channels.forEachIndexed { index, channel ->
|
||||||
|
if (state.isFavorite(channel)) {
|
||||||
|
favorites.put(if (channel.backendId != 0) channel.backendId else -(index + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The club menus exist on the TV; the phone should offer them too.
|
||||||
|
val teams = org.json.JSONArray()
|
||||||
|
dev.castarr.tv.data.TeamFilters.all
|
||||||
|
.filter { it.key in state.enabledTeams }
|
||||||
|
.forEach { club ->
|
||||||
|
val entries = org.json.JSONArray()
|
||||||
|
state.teamMatches(club.key).forEach { (channel, hit) ->
|
||||||
|
entries.put(
|
||||||
|
JSONObject()
|
||||||
|
.put("channel", channel.name)
|
||||||
|
.put("url", channel.url)
|
||||||
|
.put("group", channel.group)
|
||||||
|
// Same shortening as on the TV: the phone showed
|
||||||
|
// "BL: Bayer Leverkusen - VfB Stuttgart, tipico
|
||||||
|
// Topspiel der Woche, 16. Spieltag" in full.
|
||||||
|
.put(
|
||||||
|
"title",
|
||||||
|
dev.castarr.tv.data.TeamFilters.fixture(hit.programme.title)
|
||||||
|
?: hit.programme.title,
|
||||||
|
)
|
||||||
|
.put("start", hit.programme.start)
|
||||||
|
.put("stop", hit.programme.stop)
|
||||||
|
.put("further", hit.further)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
teams.put(
|
||||||
|
JSONObject()
|
||||||
|
.put("key", club.key)
|
||||||
|
.put("label", club.shortName)
|
||||||
|
.put("name", club.fullName)
|
||||||
|
.put("entries", entries)
|
||||||
|
)
|
||||||
|
}
|
||||||
return JSONObject()
|
return JSONObject()
|
||||||
.put("nowNext", nowNext)
|
.put("nowNext", nowNext)
|
||||||
.put("favorites", favorites)
|
.put("favorites", favorites)
|
||||||
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
|
.put("teams", teams)
|
||||||
|
.put("favoritesSupported", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setupNeeded(): Boolean = state.screen == AppState.Screen.WELCOME
|
override fun setupNeeded(): Boolean = state.screen == AppState.Screen.WELCOME
|
||||||
@@ -154,33 +203,48 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
state.auth.fetchServerConfig(url.trim())
|
state.auth.fetchServerConfig(url.trim())
|
||||||
val session = state.auth.startDeviceFlow()
|
// Identity providers hand out short-lived codes — Authentik
|
||||||
state.welcomeUserCode = session.userCode
|
// defaults to a minute, which is not enough time to walk to
|
||||||
state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN
|
// the phone. A fresh code is fetched automatically instead of
|
||||||
server.broadcastLoginLink(session.verificationUriComplete, session.userCode)
|
// dropping the viewer back to step one without a word.
|
||||||
val deadline = System.currentTimeMillis() + session.expiresInSeconds * 1000L
|
repeat(LOGIN_CODE_ROUNDS) { round ->
|
||||||
while (System.currentTimeMillis() < deadline) {
|
val session = state.auth.startDeviceFlow()
|
||||||
delay(session.intervalSeconds * 1000L)
|
state.welcomeUserCode = session.userCode
|
||||||
when (state.auth.poll(session)) {
|
state.welcomeLoginUrl = session.verificationUriComplete
|
||||||
is dev.castarr.tv.auth.DeviceAuth.PollResult.Success -> {
|
state.welcomeCodeRenewed = round > 0
|
||||||
state.welcomePhase = AppState.WelcomePhase.DONE
|
state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN
|
||||||
server.broadcastSetupDone()
|
server.broadcastLoginLink(session.verificationUriComplete, session.userCode)
|
||||||
state.setMode(AppState.SourceMode.DISPATCHARR)
|
if (round > 0) {
|
||||||
delay(1500)
|
server.broadcastToast("Neuer Anmeldecode: ${session.userCode}")
|
||||||
state.screen = AppState.Screen.LIVE
|
}
|
||||||
return@launch
|
val deadline =
|
||||||
|
System.currentTimeMillis() + session.expiresInSeconds * 1000L
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
delay(session.intervalSeconds * 1000L)
|
||||||
|
when (state.auth.poll(session)) {
|
||||||
|
is dev.castarr.tv.auth.DeviceAuth.PollResult.Success -> {
|
||||||
|
state.welcomePhase = AppState.WelcomePhase.DONE
|
||||||
|
server.broadcastSetupDone()
|
||||||
|
state.setMode(AppState.SourceMode.DISPATCHARR)
|
||||||
|
delay(1500)
|
||||||
|
state.screen = AppState.Screen.LIVE
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
dev.castarr.tv.auth.DeviceAuth.PollResult.Denied -> {
|
||||||
|
server.broadcastToast("Anmeldung abgelehnt — bitte erneut versuchen")
|
||||||
|
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
|
||||||
|
state.welcomeUserCode = ""
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
dev.castarr.tv.auth.DeviceAuth.PollResult.Pending -> Unit
|
||||||
}
|
}
|
||||||
dev.castarr.tv.auth.DeviceAuth.PollResult.Denied -> {
|
|
||||||
server.broadcastToast("Anmeldung abgelehnt — bitte erneut versuchen")
|
|
||||||
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
|
|
||||||
state.welcomeUserCode = ""
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
dev.castarr.tv.auth.DeviceAuth.PollResult.Pending -> Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
server.broadcastToast("Anmeldecode abgelaufen — bitte erneut versuchen")
|
server.broadcastToast("Anmeldung abgebrochen — bitte erneut versuchen")
|
||||||
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
|
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
|
||||||
|
state.welcomeUserCode = ""
|
||||||
|
state.welcomeLoginUrl = ""
|
||||||
|
state.welcomeCodeRenewed = false
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.w("Onboarding", "configure failed: ${e.javaClass.simpleName}")
|
android.util.Log.w("Onboarding", "configure failed: ${e.javaClass.simpleName}")
|
||||||
server.broadcastToast("Server nicht erreichbar oder ohne Anmeldung")
|
server.broadcastToast("Server nicht erreichbar oder ohne Anmeldung")
|
||||||
@@ -190,8 +254,15 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onToggleFavorite(channelId: Int) {
|
override fun onToggleFavorite(channelId: Int) {
|
||||||
val channel = state.activeChannels().firstOrNull { it.backendId == channelId } ?: return
|
val channels = state.activeChannels()
|
||||||
state.dispatcharr.toggleFavorite(channel)
|
val channel = if (channelId < 0) {
|
||||||
|
channels.getOrNull(-channelId - 1)
|
||||||
|
} else {
|
||||||
|
channels.firstOrNull { it.backendId == channelId }
|
||||||
|
} ?: return
|
||||||
|
state.toggleFavorite(channel)
|
||||||
|
// The backend round-trip needs a moment; a local favourite does not,
|
||||||
|
// but one delayed broadcast covers both.
|
||||||
mainHandler.postDelayed({ server.broadcastChannels() }, 800)
|
mainHandler.postDelayed({ server.broadcastChannels() }, 800)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +280,20 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
onDigit(keyCode - KeyEvent.KEYCODE_0)
|
onDigit(keyCode - KeyEvent.KEYCODE_0)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// Channel keys jump the open list by initial letter.
|
||||||
|
if (!state.playerVisible && state.screen == AppState.Screen.LIVE) {
|
||||||
|
when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_CHANNEL_UP -> {
|
||||||
|
state.requestLetterJump(-1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_CHANNEL_DOWN -> {
|
||||||
|
state.requestLetterJump(1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
if (state.playerVisible) {
|
if (state.playerVisible) {
|
||||||
when (keyCode) {
|
when (keyCode) {
|
||||||
// OK opens the control overlay (buttons take over from
|
// OK opens the control overlay (buttons take over from
|
||||||
@@ -217,6 +302,9 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
// reaches the activity.
|
// reaches the activity.
|
||||||
KeyEvent.KEYCODE_DPAD_CENTER,
|
KeyEvent.KEYCODE_DPAD_CENTER,
|
||||||
KeyEvent.KEYCODE_ENTER -> {
|
KeyEvent.KEYCODE_ENTER -> {
|
||||||
|
// An offered club match takes the key before playback
|
||||||
|
// controls do — that is what the hint on it promises.
|
||||||
|
if (state.acceptClubNudge()) return true
|
||||||
state.pingOverlay()
|
state.pingOverlay()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -236,6 +324,13 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
state.stopPlayback()
|
state.stopPlayback()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// The jump every remote has: back to what was on before.
|
||||||
|
KeyEvent.KEYCODE_LAST_CHANNEL,
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||||
|
state.pingOverlay()
|
||||||
|
state.zapBack()
|
||||||
|
return true
|
||||||
|
}
|
||||||
KeyEvent.KEYCODE_DPAD_UP,
|
KeyEvent.KEYCODE_DPAD_UP,
|
||||||
KeyEvent.KEYCODE_CHANNEL_UP -> {
|
KeyEvent.KEYCODE_CHANNEL_UP -> {
|
||||||
state.zap(-1)
|
state.zap(-1)
|
||||||
@@ -262,6 +357,10 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_BACK -> {
|
KeyEvent.KEYCODE_BACK -> {
|
||||||
|
if (state.clubNudge != null) {
|
||||||
|
state.dismissClubNudge()
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (state.overlayVisible) state.overlayVisible = false
|
if (state.overlayVisible) state.overlayVisible = false
|
||||||
else state.stopPlayback()
|
else state.stopPlayback()
|
||||||
return true
|
return true
|
||||||
@@ -325,5 +424,9 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
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
|
const val DIGIT_COMMIT_MS = 1_800L
|
||||||
|
const val UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000L
|
||||||
|
|
||||||
|
/** How often a fresh login code is fetched before giving up. */
|
||||||
|
const val LOGIN_CODE_ROUNDS = 8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
app/src/main/java/dev/castarr/tv/data/Crests.kt
Normal file
63
app/src/main/java/dev/castarr/tv/data/Crests.kt
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves club crests at runtime from Wikipedia's page summary. The badges
|
||||||
|
* are trademarks, so they are fetched and cached on the device rather than
|
||||||
|
* shipped with the app — nothing copyrighted lives in the repository.
|
||||||
|
*/
|
||||||
|
class Crests(context: Context) {
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences("crests", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/** Cached crest URL for a club, fetched once and then reused. */
|
||||||
|
suspend fun urlFor(article: String): String? = withContext(Dispatchers.IO) {
|
||||||
|
prefs.getString(article, null)?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
return@withContext it
|
||||||
|
}
|
||||||
|
val resolved = runCatching { fetch(article) }
|
||||||
|
.onFailure { Log.w(TAG, "crest lookup failed for $article: $it") }
|
||||||
|
.getOrNull()
|
||||||
|
if (resolved != null) {
|
||||||
|
Log.i(TAG, "crest for $article: $resolved")
|
||||||
|
prefs.edit().putString(article, resolved).apply()
|
||||||
|
} else {
|
||||||
|
// Deliberately not cached: a single failed lookup (no network
|
||||||
|
// yet at start-up) must not hide the crest forever.
|
||||||
|
Log.w(TAG, "no crest for $article")
|
||||||
|
}
|
||||||
|
resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetch(article: String): String? {
|
||||||
|
val encoded = URLEncoder.encode(article, "UTF-8").replace("+", "%20")
|
||||||
|
val connection = URL("$SUMMARY$encoded").openConnection() as HttpURLConnection
|
||||||
|
return try {
|
||||||
|
connection.connectTimeout = 10_000
|
||||||
|
connection.readTimeout = 10_000
|
||||||
|
connection.setRequestProperty("User-Agent", USER_AGENT)
|
||||||
|
connection.setRequestProperty("Accept", "application/json")
|
||||||
|
val body = connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
JSONObject(body).optJSONObject("thumbnail")?.optString("source")
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
// Drop the analytics query the API appends.
|
||||||
|
?.substringBefore("?")
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "Crests"
|
||||||
|
const val SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/"
|
||||||
|
const val USER_AGENT = "Castarr TV (private use)"
|
||||||
|
}
|
||||||
|
}
|
||||||
119
app/src/main/java/dev/castarr/tv/data/DispatcharrJson.kt
Normal file
119
app/src/main/java/dev/castarr/tv/data/DispatcharrJson.kt
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import dev.castarr.tv.playlist.Channel
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the Dispatcharr backend sends, turned into the app's own types.
|
||||||
|
*
|
||||||
|
* Split out of [DispatcharrRepository] so the parsing can be tested without a
|
||||||
|
* server: the whole backend path had no coverage, and the first run against a
|
||||||
|
* real instance turned up three bugs in an afternoon.
|
||||||
|
*/
|
||||||
|
object DispatcharrJson {
|
||||||
|
|
||||||
|
/** `{"results": [...]}` from the paginated API, or a bare array. */
|
||||||
|
fun paginatedResults(body: String): JSONArray =
|
||||||
|
runCatching { JSONObject(body).optJSONArray("results") }.getOrNull() ?: JSONArray(body)
|
||||||
|
|
||||||
|
/** Next page URL of a paginated response, or null on the last one. */
|
||||||
|
fun nextPage(body: String): String? =
|
||||||
|
runCatching { JSONObject(body).optString("next") }.getOrNull()
|
||||||
|
?.takeIf { it.isNotEmpty() && it != "null" }
|
||||||
|
|
||||||
|
fun parseGroups(body: String): Map<Int, String> {
|
||||||
|
val results = paginatedResults(body)
|
||||||
|
return (0 until results.length()).associate {
|
||||||
|
val obj = results.getJSONObject(it)
|
||||||
|
obj.getInt("id") to obj.optString("name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `{"channels": [1, 2]}` — the ids the signed-in user starred. */
|
||||||
|
fun parseFavorites(body: String): Set<Int> {
|
||||||
|
val ids = runCatching { JSONObject(body).optJSONArray("channels") }.getOrNull() ?: JSONArray()
|
||||||
|
return (0 until ids.length()).map { ids.getInt(it) }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output profile names; inactive ones are not offered. */
|
||||||
|
fun parseProfiles(body: String): List<String> {
|
||||||
|
val results = paginatedResults(body)
|
||||||
|
return (0 until results.length()).mapNotNull {
|
||||||
|
val obj = results.getJSONObject(it)
|
||||||
|
if (obj.optBoolean("is_active", true)) obj.optString("name") else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of channels.
|
||||||
|
*
|
||||||
|
* A channel without a uuid cannot be played (the proxy resolves by uuid,
|
||||||
|
* integer ids give a 404), and one hidden from output is not meant to
|
||||||
|
* show up at all — both are dropped.
|
||||||
|
*/
|
||||||
|
fun parseChannels(
|
||||||
|
body: String,
|
||||||
|
groups: Map<Int, String>,
|
||||||
|
streamUrl: (uuid: String) -> String,
|
||||||
|
logoUrl: (id: Int) -> String,
|
||||||
|
): List<Channel> {
|
||||||
|
val results = paginatedResults(body)
|
||||||
|
val list = mutableListOf<Channel>()
|
||||||
|
for (i in 0 until results.length()) {
|
||||||
|
val obj = results.getJSONObject(i)
|
||||||
|
val uuid = obj.optString("uuid")
|
||||||
|
if (uuid.isEmpty() || obj.optBoolean("hidden_from_output")) continue
|
||||||
|
list.add(
|
||||||
|
Channel(
|
||||||
|
name = obj.optString("effective_name").ifEmpty { obj.optString("name") },
|
||||||
|
url = streamUrl(uuid),
|
||||||
|
group = groups[obj.optInt("channel_group_id")].orEmpty(),
|
||||||
|
logo = (obj.optInt("effective_logo_id").takeIf { it > 0 }
|
||||||
|
?: obj.optInt("logo_id").takeIf { it > 0 })
|
||||||
|
?.let(logoUrl)
|
||||||
|
.orEmpty(),
|
||||||
|
tvgId = obj.optString("effective_tvg_id").ifEmpty { obj.optString("tvg_id") },
|
||||||
|
backendId = obj.getInt("id"),
|
||||||
|
streamKey = uuid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The EPG grid, keyed by tvg-id. The envelope is `{"data": [...]}`, but
|
||||||
|
* `results` and a bare array are tolerated — the fork has shipped all
|
||||||
|
* three at some point.
|
||||||
|
*/
|
||||||
|
fun parseEpgGrid(body: String): Map<String, List<Programme>> {
|
||||||
|
val root = runCatching { JSONObject(body) }.getOrNull()
|
||||||
|
val results = root?.optJSONArray("data")
|
||||||
|
?: root?.optJSONArray("results")
|
||||||
|
?: runCatching { JSONArray(body) }.getOrElse { error("unexpected EPG envelope") }
|
||||||
|
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
val map = HashMap<String, MutableList<Programme>>()
|
||||||
|
for (i in 0 until results.length()) {
|
||||||
|
val obj = results.getJSONObject(i)
|
||||||
|
val tvgId = obj.optString("tvg_id")
|
||||||
|
if (tvgId.isEmpty()) continue
|
||||||
|
val start = parseIso(obj.optString("start_time"), format)
|
||||||
|
val stop = parseIso(obj.optString("end_time"), format)
|
||||||
|
if (start == 0L || stop <= start) continue
|
||||||
|
map.getOrPut(tvgId) { mutableListOf() }
|
||||||
|
.add(Programme(start, stop, obj.optString("title")))
|
||||||
|
}
|
||||||
|
map.values.forEach { it.sortBy(Programme::start) }
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseIso(raw: String, format: SimpleDateFormat): Long = runCatching {
|
||||||
|
format.parse(raw.substringBefore(".").substringBefore("+").removeSuffix("Z"))?.time ?: 0L
|
||||||
|
}.getOrDefault(0L)
|
||||||
|
}
|
||||||
@@ -11,12 +11,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.TimeZone
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Channel source backed by the Dispatcharr fork's Bearer API: channels,
|
* Channel source backed by the Dispatcharr fork's Bearer API: channels,
|
||||||
@@ -34,6 +30,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
|||||||
val epgUpdatedAt = MutableStateFlow(0L)
|
val epgUpdatedAt = MutableStateFlow(0L)
|
||||||
val status = MutableStateFlow("")
|
val status = MutableStateFlow("")
|
||||||
|
|
||||||
|
/** When the channel list last came through, persisted across restarts. */
|
||||||
|
val updatedAt = MutableStateFlow(prefs.getLong("channels_at", 0L))
|
||||||
|
|
||||||
|
/** True while the most recent attempt to reach the backend failed. */
|
||||||
|
val unreachable = MutableStateFlow(false)
|
||||||
|
|
||||||
private var programmesByTvgId: Map<String, List<Programme>> = emptyMap()
|
private var programmesByTvgId: Map<String, List<Programme>> = emptyMap()
|
||||||
var outputProfile: String = ""
|
var outputProfile: String = ""
|
||||||
|
|
||||||
@@ -49,13 +51,20 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
val result = runCatching {
|
val result = runCatching {
|
||||||
status.value = "loading_channels"
|
status.value = "loading_channels"
|
||||||
|
Log.i(TAG, "refresh start (loggedIn=${auth.isLoggedIn})")
|
||||||
val token = auth.accessToken() ?: error("not logged in")
|
val token = auth.accessToken() ?: error("not logged in")
|
||||||
val groups = fetchGroups(token)
|
val groups = fetchGroups(token)
|
||||||
val list = fetchChannels(token, groups)
|
val list = fetchChannels(token, groups)
|
||||||
channels.value = list
|
channels.value = list
|
||||||
prefs.edit().putString("channels_cache", Channel.listToJson(list).toString()).apply()
|
prefs.edit().putString("channels_cache", Channel.listToJson(list).toString()).apply()
|
||||||
launch { runCatching { refreshFavorites(token) } }
|
launch {
|
||||||
launch { runCatching { refreshProfiles(token) } }
|
runCatching { refreshFavorites(token) }
|
||||||
|
.onFailure { Log.w(TAG, "favorites failed: ${it.javaClass.simpleName}: ${it.message?.take(120)}") }
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
runCatching { refreshProfiles(token) }
|
||||||
|
.onFailure { Log.w(TAG, "profiles failed: ${it.javaClass.simpleName}: ${it.message?.take(120)}") }
|
||||||
|
}
|
||||||
launch {
|
launch {
|
||||||
runCatching { refreshEpg(token) }
|
runCatching { refreshEpg(token) }
|
||||||
.onFailure { Log.w(TAG, "epg failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
|
.onFailure { Log.w(TAG, "epg failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
|
||||||
@@ -63,6 +72,11 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
|||||||
list.size
|
list.size
|
||||||
}
|
}
|
||||||
result.onFailure { Log.w(TAG, "refresh failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
|
result.onFailure { Log.w(TAG, "refresh failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
|
||||||
|
result.onSuccess {
|
||||||
|
updatedAt.value = System.currentTimeMillis()
|
||||||
|
prefs.edit().putLong("channels_at", updatedAt.value).apply()
|
||||||
|
}
|
||||||
|
unreachable.value = result.isFailure
|
||||||
status.value = if (result.isSuccess) "" else "channels_error"
|
status.value = if (result.isSuccess) "" else "channels_error"
|
||||||
onDone(result)
|
onDone(result)
|
||||||
}
|
}
|
||||||
@@ -71,6 +85,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
|||||||
fun nowNext(channel: Channel): NowNext =
|
fun nowNext(channel: Channel): NowNext =
|
||||||
XmltvParser.nowNext(programmesByTvgId[channel.tvgId])
|
XmltvParser.nowNext(programmesByTvgId[channel.tvgId])
|
||||||
|
|
||||||
|
/** Programmes of this channel between now and now + [windowMs]. */
|
||||||
|
fun upcoming(channel: Channel, windowMs: Long): List<Programme> {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return XmltvParser.programmesIn(programmesByTvgId[channel.tvgId], now, now + windowMs)
|
||||||
|
}
|
||||||
|
|
||||||
fun toggleFavorite(channel: Channel) {
|
fun toggleFavorite(channel: Channel) {
|
||||||
if (channel.backendId == 0) return
|
if (channel.backendId == 0) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -100,45 +120,23 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
|||||||
channels.value = channels.value.map { it.copy(url = streamUrl(it.streamKey)) }
|
channels.value = channels.value.map { it.copy(url = streamUrl(it.streamKey)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchGroups(token: String): Map<Int, String> {
|
private suspend fun fetchGroups(token: String): Map<Int, String> =
|
||||||
val body = request("${auth.serverUrl}/api/channels/groups/?page_size=1000", "GET", token)
|
DispatcharrJson.parseGroups(
|
||||||
val results = paginatedResults(body)
|
request("${auth.serverUrl}/api/channels/groups/?page_size=1000", "GET", token)
|
||||||
return (0 until results.length()).associate {
|
)
|
||||||
val obj = results.getJSONObject(it)
|
|
||||||
obj.getInt("id") to obj.optString("name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun fetchChannels(token: String, groups: Map<Int, String>): List<Channel> {
|
private suspend fun fetchChannels(token: String, groups: Map<Int, String>): List<Channel> {
|
||||||
val list = mutableListOf<Channel>()
|
val list = mutableListOf<Channel>()
|
||||||
var url: String? = "${auth.serverUrl}/api/channels/channels/?page_size=500"
|
var url: String? = "${auth.serverUrl}/api/channels/channels/?page_size=500"
|
||||||
while (url != null && list.size < 10_000) {
|
while (url != null && list.size < 10_000) {
|
||||||
val body = request(url, "GET", token)
|
val body = request(url, "GET", token)
|
||||||
val json = runCatching { JSONObject(body) }.getOrNull()
|
list += DispatcharrJson.parseChannels(
|
||||||
val results = json?.optJSONArray("results") ?: JSONArray(body)
|
body = body,
|
||||||
for (i in 0 until results.length()) {
|
groups = groups,
|
||||||
val obj = results.getJSONObject(i)
|
streamUrl = ::streamUrl,
|
||||||
val id = obj.getInt("id")
|
logoUrl = { "${auth.serverUrl.trimEnd('/')}/api/channels/logos/$it/cache/" },
|
||||||
val uuid = obj.optString("uuid")
|
)
|
||||||
if (uuid.isEmpty() || obj.optBoolean("hidden_from_output")) continue
|
url = DispatcharrJson.nextPage(body)
|
||||||
list.add(
|
|
||||||
Channel(
|
|
||||||
name = obj.optString("effective_name").ifEmpty { obj.optString("name") },
|
|
||||||
url = streamUrl(uuid),
|
|
||||||
group = groups[obj.optInt("channel_group_id")].orEmpty(),
|
|
||||||
// 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") },
|
|
||||||
backendId = id,
|
|
||||||
streamKey = uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
url = json?.optString("next")?.takeIf { it.isNotEmpty() && it != "null" }
|
|
||||||
}
|
}
|
||||||
list.sortBy { it.name.lowercase() }
|
list.sortBy { it.name.lowercase() }
|
||||||
return list
|
return list
|
||||||
@@ -146,54 +144,22 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
|||||||
|
|
||||||
private suspend fun refreshFavorites(token: String) {
|
private suspend fun refreshFavorites(token: String) {
|
||||||
val body = request("${auth.serverUrl}/api/channels/favorites/", "GET", token)
|
val body = request("${auth.serverUrl}/api/channels/favorites/", "GET", token)
|
||||||
val ids = JSONObject(body).optJSONArray("channels") ?: JSONArray()
|
favorites.value = DispatcharrJson.parseFavorites(body)
|
||||||
favorites.value = (0 until ids.length()).map { ids.getInt(it) }.toSet()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refreshProfiles(token: String) {
|
private suspend fun refreshProfiles(token: String) {
|
||||||
val body = request("${auth.serverUrl}/api/core/outputprofiles/", "GET", token)
|
val body = request("${auth.serverUrl}/api/core/outputprofiles/", "GET", token)
|
||||||
val results = paginatedResults(body)
|
profiles.value = DispatcharrJson.parseProfiles(body)
|
||||||
profiles.value = (0 until results.length()).mapNotNull {
|
|
||||||
val obj = results.getJSONObject(it)
|
|
||||||
if (obj.optBoolean("is_active", true)) obj.optString("name") else null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refreshEpg(token: String) {
|
private suspend fun refreshEpg(token: String) {
|
||||||
status.value = "loading_epg"
|
status.value = "loading_epg"
|
||||||
val body = request("${auth.serverUrl}/api/epg/grid/", "GET", token)
|
val body = request("${auth.serverUrl}/api/epg/grid/", "GET", token)
|
||||||
// Envelope is {"data": [...]}; tolerate bare arrays and "results" too.
|
programmesByTvgId = DispatcharrJson.parseEpgGrid(body)
|
||||||
val root = runCatching { JSONObject(body) }.getOrNull()
|
|
||||||
val results = root?.optJSONArray("data")
|
|
||||||
?: root?.optJSONArray("results")
|
|
||||||
?: runCatching { JSONArray(body) }.getOrElse { error("unexpected EPG envelope") }
|
|
||||||
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply {
|
|
||||||
timeZone = TimeZone.getTimeZone("UTC")
|
|
||||||
}
|
|
||||||
val map = HashMap<String, MutableList<Programme>>()
|
|
||||||
for (i in 0 until results.length()) {
|
|
||||||
val obj = results.getJSONObject(i)
|
|
||||||
val tvgId = obj.optString("tvg_id")
|
|
||||||
if (tvgId.isEmpty()) continue
|
|
||||||
val start = parseIso(obj.optString("start_time"), format)
|
|
||||||
val stop = parseIso(obj.optString("end_time"), format)
|
|
||||||
if (start == 0L || stop <= start) continue
|
|
||||||
map.getOrPut(tvgId) { mutableListOf() }
|
|
||||||
.add(Programme(start, stop, obj.optString("title")))
|
|
||||||
}
|
|
||||||
map.values.forEach { it.sortBy(Programme::start) }
|
|
||||||
programmesByTvgId = map
|
|
||||||
epgUpdatedAt.value = System.currentTimeMillis()
|
epgUpdatedAt.value = System.currentTimeMillis()
|
||||||
status.value = ""
|
status.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseIso(raw: String, format: SimpleDateFormat): Long = runCatching {
|
|
||||||
format.parse(raw.substringBefore(".").substringBefore("+").removeSuffix("Z"))?.time ?: 0L
|
|
||||||
}.getOrDefault(0L)
|
|
||||||
|
|
||||||
private fun paginatedResults(body: String): JSONArray =
|
|
||||||
runCatching { JSONObject(body).optJSONArray("results") }.getOrNull() ?: JSONArray(body)
|
|
||||||
|
|
||||||
private suspend fun request(url: String, method: String, token: String): String =
|
private suspend fun request(url: String, method: String, token: String): String =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val connection = URL(url).openConnection() as HttpURLConnection
|
val connection = URL(url).openConnection() as HttpURLConnection
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ class SourceRepository(context: Context) {
|
|||||||
val epgUpdatedAt = MutableStateFlow(0L)
|
val epgUpdatedAt = MutableStateFlow(0L)
|
||||||
val status = MutableStateFlow("")
|
val status = MutableStateFlow("")
|
||||||
|
|
||||||
|
/** When the channel list last came through, persisted across restarts. */
|
||||||
|
val updatedAt = MutableStateFlow(prefs.getLong("channels_at", 0L))
|
||||||
|
|
||||||
|
/** True while the most recent attempt to reach the source failed. */
|
||||||
|
val unreachable = MutableStateFlow(false)
|
||||||
|
|
||||||
private var programmes: Map<String, List<Programme>> = emptyMap()
|
private var programmes: Map<String, List<Programme>> = emptyMap()
|
||||||
private var nameToEpgId: Map<String, String> = emptyMap()
|
private var nameToEpgId: Map<String, String> = emptyMap()
|
||||||
|
|
||||||
@@ -62,6 +68,11 @@ class SourceRepository(context: Context) {
|
|||||||
parsed.size
|
parsed.size
|
||||||
}
|
}
|
||||||
result.onFailure { Log.w(TAG, "channel refresh failed", it) }
|
result.onFailure { Log.w(TAG, "channel refresh failed", it) }
|
||||||
|
result.onSuccess {
|
||||||
|
updatedAt.value = System.currentTimeMillis()
|
||||||
|
prefs.edit().putLong("channels_at", updatedAt.value).apply()
|
||||||
|
}
|
||||||
|
unreachable.value = result.isFailure
|
||||||
status.value = if (result.isSuccess) "" else "channels_error"
|
status.value = if (result.isSuccess) "" else "channels_error"
|
||||||
onDone(result)
|
onDone(result)
|
||||||
if (result.isSuccess && epg.isNotEmpty()) refreshEpg(epg)
|
if (result.isSuccess && epg.isNotEmpty()) refreshEpg(epg)
|
||||||
@@ -82,13 +93,20 @@ class SourceRepository(context: Context) {
|
|||||||
status.value = ""
|
status.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Now/Next for an M3U channel: match tvg-id first, then name. */
|
/** Programmes of this channel between now and now + [windowMs]. */
|
||||||
fun nowNext(channel: Channel): NowNext {
|
fun upcoming(channel: Channel, windowMs: Long): List<Programme> {
|
||||||
val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] }
|
val now = System.currentTimeMillis()
|
||||||
val byName = direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] }
|
return XmltvParser.programmesIn(programmesFor(channel), now, now + windowMs)
|
||||||
return XmltvParser.nowNext(byName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun programmesFor(channel: Channel): List<Programme>? {
|
||||||
|
val direct = channel.tvgId.takeIf { it.isNotEmpty() }?.let { programmes[it] }
|
||||||
|
return direct ?: nameToEpgId[channel.name.lowercase()]?.let { programmes[it] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Now/Next for an M3U channel: match tvg-id first, then name. */
|
||||||
|
fun nowNext(channel: Channel): NowNext = XmltvParser.nowNext(programmesFor(channel))
|
||||||
|
|
||||||
private fun loadCachedChannels() {
|
private fun loadCachedChannels() {
|
||||||
runCatching {
|
runCatching {
|
||||||
val cached = prefs.getString("channels_cache", null) ?: return
|
val cached = prefs.getString("channels_cache", null) ?: return
|
||||||
|
|||||||
222
app/src/main/java/dev/castarr/tv/data/TeamFilters.kt
Normal file
222
app/src/main/java/dev/castarr/tv/data/TeamFilters.kt
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-club shortcut to "where does my club play tonight": a rail entry that
|
||||||
|
* lists every channel whose EPG mentions the club within the next few hours.
|
||||||
|
* Crests are fetched from Wikipedia at runtime and cached on the device;
|
||||||
|
* the club colours are the fallback while that is pending or unavailable.
|
||||||
|
*/
|
||||||
|
data class TeamFilter(
|
||||||
|
val key: String,
|
||||||
|
/** Two-to-four letter code, shown next to the full name in the picker. */
|
||||||
|
val label: String,
|
||||||
|
/**
|
||||||
|
* Name for the club menu in the channel list. The code alone ("FCSP")
|
||||||
|
* is unreadable for anyone who did not set the club up themselves.
|
||||||
|
*/
|
||||||
|
val shortName: String,
|
||||||
|
/** Full club name, shown in the settings and in messages. */
|
||||||
|
val fullName: String,
|
||||||
|
/** Lowercase needles matched against programme titles. */
|
||||||
|
val needles: List<String>,
|
||||||
|
val primary: Color,
|
||||||
|
val secondary: Color,
|
||||||
|
/** German Wikipedia article the crest is resolved from. */
|
||||||
|
val article: String,
|
||||||
|
/** 1, 2 or 3 — groups the picker by division. */
|
||||||
|
val league: Int,
|
||||||
|
) {
|
||||||
|
fun matches(title: String): Boolean {
|
||||||
|
val haystack = title.lowercase()
|
||||||
|
return needles.any { haystack.contains(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object TeamFilters {
|
||||||
|
|
||||||
|
/** Minimum window scanned ahead of now. */
|
||||||
|
const val MIN_WINDOW_MS = 3 * 60 * 60 * 1000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans until midnight, but never less than [MIN_WINDOW_MS] — asking in
|
||||||
|
* the morning should already answer "does my club play tonight", and
|
||||||
|
* asking late at night must not cut the evening short.
|
||||||
|
*/
|
||||||
|
fun windowEnd(now: Long): Long {
|
||||||
|
val midnight = java.util.Calendar.getInstance().apply {
|
||||||
|
timeInMillis = now
|
||||||
|
add(java.util.Calendar.DAY_OF_YEAR, 1)
|
||||||
|
set(java.util.Calendar.HOUR_OF_DAY, 0)
|
||||||
|
set(java.util.Calendar.MINUTE, 0)
|
||||||
|
set(java.util.Calendar.SECOND, 0)
|
||||||
|
set(java.util.Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
return maxOf(midnight, now + MIN_WINDOW_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** At most this many club groups sit in the rail at once. */
|
||||||
|
const val MAX_ACTIVE = 3
|
||||||
|
|
||||||
|
private const val WHITE = 0xFFF2F3F5
|
||||||
|
private const val BLACK = 0xFF15171B
|
||||||
|
|
||||||
|
private fun club(
|
||||||
|
key: String,
|
||||||
|
label: String,
|
||||||
|
shortName: String,
|
||||||
|
fullName: String,
|
||||||
|
needles: List<String>,
|
||||||
|
primary: Long,
|
||||||
|
secondary: Long,
|
||||||
|
article: String = fullName,
|
||||||
|
league: Int = 1,
|
||||||
|
) = TeamFilter(key, label, shortName, fullName, needles, Color(primary), Color(secondary), article, league)
|
||||||
|
|
||||||
|
/** Clubs of the top three German divisions. */
|
||||||
|
val all: List<TeamFilter> = listOf(
|
||||||
|
// --- Bundesliga ---
|
||||||
|
club("bayern", "FCB", "Bayern", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE, league = 1),
|
||||||
|
club("bvb", "BVB", "Dortmund", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK, league = 1),
|
||||||
|
club("leipzig", "RBL", "Leipzig", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE, league = 1),
|
||||||
|
club("leverkusen", "B04", "Leverkusen", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK, league = 1),
|
||||||
|
club("frankfurt", "SGE", "Frankfurt", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F, league = 1),
|
||||||
|
club("stuttgart", "1893", "Stuttgart", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE, league = 1),
|
||||||
|
club("gladbach", "BMG", "Gladbach", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F, league = 1),
|
||||||
|
club("wolfsburg", "WOB", "Wolfsburg", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE, league = 1),
|
||||||
|
club("bremen", "SVW", "Werder", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE, league = 1),
|
||||||
|
club("freiburg", "SCF", "Freiburg", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE, league = 1),
|
||||||
|
club("hoffenheim", "TSG", "Hoffenheim", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE, league = 1),
|
||||||
|
club("mainz", "M05", "Mainz", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE, league = 1),
|
||||||
|
club("augsburg", "FCA", "Augsburg", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F, league = 1),
|
||||||
|
club("union", "FCU", "Union Berlin", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100, league = 1),
|
||||||
|
club("koeln", "KOE", "Köln", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE, league = 1),
|
||||||
|
club("hsv", "HSV", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK, league = 1),
|
||||||
|
club("heidenheim", "HDH", "Heidenheim", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4, league = 1),
|
||||||
|
club("st-pauli", "FCSP", "St. Pauli", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE, league = 1),
|
||||||
|
// --- 2. Bundesliga ---
|
||||||
|
club("schalke", "S04", "Schalke", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
|
||||||
|
club("hertha", "BSC", "Hertha", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE, league = 2),
|
||||||
|
club("duesseldorf", "F95", "Düsseldorf", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE, league = 2),
|
||||||
|
club("nuernberg", "FCN", "Nürnberg", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE, league = 2),
|
||||||
|
club("kaiserslautern", "FCK", "Kaiserslautern", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE, league = 2),
|
||||||
|
club("karlsruhe", "KSC", "Karlsruhe", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE, league = 2),
|
||||||
|
club("hannover", "H96", "Hannover 96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE, league = 2),
|
||||||
|
club("paderborn", "SCP", "Paderborn", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE, league = 2),
|
||||||
|
club("magdeburg", "FCM", "Magdeburg", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE, league = 2),
|
||||||
|
club("elversberg", "SVE", "Elversberg", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F, league = 2),
|
||||||
|
club("darmstadt", "SV98", "Darmstadt", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE, league = 2),
|
||||||
|
club("braunschweig", "BTSV", "Braunschweig", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E, league = 2),
|
||||||
|
club("bochum", "BOC", "Bochum", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE, league = 2),
|
||||||
|
club("muenster", "SCPM", "Münster", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE, league = 2),
|
||||||
|
club("fuerth", "SGF", "Fürth", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE, league = 2),
|
||||||
|
club("holstein", "KSV", "Kiel", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F, league = 2),
|
||||||
|
club("dresden", "SGD", "Dresden", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK, league = 2),
|
||||||
|
club("bielefeld", "DSC", "Bielefeld", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE, league = 2),
|
||||||
|
// --- 3. Liga ---
|
||||||
|
club("hansa", "FCH", "Rostock", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE, league = 3),
|
||||||
|
club("saarbruecken", "FCS", "Saarbrücken", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK, league = 3),
|
||||||
|
club("aue", "FCE", "Aue", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE, league = 3),
|
||||||
|
club("cottbus", "FCEC", "Cottbus", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE, league = 3),
|
||||||
|
club("essen", "RWE", "Essen", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE, league = 3),
|
||||||
|
club("duisburg", "MSV", "Duisburg", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE, league = 3),
|
||||||
|
club("mannheim", "SVWM", "Mannheim", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE, league = 3),
|
||||||
|
club("wehen", "SVWW", "Wehen Wiesbaden", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK, league = 3),
|
||||||
|
club("ulm", "SSV", "Ulm", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball", league = 3),
|
||||||
|
club("regensburg", "SSVJ", "Regensburg", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE, league = 3),
|
||||||
|
club("verl", "SCV", "Verl", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE, league = 3),
|
||||||
|
club("viktoria", "VKÖ", "Viktoria Köln", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE, league = 3),
|
||||||
|
club("havelse", "TSVH", "Havelse", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE, league = 3),
|
||||||
|
club("schweinfurt", "FC05", "Schweinfurt", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE, league = 3),
|
||||||
|
club("osnabrueck", "VfLO", "Osnabrück", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE, league = 3),
|
||||||
|
club("aachen", "ALE", "Aachen", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK, league = 3),
|
||||||
|
club("ingolstadt", "FCI", "Ingolstadt", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK, league = 3),
|
||||||
|
club("wuppertal", "WSV", "Wuppertal", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2, league = 3),
|
||||||
|
club("stuttgart-ii", "VfB2", "Stuttgart II", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK, league = 3),
|
||||||
|
club("hoffenheim-ii", "TSG2", "Hoffenheim II", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim", league = 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Clubs switched on for a viewer before they touch the settings. */
|
||||||
|
private val defaultForUser = mapOf(
|
||||||
|
"benjamin" to listOf("hansa"),
|
||||||
|
"tobiasb" to listOf("stuttgart"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only sport and free-to-air groups are scanned. Searching all 500
|
||||||
|
* channels would mostly turn up shopping and radio, and the club is
|
||||||
|
* never on those anyway.
|
||||||
|
*/
|
||||||
|
private val groupIncludes = listOf(
|
||||||
|
"sport", "dazn", "sky", "magenta", "prime", "free tv", "hd+",
|
||||||
|
"dyn", "del", "bundesliga", "fussball", "fußball", "at / ch", "at/ch",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Explicitly out of scope even though they match an include. */
|
||||||
|
private val groupExcludes = listOf("nfl")
|
||||||
|
|
||||||
|
fun scansGroup(group: String): Boolean {
|
||||||
|
val g = group.lowercase()
|
||||||
|
if (groupExcludes.any { g.contains(it) }) return false
|
||||||
|
return groupIncludes.any { g.contains(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fixture inside an EPG title: "Pokal: Nordstadt - FC St. Pauli,
|
||||||
|
* 1. Runde" becomes "Nordstadt - FC St. Pauli".
|
||||||
|
*
|
||||||
|
* In the club menu the row exists *because* of that pairing, and the
|
||||||
|
* club is usually the second half — exactly the half a one-line title
|
||||||
|
* cuts off. Returns null when there is no pairing to find, and the
|
||||||
|
* caller keeps the original title.
|
||||||
|
*/
|
||||||
|
fun fixture(title: String): String? {
|
||||||
|
// Drop a leading competition ("Pokal:", "Bundesliga:").
|
||||||
|
val body = title.substringAfter(":", title).trim()
|
||||||
|
val separator = SEPARATORS.firstOrNull { body.contains(it) } ?: return null
|
||||||
|
// Trailing detail after the pairing ("…, 1. Runde", "… | live").
|
||||||
|
val pairing = body.substringBefore(",").substringBefore(" | ").trim()
|
||||||
|
if (!pairing.contains(separator)) return null
|
||||||
|
val (rawHome, rawAway) = pairing.split(separator, limit = 2)
|
||||||
|
val home = rawHome.trim()
|
||||||
|
// A second dash usually starts a detail, not a third team:
|
||||||
|
// "… - VfB Stuttgart - 1. Halbzeit".
|
||||||
|
val away = rawAway.substringBefore(" - ").trim()
|
||||||
|
if (home.length < 3 || away.length < 3) return null
|
||||||
|
// A season, a matchday or a half is not an opponent — real EPG data
|
||||||
|
// is full of "VfB Stuttgart - Saison 25-26".
|
||||||
|
if (isNotATeam(home) || isNotATeam(away)) return null
|
||||||
|
return "$home - $away"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isNotATeam(side: String): Boolean {
|
||||||
|
val s = side.lowercase()
|
||||||
|
if (NON_TEAM_WORDS.any { s.contains(it) }) return true
|
||||||
|
return YEAR_LIKE.containsMatchIn(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dash variants German EPG data uses between the two teams. */
|
||||||
|
private val SEPARATORS = listOf(" - ", " – ", " — ", " vs. ", " vs ", " gegen ")
|
||||||
|
|
||||||
|
private val NON_TEAM_WORDS = listOf(
|
||||||
|
"saison", "halbzeit", "spieltag", "runde", "highlights", "höhepunkte",
|
||||||
|
"konferenz", "vereinsprofil", "rückblick", "zusammenfassung", "magazin",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** "25-26", "2025/26", "1899" — never an opponent on its own. */
|
||||||
|
private val YEAR_LIKE = Regex("""\b\d{2}\s*[-/]\s*\d{2}\b|\b(19|20)\d{2}\b""")
|
||||||
|
|
||||||
|
fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key }
|
||||||
|
|
||||||
|
/** Clubs grouped by division, in the order the picker shows them. */
|
||||||
|
fun byLeague(clubs: List<TeamFilter> = all): List<Pair<String, List<TeamFilter>>> =
|
||||||
|
listOf(
|
||||||
|
"1. Bundesliga" to clubs.filter { it.league == 1 },
|
||||||
|
"2. Bundesliga" to clubs.filter { it.league == 2 },
|
||||||
|
"3. Liga" to clubs.filter { it.league == 3 },
|
||||||
|
).filter { it.second.isNotEmpty() }
|
||||||
|
|
||||||
|
fun defaultKeysFor(username: String): List<String> =
|
||||||
|
defaultForUser[username.trim().lowercase()].orEmpty()
|
||||||
|
}
|
||||||
@@ -105,6 +105,39 @@ object XmltvParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapses the same programme listed several times.
|
||||||
|
*
|
||||||
|
* Merged provider EPGs carry a title more than once with starts a few
|
||||||
|
* minutes apart — the day plan showed "The Big Bang Theory" three times
|
||||||
|
* in half an hour and marked two entries as running at once. The first
|
||||||
|
* entry wins and swallows the repeats that start inside its slot.
|
||||||
|
*/
|
||||||
|
fun collapseDuplicates(
|
||||||
|
programmes: List<Programme>,
|
||||||
|
toleranceMs: Long = DUPLICATE_TOLERANCE_MS,
|
||||||
|
): List<Programme> {
|
||||||
|
val kept = mutableListOf<Programme>()
|
||||||
|
programmes.sortedBy { it.start }.forEach { candidate ->
|
||||||
|
val previous = kept.lastOrNull { it.title.equals(candidate.title, ignoreCase = true) }
|
||||||
|
val repeats = previous != null &&
|
||||||
|
(candidate.start - previous.start <= toleranceMs || candidate.start < previous.stop)
|
||||||
|
if (!repeats) kept.add(candidate)
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two starts this close with the same title are the same broadcast. */
|
||||||
|
const val DUPLICATE_TOLERANCE_MS = 30 * 60 * 1000L
|
||||||
|
|
||||||
|
/** Programmes overlapping [from]..[to], in broadcast order. */
|
||||||
|
fun programmesIn(
|
||||||
|
programmes: List<Programme>?,
|
||||||
|
from: Long,
|
||||||
|
to: Long,
|
||||||
|
): List<Programme> =
|
||||||
|
programmes.orEmpty().filter { it.stop > from && it.start < to }
|
||||||
|
|
||||||
fun nowNext(programmes: List<Programme>?, at: Long = System.currentTimeMillis()): NowNext {
|
fun nowNext(programmes: List<Programme>?, at: Long = System.currentTimeMillis()): NowNext {
|
||||||
if (programmes.isNullOrEmpty()) return NowNext(null, null)
|
if (programmes.isNullOrEmpty()) return NowNext(null, null)
|
||||||
val index = programmes.indexOfFirst { at < it.stop }
|
val index = programmes.indexOfFirst { at < it.stop }
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ object Pairing {
|
|||||||
prefs(context).edit().clear().apply()
|
prefs(context).edit().clear().apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many phones would have to scan again after a reset — the number
|
||||||
|
* the confirmation asks about, so nobody wipes a working pairing blind.
|
||||||
|
*/
|
||||||
|
fun pairedCount(context: Context): Int = sessionTokens(context).size
|
||||||
|
|
||||||
private fun sessionTokens(context: Context): List<String> =
|
private fun sessionTokens(context: Context): List<String> =
|
||||||
prefs(context).getStringSet(KEY_SESSIONS, emptySet())?.toList().orEmpty()
|
prefs(context).getStringSet(KEY_SESSIONS, emptySet())?.toList().orEmpty()
|
||||||
|
|
||||||
|
|||||||
78
app/src/main/java/dev/castarr/tv/server/AttemptBudget.kt
Normal file
78
app/src/main/java/dev/castarr/tv/server/AttemptBudget.kt
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package dev.castarr.tv.server
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-address budget for failed authentication attempts.
|
||||||
|
*
|
||||||
|
* Kept free of Android and of the clock so the rule itself can be tested:
|
||||||
|
* this is where the phone remote was thrown out in 0.11.0, because every
|
||||||
|
* connection cost budget instead of only the failed ones. A remote
|
||||||
|
* reconnects on every network hiccup, and five reconnects a minute are
|
||||||
|
* normal traffic, not an attack.
|
||||||
|
*/
|
||||||
|
class AttemptBudget(
|
||||||
|
private val windowMs: Long = WINDOW_MS,
|
||||||
|
private val maxFailures: Int = MAX_FAILURES,
|
||||||
|
private val maxTrackedAddresses: Int = MAX_TRACKED_ADDRESSES,
|
||||||
|
private val now: () -> Long = System::currentTimeMillis,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val failures = HashMap<String, ArrayDeque<Long>>()
|
||||||
|
|
||||||
|
/** True while this address may still try. Never consumes budget. */
|
||||||
|
@Synchronized
|
||||||
|
fun allows(address: String): Boolean {
|
||||||
|
val queue = failures[address] ?: return true
|
||||||
|
prune(queue, now())
|
||||||
|
if (queue.isEmpty()) failures.remove(address)
|
||||||
|
return queue.size < maxFailures
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only a *failed* authentication costs budget. */
|
||||||
|
@Synchronized
|
||||||
|
fun recordFailure(address: String) {
|
||||||
|
val timestamp = now()
|
||||||
|
val queue = failures.getOrPut(address) { ArrayDeque() }
|
||||||
|
queue.addLast(timestamp)
|
||||||
|
if (failures.size > maxTrackedAddresses) evict(timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A successful authentication wipes the address clean. */
|
||||||
|
@Synchronized
|
||||||
|
fun clear(address: String) {
|
||||||
|
failures.remove(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun trackedAddresses(): Int = failures.size
|
||||||
|
|
||||||
|
private fun prune(queue: ArrayDeque<Long>, timestamp: Long) {
|
||||||
|
while (queue.isNotEmpty() && timestamp - queue.first() > windowMs) {
|
||||||
|
queue.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expired entries first, and only then the oldest addresses. Dropping
|
||||||
|
* just the empty queues left the map growing without bound as long as
|
||||||
|
* every tracked address still held one fresh failure.
|
||||||
|
*/
|
||||||
|
private fun evict(timestamp: Long) {
|
||||||
|
val iterator = failures.entries.iterator()
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
val entry = iterator.next()
|
||||||
|
prune(entry.value, timestamp)
|
||||||
|
if (entry.value.isEmpty()) iterator.remove()
|
||||||
|
}
|
||||||
|
if (failures.size <= maxTrackedAddresses) return
|
||||||
|
failures.entries
|
||||||
|
.sortedBy { it.value.firstOrNull() ?: 0L }
|
||||||
|
.take(failures.size - maxTrackedAddresses)
|
||||||
|
.forEach { failures.remove(it.key) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val WINDOW_MS = 60_000L
|
||||||
|
const val MAX_FAILURES = 5
|
||||||
|
const val MAX_TRACKED_ADDRESSES = 64
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,30 +59,19 @@ class ControlServer(
|
|||||||
var running = false
|
var running = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
// Failed authentication attempts per remote address. Counting every
|
// Failed authentication attempts per remote address. The rule itself
|
||||||
// failure (not just the ones carrying a code) keeps a hostile client
|
// lives in AttemptBudget, where it is unit-tested.
|
||||||
// from spending someone else's budget.
|
private val attempts = AttemptBudget()
|
||||||
private val attempts = HashMap<String, ArrayDeque<Long>>()
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun attemptAllowed(address: String): Boolean {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val queue = attempts.getOrPut(address) { ArrayDeque() }
|
|
||||||
while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) {
|
|
||||||
queue.removeFirst()
|
|
||||||
}
|
|
||||||
if (attempts.size > MAX_TRACKED_ADDRESSES) {
|
|
||||||
attempts.entries.removeAll { it.value.isEmpty() }
|
|
||||||
}
|
|
||||||
if (queue.size >= ATTEMPT_MAX) return false
|
|
||||||
queue.addLast(now)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startServer() {
|
fun startServer() {
|
||||||
// A busy port must not take the whole app down — the remote is
|
// A busy port must not take the whole app down — the remote is
|
||||||
// optional, everything else keeps working.
|
// optional, everything else keeps working.
|
||||||
running = runCatching { start(NanoHTTPD.SOCKET_READ_TIMEOUT, true) }
|
// NanoHTTPD's 5 s default also applies to the long-lived WebSocket:
|
||||||
|
// with pings only every 8 s the socket timed out mid-session and the
|
||||||
|
// remote was thrown out after a few seconds. The timeout still has to
|
||||||
|
// exist (idle connections must not pin threads), it just has to be
|
||||||
|
// comfortably longer than the ping interval.
|
||||||
|
running = runCatching { start(SOCKET_TIMEOUT_MS, true) }
|
||||||
.onFailure { Log.w(TAG, "control server unavailable: ${it.javaClass.simpleName}") }
|
.onFailure { Log.w(TAG, "control server unavailable: ${it.javaClass.simpleName}") }
|
||||||
.isSuccess
|
.isSuccess
|
||||||
if (!running) return
|
if (!running) return
|
||||||
@@ -292,9 +281,7 @@ class ControlServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleHello(msg: JSONObject) {
|
private fun handleHello(msg: JSONObject) {
|
||||||
// Every failed attempt counts against this address, whether it
|
if (!attempts.allows(remoteAddress)) {
|
||||||
// carried a token or a code.
|
|
||||||
if (!attemptAllowed(remoteAddress)) {
|
|
||||||
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
|
||||||
@@ -302,10 +289,12 @@ class ControlServer(
|
|||||||
val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
|
val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
|
||||||
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
|
val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
|
||||||
if (!tokenOk && !codeOk) {
|
if (!tokenOk && !codeOk) {
|
||||||
|
attempts.recordFailure(remoteAddress)
|
||||||
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) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
attempts.clear(remoteAddress)
|
||||||
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
|
// A code-authenticated client gets its own revocable token, never
|
||||||
@@ -339,9 +328,7 @@ 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 ATTEMPT_WINDOW_MS = 60_000L
|
const val SOCKET_TIMEOUT_MS = 40_000
|
||||||
const val ATTEMPT_MAX = 5
|
|
||||||
const val MAX_TRACKED_ADDRESSES = 64
|
|
||||||
const val MAX_CLIENTS = 8
|
const val MAX_CLIENTS = 8
|
||||||
const val HANDSHAKE_TIMEOUT_MS = 10_000L
|
const val HANDSHAKE_TIMEOUT_MS = 10_000L
|
||||||
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
|
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
|
||||||
|
|||||||
@@ -54,19 +54,22 @@ fun AdvancedScreen(state: AppState) {
|
|||||||
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(18.dp))
|
Spacer(Modifier.height(18.dp))
|
||||||
|
// Labels sit above the field, not in a notch in its outline: the
|
||||||
|
// notch never cut out cleanly here and the border ran straight
|
||||||
|
// through the text.
|
||||||
|
FieldLabel("M3U-URL")
|
||||||
TvTextField(
|
TvTextField(
|
||||||
value = m3u,
|
value = m3u,
|
||||||
onValueChange = { m3u = it },
|
onValueChange = { m3u = it },
|
||||||
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),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(14.dp))
|
||||||
|
FieldLabel("XMLTV-EPG-URL (optional)")
|
||||||
TvTextField(
|
TvTextField(
|
||||||
value = epg,
|
value = epg,
|
||||||
onValueChange = { epg = it },
|
onValueChange = { epg = it },
|
||||||
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),
|
||||||
@@ -96,3 +99,15 @@ fun AdvancedScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FieldLabel(text: String) {
|
||||||
|
Text(
|
||||||
|
text.uppercase(),
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
letterSpacing = 1.5.sp,
|
||||||
|
modifier = Modifier.padding(bottom = 6.dp, start = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
package dev.castarr.tv.ui
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
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.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.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.layout.wrapContentSize
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
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.getValue
|
||||||
|
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.focusRequester
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
@@ -24,11 +35,10 @@ 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
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.tv.material3.Border
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.tv.material3.Surface
|
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.LocalContentColor
|
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 kotlin.math.PI
|
import kotlin.math.PI
|
||||||
@@ -69,6 +79,7 @@ fun CastarrApp(state: AppState) {
|
|||||||
if (state.digitBuffer.isNotEmpty()) {
|
if (state.digitBuffer.isNotEmpty()) {
|
||||||
DigitBadge(state)
|
DigitBadge(state)
|
||||||
}
|
}
|
||||||
|
ClubNudge(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +129,8 @@ private fun TopBar(state: AppState) {
|
|||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
letterSpacing = 4.sp,
|
letterSpacing = 4.sp,
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.width(24.dp))
|
||||||
|
StaleNotice(state)
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
state.connectedRemote?.let { name ->
|
state.connectedRemote?.let { name ->
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -132,6 +145,10 @@ private fun TopBar(state: AppState) {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.width(18.dp))
|
Spacer(Modifier.width(18.dp))
|
||||||
}
|
}
|
||||||
|
state.updateAvailable?.let { version ->
|
||||||
|
UpdateChip(version) { state.screen = AppState.Screen.SETTINGS }
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
}
|
||||||
GearButton {
|
GearButton {
|
||||||
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
|
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
|
||||||
else AppState.Screen.LIVE
|
else AppState.Screen.LIVE
|
||||||
@@ -139,6 +156,33 @@ private fun TopBar(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown in the top bar as soon as the start-up check finds a newer
|
||||||
|
* release — otherwise an update would only be noticed by someone who
|
||||||
|
* happens to open the settings.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun UpdateChip(version: String, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = CastarrColors.accentDim,
|
||||||
|
contentColor = CastarrColors.accent,
|
||||||
|
focusedContainerColor = CastarrColors.accent,
|
||||||
|
focusedContentColor = CastarrColors.onAccent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Update $version",
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Round settings button with a drawn gear (glyphs render as emoji). */
|
/** Round settings button with a drawn gear (glyphs render as emoji). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun GearButton(onClick: () -> Unit) {
|
private fun GearButton(onClick: () -> Unit) {
|
||||||
@@ -184,3 +228,120 @@ private fun GearButton(onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Says so when the source could not be reached. Without it a cached channel
|
||||||
|
* list looks exactly like a normal evening with an empty EPG — the club
|
||||||
|
* menus even count down to zero.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun StaleNotice(state: AppState) {
|
||||||
|
val unreachable by when (state.sourceMode) {
|
||||||
|
AppState.SourceMode.GENERIC -> state.source.unreachable
|
||||||
|
AppState.SourceMode.DISPATCHARR -> state.dispatcharr.unreachable
|
||||||
|
}.collectAsState()
|
||||||
|
val updatedAt by when (state.sourceMode) {
|
||||||
|
AppState.SourceMode.GENERIC -> state.source.updatedAt
|
||||||
|
AppState.SourceMode.DISPATCHARR -> state.dispatcharr.updatedAt
|
||||||
|
}.collectAsState()
|
||||||
|
if (!unreachable) return
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(7.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(CastarrColors.live)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
if (updatedAt > 0) {
|
||||||
|
"Server nicht erreichbar · Stand ${formatClock(updatedAt)}"
|
||||||
|
} else {
|
||||||
|
"Server nicht erreichbar"
|
||||||
|
},
|
||||||
|
color = CastarrColors.muted,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "St. Pauli läuft jetzt auf Liga Live UHD." Shown over the running picture
|
||||||
|
* when a club match starts elsewhere; OK switches, Zurück dismisses.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ClubNudge(state: AppState) {
|
||||||
|
LaunchedEffect(state.playerVisible) {
|
||||||
|
while (state.playerVisible) {
|
||||||
|
state.checkClubNudge()
|
||||||
|
kotlinx.coroutines.delay(30_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val nudge = state.clubNudge ?: return
|
||||||
|
// The card takes focus while it is up: with the playback overlay open,
|
||||||
|
// one of its buttons would otherwise swallow the OK the card promises.
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(nudge) { runCatching { focus.requestFocus() } }
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
|
||||||
|
Surface(
|
||||||
|
onClick = { state.acceptClubNudge() },
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 28.dp, end = 40.dp)
|
||||||
|
.focusRequester(focus),
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(14.dp)),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = CastarrColors.surface,
|
||||||
|
contentColor = CastarrColors.fg,
|
||||||
|
focusedContainerColor = CastarrColors.surface,
|
||||||
|
focusedContentColor = CastarrColors.fg,
|
||||||
|
),
|
||||||
|
border = ClickableSurfaceDefaults.border(
|
||||||
|
border = Border(
|
||||||
|
border = BorderStroke(1.dp, CastarrColors.accent),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
),
|
||||||
|
focusedBorder = Border(
|
||||||
|
border = BorderStroke(2.dp, CastarrColors.accent),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 20.dp, vertical = 14.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(7.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(CastarrColors.live)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"${nudge.clubName} läuft jetzt",
|
||||||
|
color = CastarrColors.fg,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
nudge.channel.name,
|
||||||
|
color = CastarrColors.muted,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"OK zum Wechseln · Zurück zum Ausblenden",
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
13
app/src/main/java/dev/castarr/tv/ui/Clock.kt
Normal file
13
app/src/main/java/dev/castarr/tv/ui/Clock.kt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall-clock time as the screens show it: "20:15", German locale, device
|
||||||
|
* timezone. Lived twice in two screens, which is once too often for a rule
|
||||||
|
* that has to be the same everywhere.
|
||||||
|
*/
|
||||||
|
internal fun formatClock(millis: Long): String =
|
||||||
|
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
|
||||||
150
app/src/main/java/dev/castarr/tv/ui/DayPlan.kt
Normal file
150
app/src/main/java/dev/castarr/tv/ui/DayPlan.kt
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
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.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import dev.castarr.tv.AppState
|
||||||
|
import dev.castarr.tv.data.isEpgPlaceholder
|
||||||
|
import dev.castarr.tv.playlist.Channel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this channel still shows today. The EPG beyond "now and next" the
|
||||||
|
* app was missing: the data was parsed all along, only never displayed.
|
||||||
|
*
|
||||||
|
* Read-only on purpose — no recordings, no reminders, nothing to operate.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun DayPlanDialog(state: AppState, channel: Channel, onClose: () -> Unit) {
|
||||||
|
val programmes = remember(channel.url) {
|
||||||
|
state.upcomingToday(channel).filterNot { isEpgPlaceholder(it.title) }
|
||||||
|
}
|
||||||
|
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.width(760.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(CastarrColors.surface)
|
||||||
|
.padding(horizontal = 26.dp, vertical = 22.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
channel.name,
|
||||||
|
color = CastarrColors.fg,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Heute noch",
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
letterSpacing = 1.5.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
if (programmes.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Für diesen Sender liegt kein Programm vor.",
|
||||||
|
color = CastarrColors.muted,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
LazyColumn(
|
||||||
|
Modifier.heightIn(max = 520.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(programmes) { index, programme ->
|
||||||
|
val running = now in programme.start until programme.stop
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(
|
||||||
|
if (running) CastarrColors.accentDim
|
||||||
|
else androidx.compose.ui.graphics.Color.Transparent
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
formatClock(programme.start),
|
||||||
|
color = if (running) CastarrColors.accent
|
||||||
|
else CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier.width(64.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
programme.title,
|
||||||
|
color = if (running) CastarrColors.accent
|
||||||
|
else CastarrColors.fg,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (running) {
|
||||||
|
Text(
|
||||||
|
"läuft",
|
||||||
|
color = CastarrColors.accent,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (index < programmes.lastIndex) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(1.dp)
|
||||||
|
.background(CastarrColors.line)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Zurück zum Schließen",
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
271
app/src/main/java/dev/castarr/tv/ui/LiveCells.kt
Normal file
271
app/src/main/java/dev/castarr/tv/ui/LiveCells.kt
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
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.sp
|
||||||
|
import coil.compose.SubcomposeAsyncImage
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import dev.castarr.tv.AppState
|
||||||
|
import dev.castarr.tv.data.NowNext
|
||||||
|
import dev.castarr.tv.data.isEpgPlaceholder
|
||||||
|
import dev.castarr.tv.playlist.Channel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The club badge. The images ship in the APK (fetched at build time by
|
||||||
|
* tools/fetch-crests.py, never committed), so nothing has to load over the
|
||||||
|
* network; the coloured shield stands in if one is ever missing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun Crest(
|
||||||
|
team: dev.castarr.tv.data.TeamFilter,
|
||||||
|
state: AppState,
|
||||||
|
urgency: AppState.TeamUrgency = AppState.TeamUrgency.NONE,
|
||||||
|
) {
|
||||||
|
// The box is wider than the crest on purpose: the pulse ring is drawn
|
||||||
|
// around it, and a box sized to the crest would clip the ring away.
|
||||||
|
Box(Modifier.size(30.dp), contentAlignment = Alignment.Center) {
|
||||||
|
if (urgency != AppState.TeamUrgency.NONE) {
|
||||||
|
// A slow pulse is what actually catches the eye from the sofa;
|
||||||
|
// colour alone does not at that distance.
|
||||||
|
val transition = androidx.compose.animation.core.rememberInfiniteTransition(
|
||||||
|
label = "crest-pulse",
|
||||||
|
)
|
||||||
|
val phase by transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = androidx.compose.animation.core.infiniteRepeatable(
|
||||||
|
androidx.compose.animation.core.tween(2000, easing = androidx.compose.animation.core.LinearEasing),
|
||||||
|
androidx.compose.animation.core.RepeatMode.Restart,
|
||||||
|
),
|
||||||
|
label = "phase",
|
||||||
|
)
|
||||||
|
val ringColor =
|
||||||
|
if (urgency == AppState.TeamUrgency.LIVE) CastarrColors.live
|
||||||
|
else CastarrColors.accent
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
val grow = phase.coerceAtMost(0.75f) / 0.75f
|
||||||
|
drawCircle(
|
||||||
|
color = ringColor.copy(alpha = (1f - grow) * 0.85f),
|
||||||
|
radius = size.minDimension * (0.34f + grow * 0.15f),
|
||||||
|
style = Stroke(width = 2.dp.toPx()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = "file:///android_asset/crests/${team.key}.png",
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
filterQuality = androidx.compose.ui.graphics.FilterQuality.High,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
loading = { ShieldFallback(team) },
|
||||||
|
error = { ShieldFallback(team) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShieldFallback(team: dev.castarr.tv.data.TeamFilter) {
|
||||||
|
Canvas(Modifier.size(18.dp)) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val shield = Path().apply {
|
||||||
|
moveTo(w * 0.5f, 0f)
|
||||||
|
lineTo(w, h * 0.18f)
|
||||||
|
lineTo(w, h * 0.55f)
|
||||||
|
cubicTo(w, h * 0.82f, w * 0.75f, h * 0.95f, w * 0.5f, h)
|
||||||
|
cubicTo(w * 0.25f, h * 0.95f, 0f, h * 0.82f, 0f, h * 0.55f)
|
||||||
|
lineTo(0f, h * 0.18f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(shield, team.primary)
|
||||||
|
clipPath(shield) {
|
||||||
|
drawRect(
|
||||||
|
team.secondary,
|
||||||
|
topLeft = Offset(0f, h * 0.42f),
|
||||||
|
size = androidx.compose.ui.geometry.Size(w, h * 0.16f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bare channel logo (they ship transparent); initials as fallback. */
|
||||||
|
@Composable
|
||||||
|
internal fun LogoTile(channel: Channel) {
|
||||||
|
val initials = channel.name.split(" ").filter { it.isNotBlank() }
|
||||||
|
.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next club broadcast on this channel, and how many follow it. */
|
||||||
|
@Composable
|
||||||
|
internal fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier) {
|
||||||
|
val programme = hit.programme
|
||||||
|
val running = System.currentTimeMillis() in programme.start until programme.stop
|
||||||
|
// The row is here because of the fixture, so the fixture is what the
|
||||||
|
// line shows — the raw title cut off exactly at the club's name.
|
||||||
|
val title = dev.castarr.tv.data.TeamFilters.fixture(programme.title) ?: programme.title
|
||||||
|
Column(modifier) {
|
||||||
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
color = CastarrColors.fg,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
// Only "läuft" earns space up here. The kick-off is already in
|
||||||
|
// the line below, and the fixture needs every pixel: real titles
|
||||||
|
// like "Bayer Leverkusen - VfB Stuttgart" were still cut off.
|
||||||
|
if (running) {
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
"läuft",
|
||||||
|
color = CastarrColors.accent,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
(if (running) "" else "ab ") +
|
||||||
|
"${formatClock(programme.start)}–${formatClock(programme.stop)}" +
|
||||||
|
if (hit.further > 0) " · +${hit.further} weitere" else "",
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
internal fun EpgCell(
|
||||||
|
nowNext: NowNext,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
/** Shown instead of an empty half-row when the channel has no EPG. */
|
||||||
|
fallback: String = "",
|
||||||
|
) {
|
||||||
|
val now = nowNext.now?.takeUnless { isEpgPlaceholder(it.title) }
|
||||||
|
Column(modifier) {
|
||||||
|
if (now == null && fallback.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
fallback,
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (now != null) {
|
||||||
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
|
Text(
|
||||||
|
now.title,
|
||||||
|
color = CastarrColors.fg,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
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() /
|
||||||
|
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(2.dp)
|
||||||
|
.clip(RoundedCornerShape(1.dp))
|
||||||
|
.background(CastarrColors.line)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(fraction)
|
||||||
|
.height(2.dp)
|
||||||
|
.background(CastarrColors.accent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val next = nowNext.next?.takeUnless {
|
||||||
|
isEpgPlaceholder(it.title) || it.title == now.title
|
||||||
|
}
|
||||||
|
if (next != null) {
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
Text(
|
||||||
|
"danach: ${next.title}",
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
117
app/src/main/java/dev/castarr/tv/ui/LiveChannelRow.kt
Normal file
117
app/src/main/java/dev/castarr/tv/ui/LiveChannelRow.kt
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
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.sp
|
||||||
|
import androidx.tv.material3.Border
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import dev.castarr.tv.AppState
|
||||||
|
import dev.castarr.tv.data.NowNext
|
||||||
|
import dev.castarr.tv.playlist.Channel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ChannelRow(
|
||||||
|
channel: Channel,
|
||||||
|
number: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
nowNext: NowNext,
|
||||||
|
highlight: AppState.TeamHit? = null,
|
||||||
|
playing: Boolean,
|
||||||
|
favorite: Boolean,
|
||||||
|
epgStamp: Long,
|
||||||
|
onLongClick: (() -> Unit)?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
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(
|
||||||
|
containerColor = if (playing) CastarrColors.accentDim else CastarrColors.surface,
|
||||||
|
contentColor = CastarrColors.fg,
|
||||||
|
focusedContainerColor = CastarrColors.surfaceFocused,
|
||||||
|
focusedContentColor = CastarrColors.fg,
|
||||||
|
),
|
||||||
|
border = ClickableSurfaceDefaults.border(
|
||||||
|
focusedBorder = Border(
|
||||||
|
border = BorderStroke(2.dp, CastarrColors.accent),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"$number",
|
||||||
|
color = if (playing) CastarrColors.accent else CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.width(40.dp),
|
||||||
|
)
|
||||||
|
LogoTile(channel)
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.width(230.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
channel.name,
|
||||||
|
color = if (playing) CastarrColors.accent else CastarrColors.fg,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
if (favorite) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("★", color = CastarrColors.accent, fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(24.dp))
|
||||||
|
if (highlight != null) {
|
||||||
|
// In the club view the matching broadcast is the point, not
|
||||||
|
// whatever happens to be running.
|
||||||
|
HighlightCell(highlight, Modifier.weight(1f))
|
||||||
|
} else {
|
||||||
|
EpgCell(nowNext, Modifier.weight(1f), fallback = channel.group)
|
||||||
|
}
|
||||||
|
if (playing) {
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(7.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(CastarrColors.accent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
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.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.fillMaxHeight
|
||||||
@@ -15,43 +13,37 @@ 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.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
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.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
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
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
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.isEpgPlaceholder
|
|
||||||
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,
|
* Live tab: group rail on the left (focusing a group opens it — exclusive,
|
||||||
@@ -61,18 +53,45 @@ import java.util.Locale
|
|||||||
fun LiveScreen(state: AppState) {
|
fun LiveScreen(state: AppState) {
|
||||||
val genericChannels by state.source.channels.collectAsStateWithLifecycle()
|
val genericChannels by state.source.channels.collectAsStateWithLifecycle()
|
||||||
val dispatcharrChannels by state.dispatcharr.channels.collectAsStateWithLifecycle()
|
val dispatcharrChannels by state.dispatcharr.channels.collectAsStateWithLifecycle()
|
||||||
val favorites by state.dispatcharr.favorites.collectAsStateWithLifecycle()
|
// Backend favourites have to be *collected*, not read off the flow:
|
||||||
|
// reading the value inside a composable never recomposes, so the rail
|
||||||
|
// sat at zero while the backend already knew better.
|
||||||
|
val backendFavorites by state.dispatcharr.favorites.collectAsStateWithLifecycle()
|
||||||
// Re-render Now/Next when a new EPG arrives.
|
// Re-render Now/Next when a new EPG arrives.
|
||||||
val genericEpg by state.source.epgUpdatedAt.collectAsStateWithLifecycle()
|
val genericEpg by state.source.epgUpdatedAt.collectAsStateWithLifecycle()
|
||||||
val dispatcharrEpg by state.dispatcharr.epgUpdatedAt.collectAsStateWithLifecycle()
|
val dispatcharrEpg by state.dispatcharr.epgUpdatedAt.collectAsStateWithLifecycle()
|
||||||
val isDispatcharr = state.sourceMode == AppState.SourceMode.DISPATCHARR
|
val isDispatcharr = state.sourceMode == AppState.SourceMode.DISPATCHARR
|
||||||
|
fun isFavorite(channel: dev.castarr.tv.playlist.Channel) =
|
||||||
|
if (isDispatcharr) channel.backendId in backendFavorites else state.isFavorite(channel)
|
||||||
|
val favoriteCount = if (isDispatcharr) backendFavorites.size else state.favoriteCount()
|
||||||
val epgStamp = if (isDispatcharr) dispatcharrEpg else genericEpg
|
val epgStamp = if (isDispatcharr) dispatcharrEpg else genericEpg
|
||||||
val allChannels = if (isDispatcharr) dispatcharrChannels else genericChannels
|
val allChannels = if (isDispatcharr) dispatcharrChannels else genericChannels
|
||||||
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 teams = remember(state.enabledTeams) {
|
||||||
|
dev.castarr.tv.data.TeamFilters.all.filter { it.key in state.enabledTeams }
|
||||||
|
}
|
||||||
|
// Recomputed when the EPG or the channel list changes — scanning the
|
||||||
|
// sport groups' programmes is cheap, but not per frame.
|
||||||
|
val matchesByTeam = remember(teams, allChannels, epgStamp) {
|
||||||
|
teams.associate { it.key to state.teamMatches(it.key) }
|
||||||
|
}
|
||||||
|
// Re-evaluates the highlight every minute; without it "gleich" would
|
||||||
|
// only appear when something else happened to recompose.
|
||||||
|
var urgencyTick by remember { mutableStateOf(0) }
|
||||||
|
LaunchedEffect(teams) {
|
||||||
|
while (true) {
|
||||||
|
kotlinx.coroutines.delay(60_000)
|
||||||
|
urgencyTick++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val activeTeam = teams.firstOrNull { it.key == state.activeTeam }
|
||||||
|
val activeMatches = activeTeam?.let { matchesByTeam[it.key] }.orEmpty()
|
||||||
|
val teamHits = remember(activeMatches) { activeMatches.associate { it.first.url to it.second } }
|
||||||
val channels = when {
|
val channels = when {
|
||||||
state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
|
activeTeam != null -> activeMatches.map { it.first }
|
||||||
|
state.favoritesOnly -> allChannels.filter { isFavorite(it) }
|
||||||
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
|
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
|
||||||
else -> allChannels
|
else -> allChannels
|
||||||
}
|
}
|
||||||
@@ -97,11 +116,46 @@ fun LiveScreen(state: AppState) {
|
|||||||
val restoreFocus = remember { FocusRequester() }
|
val restoreFocus = remember { FocusRequester() }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
// Index of the channel the viewer last watched, so leaving playback
|
// Index of the channel the viewer last watched, so leaving playback
|
||||||
// returns them to where they were instead of the top of the list.
|
// returns them to where they were instead of the top of the list. The
|
||||||
val restoreIndex = state.lastWatched?.let { watched ->
|
// URL survives a restart, so switching the TV on lands there too.
|
||||||
channels.indexOfFirst { it.url == watched.url }.takeIf { it >= 0 }
|
val restoreTarget = state.restoreTargetUrl()
|
||||||
|
val restoreIndex = restoreTarget.takeIf { it.isNotEmpty() }?.let { url ->
|
||||||
|
channels.indexOfFirst { it.url == url }.takeIf { it >= 0 }
|
||||||
}
|
}
|
||||||
var restored by remember { mutableStateOf(false) }
|
var restored by remember { mutableStateOf(false) }
|
||||||
|
// Right jumps to the next initial letter — the only way through a long
|
||||||
|
// list without a text field, which the ten-foot rule rules out.
|
||||||
|
var focusedIndex by remember { mutableStateOf(0) }
|
||||||
|
var jumpIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
|
val jumpFocus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(jumpIndex) {
|
||||||
|
val target = jumpIndex ?: return@LaunchedEffect
|
||||||
|
listState.scrollToItem(target)
|
||||||
|
runCatching { jumpFocus.requestFocus() }
|
||||||
|
focusedIndex = target
|
||||||
|
jumpIndex = null
|
||||||
|
}
|
||||||
|
fun initial(name: String) = name.trimStart().firstOrNull()?.uppercaseChar()
|
||||||
|
fun jumpToInitial(direction: Int) {
|
||||||
|
if (channels.isEmpty()) return
|
||||||
|
val from = focusedIndex.coerceIn(0, channels.lastIndex)
|
||||||
|
val current = initial(channels[from].name)
|
||||||
|
jumpIndex = if (direction > 0) {
|
||||||
|
val next = channels.drop(from + 1).indexOfFirst { initial(it.name) != current }
|
||||||
|
// Past the last letter, wrap to the top rather than do nothing.
|
||||||
|
if (next >= 0) from + 1 + next else 0
|
||||||
|
} else {
|
||||||
|
val before = channels.take(from).indexOfLast { initial(it.name) != current }
|
||||||
|
if (before < 0) 0 else {
|
||||||
|
val letter = initial(channels[before].name)
|
||||||
|
channels.take(before + 1).indexOfFirst { initial(it.name) == letter }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state.letterJump) {
|
||||||
|
if (state.letterJump > 0) jumpToInitial(state.letterJumpDirection)
|
||||||
|
}
|
||||||
|
var dayPlanFor by remember { mutableStateOf<dev.castarr.tv.playlist.Channel?>(null) }
|
||||||
LaunchedEffect(state.groupFilter, state.favoritesOnly) {
|
LaunchedEffect(state.groupFilter, state.favoritesOnly) {
|
||||||
// A fresh group starts at the top; a return from playback does not.
|
// A fresh group starts at the top; a return from playback does not.
|
||||||
if (restoreIndex == null) listState.scrollToItem(0)
|
if (restoreIndex == null) listState.scrollToItem(0)
|
||||||
@@ -112,13 +166,24 @@ fun LiveScreen(state: AppState) {
|
|||||||
runCatching { restoreFocus.requestFocus() }
|
runCatching { restoreFocus.requestFocus() }
|
||||||
restored = true
|
restored = true
|
||||||
state.restorePending = false
|
state.restorePending = false
|
||||||
|
} else if (restoreIndex == null && !restored && channels.isNotEmpty()) {
|
||||||
|
// Nothing to restore, but the list still beats the settings gear
|
||||||
|
// as a starting point.
|
||||||
|
restored = runCatching { listFocus.requestFocus() }.isSuccess
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val intoList = if (channels.isEmpty()) Modifier
|
// A FocusRequester that is not currently attached throws when used, and
|
||||||
else Modifier.focusProperties { right = listFocus }
|
// in a LazyColumn the selected row may well be scrolled out of
|
||||||
|
// composition — so jumps are attempted, not declared.
|
||||||
|
val intoList = if (channels.isEmpty()) Modifier else Modifier.onPreviewKeyEvent { event ->
|
||||||
|
event.type == KeyEventType.KeyDown && event.key == Key.DirectionRight &&
|
||||||
|
runCatching { listFocus.requestFocus() }.isSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
val railState = rememberLazyListState()
|
||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = railState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(264.dp)
|
.width(264.dp)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
@@ -128,7 +193,8 @@ fun LiveScreen(state: AppState) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
val selected = !state.favoritesOnly && state.groupFilter == null
|
val selected = !state.favoritesOnly && state.groupFilter == null &&
|
||||||
|
state.activeTeam == null
|
||||||
GroupItem(
|
GroupItem(
|
||||||
label = "Alle Sender",
|
label = "Alle Sender",
|
||||||
count = allChannels.size,
|
count = allChannels.size,
|
||||||
@@ -138,24 +204,48 @@ fun LiveScreen(state: AppState) {
|
|||||||
),
|
),
|
||||||
suppressAutoSelect = { state.restorePending },
|
suppressAutoSelect = { state.restorePending },
|
||||||
) {
|
) {
|
||||||
|
state.activeTeam = null
|
||||||
state.favoritesOnly = false
|
state.favoritesOnly = false
|
||||||
state.groupFilter = null
|
state.groupFilter = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isDispatcharr) {
|
// Favourites exist for both source types now, so the entry is
|
||||||
item {
|
// no longer tied to being signed in.
|
||||||
GroupItem(
|
item {
|
||||||
label = "★ Favoriten",
|
GroupItem(
|
||||||
count = favorites.size,
|
label = "★ Favoriten",
|
||||||
selected = state.favoritesOnly,
|
count = favoriteCount,
|
||||||
modifier = intoList.then(
|
selected = state.favoritesOnly && state.activeTeam == null,
|
||||||
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
|
modifier = intoList.then(
|
||||||
),
|
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
|
||||||
suppressAutoSelect = { state.restorePending },
|
),
|
||||||
) {
|
suppressAutoSelect = { state.restorePending },
|
||||||
state.favoritesOnly = true
|
) {
|
||||||
state.groupFilter = null
|
state.activeTeam = null
|
||||||
}
|
state.favoritesOnly = true
|
||||||
|
state.groupFilter = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(teams, key = { it.key }) { club ->
|
||||||
|
val clubMatches = matchesByTeam[club.key].orEmpty()
|
||||||
|
val clubSignal = remember(clubMatches, urgencyTick) {
|
||||||
|
state.teamSignal(clubMatches)
|
||||||
|
}
|
||||||
|
GroupItem(
|
||||||
|
label = club.shortName,
|
||||||
|
count = clubMatches.size,
|
||||||
|
selected = state.activeTeam == club.key,
|
||||||
|
leading = { Crest(club, state, clubSignal.urgency) },
|
||||||
|
signal = clubSignal,
|
||||||
|
modifier = intoList.then(
|
||||||
|
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
|
||||||
|
else Modifier
|
||||||
|
),
|
||||||
|
suppressAutoSelect = { state.restorePending },
|
||||||
|
) {
|
||||||
|
state.activeTeam = club.key
|
||||||
|
state.favoritesOnly = false
|
||||||
|
state.groupFilter = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -171,12 +261,13 @@ fun LiveScreen(state: AppState) {
|
|||||||
GroupItem(
|
GroupItem(
|
||||||
label = group,
|
label = group,
|
||||||
count = remember(allChannels, group) { allChannels.count { it.group == group } },
|
count = remember(allChannels, group) { allChannels.count { it.group == group } },
|
||||||
selected = state.groupFilter == group,
|
selected = state.groupFilter == group && state.activeTeam == null,
|
||||||
modifier = intoList.then(
|
modifier = intoList.then(
|
||||||
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
|
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
|
||||||
),
|
),
|
||||||
suppressAutoSelect = { state.restorePending },
|
suppressAutoSelect = { state.restorePending },
|
||||||
) {
|
) {
|
||||||
|
state.activeTeam = null
|
||||||
state.favoritesOnly = false
|
state.favoritesOnly = false
|
||||||
state.groupFilter = group
|
state.groupFilter = group
|
||||||
}
|
}
|
||||||
@@ -196,9 +287,13 @@ fun LiveScreen(state: AppState) {
|
|||||||
if (channels.isEmpty()) {
|
if (channels.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
if (state.favoritesOnly)
|
when {
|
||||||
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
activeTeam != null ->
|
||||||
else "Diese Gruppe ist leer.",
|
"Heute läuft nichts mehr mit ${activeTeam.fullName}."
|
||||||
|
state.favoritesOnly ->
|
||||||
|
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
|
||||||
|
else -> "Diese Gruppe ist leer."
|
||||||
|
},
|
||||||
color = CastarrColors.faint,
|
color = CastarrColors.faint,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
@@ -213,26 +308,48 @@ fun LiveScreen(state: AppState) {
|
|||||||
// matching the remote's number pad in every view.
|
// matching the remote's number pad in every view.
|
||||||
number = allChannels.indexOf(channel) + 1,
|
number = allChannels.indexOf(channel) + 1,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusProperties { left = railFocus }
|
.onFocusChanged { if (it.isFocused) focusedIndex = listIndex }
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
if (event.type != KeyEventType.KeyDown) {
|
||||||
|
return@onPreviewKeyEvent false
|
||||||
|
}
|
||||||
|
when (event.key) {
|
||||||
|
Key.DirectionLeft ->
|
||||||
|
runCatching { railFocus.requestFocus() }.isSuccess
|
||||||
|
// The day plan for this channel — the EPG
|
||||||
|
// beyond "now and next" (#16).
|
||||||
|
Key.DirectionRight -> {
|
||||||
|
dayPlanFor = channel
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier)
|
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier)
|
||||||
.then(
|
.then(
|
||||||
if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus)
|
if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus)
|
||||||
else Modifier
|
else Modifier
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
if (listIndex == jumpIndex) Modifier.focusRequester(jumpFocus)
|
||||||
|
else Modifier
|
||||||
),
|
),
|
||||||
nowNext = state.nowNext(channel),
|
nowNext = state.nowNext(channel),
|
||||||
|
highlight = teamHits[channel.url],
|
||||||
playing = state.currentChannel?.url == channel.url,
|
playing = state.currentChannel?.url == channel.url,
|
||||||
// All rows are favorites in the favorites view — the
|
// All rows are favorites in the favorites view — the
|
||||||
// star only carries meaning elsewhere.
|
// star only carries meaning elsewhere.
|
||||||
favorite = isDispatcharr && !state.favoritesOnly &&
|
favorite = !state.favoritesOnly && isFavorite(channel),
|
||||||
channel.backendId in favorites,
|
|
||||||
epgStamp = epgStamp,
|
epgStamp = epgStamp,
|
||||||
onLongClick = if (isDispatcharr) {
|
onLongClick = { state.toggleFavorite(channel) },
|
||||||
{ state.dispatcharr.toggleFavorite(channel) }
|
|
||||||
} else null,
|
|
||||||
) { state.play(channel) }
|
) { state.play(channel) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dayPlanFor?.let { channel ->
|
||||||
|
DayPlanDialog(state, channel) { dayPlanFor = null }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -241,6 +358,9 @@ private fun GroupItem(
|
|||||||
count: Int,
|
count: Int,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
leading: (@Composable () -> Unit)? = null,
|
||||||
|
/** Replaces the count when a match is on or about to start. */
|
||||||
|
signal: AppState.TeamSignal? = null,
|
||||||
suppressAutoSelect: () -> Boolean = { false },
|
suppressAutoSelect: () -> Boolean = { false },
|
||||||
onSelect: () -> Unit,
|
onSelect: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -264,6 +384,10 @@ private fun GroupItem(
|
|||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
if (leading != null) {
|
||||||
|
leading()
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
@@ -274,190 +398,36 @@ private fun GroupItem(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
// The signal lives in the value column instead of adding a
|
||||||
"$count",
|
// badge — the count is worth less than "now" or "16:00" is.
|
||||||
fontFamily = AppFont,
|
val urgent = signal?.urgency ?: AppState.TeamUrgency.NONE
|
||||||
fontSize = 12.sp,
|
if (urgent == AppState.TeamUrgency.NONE) {
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ChannelRow(
|
|
||||||
channel: Channel,
|
|
||||||
number: Int,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
nowNext: NowNext,
|
|
||||||
playing: Boolean,
|
|
||||||
favorite: Boolean,
|
|
||||||
epgStamp: Long,
|
|
||||||
onLongClick: (() -> Unit)?,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
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(
|
|
||||||
containerColor = if (playing) CastarrColors.accentDim else CastarrColors.surface,
|
|
||||||
contentColor = CastarrColors.fg,
|
|
||||||
focusedContainerColor = CastarrColors.surfaceFocused,
|
|
||||||
focusedContentColor = CastarrColors.fg,
|
|
||||||
),
|
|
||||||
border = ClickableSurfaceDefaults.border(
|
|
||||||
focusedBorder = Border(
|
|
||||||
border = BorderStroke(2.dp, CastarrColors.accent),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"$number",
|
|
||||||
color = if (playing) CastarrColors.accent else CastarrColors.faint,
|
|
||||||
fontFamily = AppFont,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
modifier = Modifier.width(40.dp),
|
|
||||||
)
|
|
||||||
LogoTile(channel)
|
|
||||||
Spacer(Modifier.width(14.dp))
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.width(230.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
channel.name,
|
"$count",
|
||||||
color = if (playing) CastarrColors.accent else CastarrColors.fg,
|
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 16.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
|
||||||
)
|
)
|
||||||
if (favorite) {
|
} else {
|
||||||
Spacer(Modifier.width(8.dp))
|
// A focused row is filled with the accent colour, so signal
|
||||||
Text("★", color = CastarrColors.accent, fontSize = 13.sp)
|
// colour straight on the row was red on turquoise. The chip
|
||||||
|
// gives it a dark ground that holds on every row state.
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(999.dp))
|
||||||
|
.background(CastarrColors.bgDeep.copy(alpha = 0.92f))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 3.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (urgent == AppState.TeamUrgency.LIVE) "läuft"
|
||||||
|
else "ab ${formatClock(signal!!.kickOff)}",
|
||||||
|
color = if (urgent == AppState.TeamUrgency.LIVE) CastarrColors.live
|
||||||
|
else CastarrColors.accent,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(24.dp))
|
|
||||||
EpgCell(nowNext, Modifier.weight(1f))
|
|
||||||
if (playing) {
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.size(7.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(CastarrColors.accent)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bare channel logo (they ship transparent); initials as fallback. */
|
|
||||||
@Composable
|
|
||||||
private fun LogoTile(channel: Channel) {
|
|
||||||
val initials = channel.name.split(" ").filter { it.isNotBlank() }
|
|
||||||
.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) {
|
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
|
||||||
Text(
|
|
||||||
now.title,
|
|
||||||
color = CastarrColors.fg,
|
|
||||||
fontFamily = AppFont,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
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() /
|
|
||||||
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(2.dp)
|
|
||||||
.clip(RoundedCornerShape(1.dp))
|
|
||||||
.background(CastarrColors.line)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth(fraction)
|
|
||||||
.height(2.dp)
|
|
||||||
.background(CastarrColors.accent)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val next = nowNext.next?.takeUnless {
|
|
||||||
isEpgPlaceholder(it.title) || it.title == now.title
|
|
||||||
}
|
|
||||||
if (next != null) {
|
|
||||||
Spacer(Modifier.height(5.dp))
|
|
||||||
Text(
|
|
||||||
"danach: ${next.title}",
|
|
||||||
color = CastarrColors.faint,
|
|
||||||
fontFamily = AppFont,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ 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 dev.castarr.tv.data.isEpgPlaceholder
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fullscreen playback. OK opens the auto-hiding overlay whose transport
|
* Fullscreen playback. OK opens the auto-hiding overlay whose transport
|
||||||
@@ -106,9 +103,6 @@ private val OverlayText = TextStyle(
|
|||||||
shadow = Shadow(Color(0xB3000000), Offset(0f, 2f), blurRadius = 8f),
|
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) {
|
||||||
val playFocus = remember { FocusRequester() }
|
val playFocus = remember { FocusRequester() }
|
||||||
|
|||||||
240
app/src/main/java/dev/castarr/tv/ui/SettingsClubs.kt
Normal file
240
app/src/main/java/dev/castarr/tv/ui/SettingsClubs.kt
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
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.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
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.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.Surface
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import dev.castarr.tv.AppState
|
||||||
|
|
||||||
|
/** Club crest from the bundled assets; initials while it loads. */
|
||||||
|
@Composable
|
||||||
|
internal fun ClubCrest(team: dev.castarr.tv.data.TeamFilter, state: AppState, size: Int = 26) {
|
||||||
|
Box(Modifier.size(size.dp), contentAlignment = Alignment.Center) {
|
||||||
|
coil.compose.SubcomposeAsyncImage(
|
||||||
|
model = "file:///android_asset/crests/${team.key}.png",
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
loading = { CrestInitials(team) },
|
||||||
|
error = { CrestInitials(team) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CrestInitials(team: dev.castarr.tv.data.TeamFilter) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(team.primary),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
team.label.take(3),
|
||||||
|
color = team.secondary,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 8.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Club chooser: crests plus division headers, because a flat list of 56
|
||||||
|
* names is unreadable on a remote.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ClubPickerDialog(
|
||||||
|
state: AppState,
|
||||||
|
onPick: (dev.castarr.tv.data.TeamFilter) -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } }
|
||||||
|
val sections = remember(state.enabledTeams) {
|
||||||
|
dev.castarr.tv.data.TeamFilters.byLeague(
|
||||||
|
dev.castarr.tv.data.TeamFilters.all.filter { it.key !in state.enabledTeams }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val firstKey = sections.firstOrNull()?.second?.firstOrNull()?.key
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
// Left and right jump a whole division: without it the third league is
|
||||||
|
// forty presses away.
|
||||||
|
var leagueIndex by remember { mutableStateOf(0) }
|
||||||
|
val leagueFocus = remember { FocusRequester() }
|
||||||
|
var leagueJump by remember { mutableStateOf(false) }
|
||||||
|
// Item index of each division header in the flat list the LazyColumn
|
||||||
|
// builds (one header plus its clubs per section).
|
||||||
|
val leagueStarts = remember(sections) {
|
||||||
|
var index = 0
|
||||||
|
sections.map { (_, clubs) ->
|
||||||
|
val start = index
|
||||||
|
index += clubs.size + 1
|
||||||
|
start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val leagueFirstKeys = remember(sections) { sections.map { it.second.firstOrNull()?.key } }
|
||||||
|
LaunchedEffect(leagueIndex, leagueJump) {
|
||||||
|
if (!leagueJump) return@LaunchedEffect
|
||||||
|
leagueStarts.getOrNull(leagueIndex)?.let { listState.scrollToItem(it) }
|
||||||
|
runCatching { leagueFocus.requestFocus() }
|
||||||
|
leagueJump = false
|
||||||
|
}
|
||||||
|
fun jumpLeague(direction: Int) {
|
||||||
|
val next = (leagueIndex + direction).coerceIn(0, sections.lastIndex.coerceAtLeast(0))
|
||||||
|
if (next == leagueIndex) return
|
||||||
|
leagueIndex = next
|
||||||
|
leagueJump = true
|
||||||
|
}
|
||||||
|
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.width(420.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(CastarrColors.surface)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 14.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"VEREIN HINZUFÜGEN",
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
modifier = Modifier.padding(start = 14.dp, bottom = 2.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"◀ ▶ Liga wechseln",
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier.padding(start = 14.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(max = 460.dp)
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
|
when (event.key) {
|
||||||
|
Key.DirectionRight -> { jumpLeague(1); true }
|
||||||
|
Key.DirectionLeft -> { jumpLeague(-1); true }
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
sections.forEach { (league, clubs) ->
|
||||||
|
item(key = "h-$league") {
|
||||||
|
Text(
|
||||||
|
league,
|
||||||
|
color = CastarrColors.accent,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
letterSpacing = 1.5.sp,
|
||||||
|
modifier = Modifier.padding(start = 14.dp, top = 12.dp, bottom = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(clubs, key = { it.key }) { club ->
|
||||||
|
val focusMod = when (club.key) {
|
||||||
|
firstKey -> Modifier.focusRequester(firstFocus)
|
||||||
|
leagueFirstKeys.getOrNull(leagueIndex) ->
|
||||||
|
Modifier.focusRequester(leagueFocus)
|
||||||
|
else -> Modifier
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
onClick = { onPick(club) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(focusMod),
|
||||||
|
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 = 9.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
ClubCrest(club, state, size = 24)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
club.fullName,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
club.label,
|
||||||
|
color = CastarrColors.faint,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
package dev.castarr.tv.ui
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -17,8 +12,10 @@ 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.rememberScrollState
|
||||||
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.foundation.verticalScroll
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -31,19 +28,12 @@ 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.foundation.focusGroup
|
||||||
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.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
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.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.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.BuildConfig
|
import dev.castarr.tv.BuildConfig
|
||||||
@@ -53,7 +43,7 @@ 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. */
|
/** One open picker dialog: which setting, its options, what happens on pick. */
|
||||||
private data class Picker(
|
internal data class Picker(
|
||||||
val title: String,
|
val title: String,
|
||||||
val options: List<String>,
|
val options: List<String>,
|
||||||
val selected: Int,
|
val selected: Int,
|
||||||
@@ -72,15 +62,30 @@ fun SettingsScreen(state: AppState) {
|
|||||||
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) }
|
var picker by remember { mutableStateOf<Picker?>(null) }
|
||||||
|
var clubPicker by remember { mutableStateOf(false) }
|
||||||
// Bumped on reset so the QR code and the four-digit code redraw.
|
// Bumped on reset so the QR code and the four-digit code redraw.
|
||||||
var pairingEpoch by remember { mutableStateOf(0) }
|
var pairingEpoch by remember { mutableStateOf(0) }
|
||||||
|
var confirmPairingReset by remember { mutableStateOf(false) }
|
||||||
|
// Focus belongs on the first row of the left column, not on the gear the
|
||||||
|
// viewer just left: from there "down" used to land on the destructive
|
||||||
|
// action in the right column and go nowhere after that.
|
||||||
|
val firstRowFocus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { runCatching { firstRowFocus.requestFocus() } }
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 40.dp, vertical = 12.dp)
|
.padding(horizontal = 40.dp, vertical = 12.dp)
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1.25f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
// Three columns instead of two: the cards outgrew one screen once
|
||||||
|
// club menus arrived, and the ten-foot rule (CONTEXT.md) allows no
|
||||||
|
// scrolling outside the channel list.
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.focusGroup(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
SettingsCard("Konto") {
|
SettingsCard("Konto") {
|
||||||
if (state.auth.isLoggedIn) {
|
if (state.auth.isLoggedIn) {
|
||||||
Row(
|
Row(
|
||||||
@@ -114,14 +119,21 @@ fun SettingsScreen(state: AppState) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingRow("Abmelden", danger = true) { state.startOnboarding() }
|
SettingRow(
|
||||||
|
"Abmelden",
|
||||||
|
danger = true,
|
||||||
|
modifier = Modifier.focusRequester(firstRowFocus),
|
||||||
|
) { state.startOnboarding() }
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Nicht angemeldet.",
|
"Nicht angemeldet.",
|
||||||
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
|
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||||
)
|
)
|
||||||
SettingRow("Jetzt anmelden") { state.startOnboarding() }
|
SettingRow(
|
||||||
|
"Jetzt anmelden",
|
||||||
|
modifier = Modifier.focusRequester(firstRowFocus),
|
||||||
|
) { state.startOnboarding() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +175,42 @@ fun SettingsScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsCard("Vereinsmenüs") {
|
||||||
|
Text(
|
||||||
|
"Eigene Gruppe mit allen Sendern, auf denen der Verein " +
|
||||||
|
"heute noch läuft. Bis zu " +
|
||||||
|
"${dev.castarr.tv.data.TeamFilters.MAX_ACTIVE} Vereine.",
|
||||||
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
|
||||||
|
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 6.dp),
|
||||||
|
)
|
||||||
|
val active = dev.castarr.tv.data.TeamFilters.all
|
||||||
|
.filter { it.key in state.enabledTeams }
|
||||||
|
active.forEach { club ->
|
||||||
|
SettingRow(
|
||||||
|
club.fullName,
|
||||||
|
subtitle = "Erscheint als ${club.shortName} in der Senderliste · Entfernen",
|
||||||
|
leading = { ClubCrest(club, state) },
|
||||||
|
trailing = { RemoveIcon() },
|
||||||
|
) { state.toggleTeam(club.key) }
|
||||||
|
}
|
||||||
|
if (active.size < dev.castarr.tv.data.TeamFilters.MAX_ACTIVE) {
|
||||||
|
SettingRow(
|
||||||
|
"Verein hinzufügen",
|
||||||
|
subtitle = "1. bis 3. Liga",
|
||||||
|
) { clubPicker = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.focusGroup(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
SettingsCard("App") {
|
SettingsCard("App") {
|
||||||
SettingRow(
|
SettingRow(
|
||||||
"Version ${BuildConfig.VERSION_NAME}",
|
"Version ${BuildConfig.VERSION_NAME}",
|
||||||
@@ -203,16 +251,37 @@ fun SettingsScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SettingRow(
|
||||||
|
"Senderliste neu laden",
|
||||||
|
subtitle = "Holt Sender, Programm und Favoriten erneut",
|
||||||
|
) { state.refreshActive() }
|
||||||
SettingRow(
|
SettingRow(
|
||||||
"Erweitert",
|
"Erweitert",
|
||||||
subtitle = "M3U/EPG-Adressen von Hand eintragen",
|
subtitle = "M3U/EPG-Adressen von Hand eintragen",
|
||||||
) { state.screen = AppState.Screen.ADVANCED }
|
) { state.screen = AppState.Screen.ADVANCED }
|
||||||
|
// "Zwei Wochen ohne Absturz" ist nur nachprüfbar, wenn ein
|
||||||
|
// Absturz überhaupt eine Spur hinterlässt.
|
||||||
|
val lastCrash = remember(pairingEpoch) {
|
||||||
|
dev.castarr.tv.CrashLog.lastSummary(context)
|
||||||
|
}
|
||||||
|
SettingRow(
|
||||||
|
"Stabilität",
|
||||||
|
subtitle = lastCrash?.let { "Letzter Absturz: $it · Klicken löscht" }
|
||||||
|
?: "Kein Absturz aufgezeichnet",
|
||||||
|
) {
|
||||||
|
dev.castarr.tv.CrashLog.clear(context)
|
||||||
|
pairingEpoch++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
|
|
||||||
Column(Modifier.weight(0.75f)) {
|
Column(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.focusGroup()
|
||||||
|
) {
|
||||||
SettingsCard("Handy-Fernbedienung") {
|
SettingsCard("Handy-Fernbedienung") {
|
||||||
val address = remember(pairingEpoch) { Pairing.lanAddress() }
|
val address = remember(pairingEpoch) { Pairing.lanAddress() }
|
||||||
if (!state.remoteAvailable) {
|
if (!state.remoteAvailable) {
|
||||||
@@ -249,10 +318,7 @@ fun SettingsScreen(state: AppState) {
|
|||||||
SettingRow(
|
SettingRow(
|
||||||
"Kopplung zurücksetzen",
|
"Kopplung zurücksetzen",
|
||||||
subtitle = "Neuer Code, alle Handys müssen neu scannen",
|
subtitle = "Neuer Code, alle Handys müssen neu scannen",
|
||||||
) {
|
) { confirmPairingReset = true }
|
||||||
Pairing.reset(context)
|
|
||||||
pairingEpoch++
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Keine Netzwerkverbindung",
|
"Keine Netzwerkverbindung",
|
||||||
@@ -267,250 +333,31 @@ fun SettingsScreen(state: AppState) {
|
|||||||
picker?.let { current ->
|
picker?.let { current ->
|
||||||
PickerDialog(current) { picker = null }
|
PickerDialog(current) { picker = null }
|
||||||
}
|
}
|
||||||
}
|
if (confirmPairingReset) {
|
||||||
|
val paired = remember(pairingEpoch) { Pairing.pairedCount(context) }
|
||||||
/** TV-friendly dropdown replacement: fullscreen scrim, options centered. */
|
ConfirmDialog(
|
||||||
@Composable
|
title = "Kopplung zurücksetzen?",
|
||||||
private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
message = when (paired) {
|
||||||
val selectedFocus = remember { FocusRequester() }
|
0 -> "Der Code und der QR-Code werden neu erzeugt. Bereits " +
|
||||||
LaunchedEffect(Unit) { selectedFocus.requestFocus() }
|
"gekoppelte Handys müssen danach erneut scannen."
|
||||||
Dialog(
|
1 -> "Ein gekoppeltes Handy verliert den Zugriff und muss " +
|
||||||
onDismissRequest = onClose,
|
"erneut scannen."
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
else -> "$paired gekoppelte Handys verlieren den Zugriff und " +
|
||||||
) {
|
"müssen erneut scannen."
|
||||||
Box(
|
},
|
||||||
Modifier
|
confirmLabel = "Zurücksetzen",
|
||||||
.fillMaxSize()
|
onConfirm = {
|
||||||
// Own heavy scrim — the platform default is too light and
|
Pairing.reset(context)
|
||||||
// lets the white QR card fight the panel.
|
pairingEpoch++
|
||||||
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
|
},
|
||||||
contentAlignment = Alignment.Center,
|
onClose = { confirmPairingReset = false },
|
||||||
) {
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
if (clubPicker) {
|
||||||
|
ClubPickerDialog(
|
||||||
@Composable
|
state = state,
|
||||||
private fun SettingsCard(title: String, content: @Composable () -> Unit) {
|
onPick = { club -> state.toggleTeam(club.key); clubPicker = false },
|
||||||
Column(
|
onClose = { clubPicker = false },
|
||||||
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
|
|
||||||
fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
|
|
||||||
Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
|
||||||
containerColor = if (danger) CastarrColors.surface else CastarrColors.accentDim,
|
|
||||||
contentColor = if (danger) CastarrColors.muted else CastarrColors.accent,
|
|
||||||
focusedContainerColor = if (danger) CastarrColors.live else CastarrColors.accent,
|
|
||||||
focusedContentColor = if (danger) CastarrColors.fg else CastarrColors.onAccent,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
fontFamily = AppFont, fontSize = 13.sp,
|
|
||||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
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(
|
|
||||||
Modifier
|
|
||||||
.clip(RoundedCornerShape(18.dp))
|
|
||||||
.background(CastarrColors.bg)
|
|
||||||
.border(1.dp, CastarrColors.line, RoundedCornerShape(18.dp))
|
|
||||||
.padding(8.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(androidx.compose.ui.graphics.Color(0xFFF4F6F7))
|
|
||||||
.padding(10.dp),
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
bitmap = bitmap.asImageBitmap(),
|
|
||||||
contentDescription = "QR-Code zum Koppeln",
|
|
||||||
modifier = Modifier.size(164.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
411
app/src/main/java/dev/castarr/tv/ui/SettingsWidgets.kt
Normal file
411
app/src/main/java/dev/castarr/tv/ui/SettingsWidgets.kt
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
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.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.Surface
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
|
||||||
|
/** TV-friendly dropdown replacement: fullscreen scrim, options centered. */
|
||||||
|
@Composable
|
||||||
|
internal fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
||||||
|
val selectedFocus = remember { FocusRequester() }
|
||||||
|
// A requester bound to no composed row throws; with no preselection
|
||||||
|
// (adding something new) there is deliberately no such row.
|
||||||
|
LaunchedEffect(Unit) { runCatching { selectedFocus.requestFocus() } }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
LaunchedEffect(picker.selected) {
|
||||||
|
if (picker.selected > 0) listState.scrollToItem(picker.selected)
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.heightIn(max = 420.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(picker.options) { index, option ->
|
||||||
|
val selected = index == picker.selected
|
||||||
|
Surface(
|
||||||
|
onClick = {
|
||||||
|
picker.onPick(index)
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(
|
||||||
|
if (selected || (picker.selected < 0 && index == 0))
|
||||||
|
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
|
||||||
|
internal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drawn "x": the row only ever removes, so a toggle would mislead. */
|
||||||
|
@Composable
|
||||||
|
internal fun RemoveIcon() {
|
||||||
|
val color = CastarrColors.muted
|
||||||
|
Canvas(Modifier.size(14.dp)) {
|
||||||
|
val stroke = 2.dp.toPx()
|
||||||
|
drawLine(color, Offset(0f, 0f), Offset(size.width, size.height), stroke)
|
||||||
|
drawLine(color, Offset(size.width, 0f), Offset(0f, size.height), stroke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TogglePill(on: Boolean) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(999.dp))
|
||||||
|
.background(if (on) CastarrColors.accent else CastarrColors.bg)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 5.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (on) "An" else "Aus",
|
||||||
|
color = if (on) CastarrColors.onAccent else CastarrColors.muted,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val rowShape = RoundedCornerShape(10.dp)
|
||||||
|
|
||||||
|
/** One full-width focusable settings row: label + subtitle, value right. */
|
||||||
|
@Composable
|
||||||
|
internal fun SettingRow(
|
||||||
|
label: String,
|
||||||
|
subtitle: String? = null,
|
||||||
|
danger: Boolean = false,
|
||||||
|
leading: (@Composable () -> Unit)? = null,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
if (leading != null) {
|
||||||
|
leading()
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
|
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 = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trailing != null) {
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
trailing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ActionButton(
|
||||||
|
label: String,
|
||||||
|
danger: Boolean = false,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = if (danger) CastarrColors.surface else CastarrColors.accentDim,
|
||||||
|
contentColor = if (danger) CastarrColors.muted else CastarrColors.accent,
|
||||||
|
focusedContainerColor = if (danger) CastarrColors.live else CastarrColors.accent,
|
||||||
|
focusedContentColor = if (danger) CastarrColors.fg else CastarrColors.onAccent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
fontFamily = AppFont, fontSize = 13.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
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(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(CastarrColors.bg)
|
||||||
|
.border(1.dp, CastarrColors.line, RoundedCornerShape(18.dp))
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(androidx.compose.ui.graphics.Color(0xFFF4F6F7))
|
||||||
|
.padding(10.dp),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = bitmap.asImageBitmap(),
|
||||||
|
contentDescription = "QR-Code zum Koppeln",
|
||||||
|
modifier = Modifier.size(164.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks before something irreversible happens. Focus starts on "Abbrechen":
|
||||||
|
* a stray press of OK must not be the one that carries out the action.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ConfirmDialog(
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
confirmLabel: String,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
val cancelFocus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } }
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onClose,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.width(520.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(CastarrColors.surface)
|
||||||
|
.padding(horizontal = 26.dp, vertical = 24.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
color = CastarrColors.fg,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
color = CastarrColors.muted,
|
||||||
|
fontFamily = AppFont,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(22.dp))
|
||||||
|
Row {
|
||||||
|
ActionButton("Abbrechen", modifier = Modifier.focusRequester(cancelFocus)) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
ActionButton(confirmLabel, danger = true) {
|
||||||
|
onConfirm()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,6 @@ import androidx.compose.ui.text.TextStyle
|
|||||||
fun TvTextField(
|
fun TvTextField(
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
label: @Composable () -> Unit,
|
|
||||||
colors: TextFieldColors,
|
colors: TextFieldColors,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
textStyle: TextStyle = TextStyle.Default,
|
textStyle: TextStyle = TextStyle.Default,
|
||||||
@@ -30,7 +29,6 @@ fun TvTextField(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
label = label,
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
textStyle = textStyle,
|
textStyle = textStyle,
|
||||||
colors = colors,
|
colors = colors,
|
||||||
|
|||||||
@@ -61,8 +61,18 @@ fun WelcomeScreen(state: AppState) {
|
|||||||
AppState.WelcomePhase.WAIT_PHONE -> Unit
|
AppState.WelcomePhase.WAIT_PHONE -> Unit
|
||||||
AppState.WelcomePhase.WAIT_URL -> Status("Handy verbunden — warte auf den Server…")
|
AppState.WelcomePhase.WAIT_URL -> Status("Handy verbunden — warte auf den Server…")
|
||||||
AppState.WelcomePhase.WAIT_LOGIN -> Status(
|
AppState.WelcomePhase.WAIT_LOGIN -> Status(
|
||||||
if (state.welcomeUserCode.isEmpty()) "Warte auf die Anmeldung am Handy…"
|
when {
|
||||||
else "Anmeldung am Handy bestätigen · Code ${state.welcomeUserCode}"
|
state.welcomeUserCode.isEmpty() ->
|
||||||
|
"Warte auf die Anmeldung am Handy…"
|
||||||
|
// Saying so beats swapping the digits in silence
|
||||||
|
// while somebody is typing them.
|
||||||
|
state.welcomeCodeRenewed ->
|
||||||
|
"Der alte Code war abgelaufen — neuer Code " +
|
||||||
|
state.welcomeUserCode
|
||||||
|
else ->
|
||||||
|
"Anmeldung am Handy bestätigen · Code " +
|
||||||
|
state.welcomeUserCode
|
||||||
|
}
|
||||||
)
|
)
|
||||||
AppState.WelcomePhase.DONE -> Status("Angemeldet! Lade Sender…")
|
AppState.WelcomePhase.DONE -> Status("Angemeldet! Lade Sender…")
|
||||||
}
|
}
|
||||||
@@ -78,18 +88,40 @@ fun WelcomeScreen(state: AppState) {
|
|||||||
.background(Color(0xFFFBFCFD))
|
.background(Color(0xFFFBFCFD))
|
||||||
.padding(18.dp),
|
.padding(18.dp),
|
||||||
) {
|
) {
|
||||||
val qr = remember(address) {
|
// While the login is pending the QR points at the
|
||||||
|
// identity provider's confirmation page instead of at
|
||||||
|
// this TV: scanning it beats typing nine digits
|
||||||
|
// against a code that lives a minute.
|
||||||
|
val target = state.welcomeLoginUrl.ifEmpty {
|
||||||
|
Pairing.pairingUrl(context, address)
|
||||||
|
}
|
||||||
|
val qr = remember(address, target) {
|
||||||
Qr.encode(
|
Qr.encode(
|
||||||
Pairing.pairingUrl(context, address),
|
target,
|
||||||
520,
|
520,
|
||||||
android.graphics.Color.parseColor("#101216"),
|
android.graphics.Color.parseColor("#101216"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Image(qr.asImageBitmap(), contentDescription = "Einrichtungs-QR", modifier = Modifier.size(230.dp))
|
Image(
|
||||||
|
qr.asImageBitmap(),
|
||||||
|
contentDescription = if (state.welcomeLoginUrl.isEmpty()) {
|
||||||
|
"Einrichtungs-QR"
|
||||||
|
} else {
|
||||||
|
"Anmelde-QR"
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(230.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
Pairing.remoteUrl(address),
|
// Both fallbacks in one line: the address for a phone
|
||||||
|
// whose camera will not scan, and the code the remote
|
||||||
|
// asks for when the QR was not what opened it.
|
||||||
|
if (state.welcomeLoginUrl.isEmpty()) {
|
||||||
|
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}"
|
||||||
|
} else {
|
||||||
|
"Scannen und bestätigen — oder Code ${state.welcomeUserCode} eingeben"
|
||||||
|
},
|
||||||
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ object UpdateChecker {
|
|||||||
|
|
||||||
private var apkUrl: String = ""
|
private var apkUrl: String = ""
|
||||||
|
|
||||||
|
/** Guards against a second click landing on the same download. */
|
||||||
|
private val busy = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||||
|
|
||||||
/** Returns the newer version tag, or null when current. Never throws. */
|
/** Returns the newer version tag, or null when current. Never throws. */
|
||||||
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
|
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -42,7 +45,7 @@ object UpdateChecker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (url.isEmpty() && tag.isNotEmpty()) url = APK_FALLBACK
|
if (url.isEmpty() && tag.isNotEmpty()) url = APK_FALLBACK
|
||||||
if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) {
|
if (url.isNotEmpty() && UpdateRules.isNewer(tag, BuildConfig.VERSION_NAME)) {
|
||||||
apkUrl = url
|
apkUrl = url
|
||||||
val version = "v$tag"
|
val version = "v$tag"
|
||||||
withContext(Dispatchers.Main) { state.updateAvailable = version }
|
withContext(Dispatchers.Main) { state.updateAvailable = version }
|
||||||
@@ -54,50 +57,71 @@ object UpdateChecker {
|
|||||||
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
|
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Downloads the APK and hands it to the package installer. */
|
/**
|
||||||
|
* Downloads the APK and hands it to the package installer.
|
||||||
|
*
|
||||||
|
* The download lands in a .part file that is only renamed once the byte
|
||||||
|
* count matches what the server announced. Handing a half-written APK to
|
||||||
|
* the installer leaves it sitting on a spinner with nothing to report,
|
||||||
|
* which is indistinguishable from a hang.
|
||||||
|
*/
|
||||||
suspend fun downloadAndInstall(context: Context, state: AppState): String? =
|
suspend fun downloadAndInstall(context: Context, state: AppState): String? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
// A second click while the first download runs would have two
|
||||||
if (apkUrl.isEmpty()) check(state)
|
// writers on one file, and the installer reads whichever half won.
|
||||||
require(apkUrl.isNotEmpty()) { "no update available" }
|
if (!busy.compareAndSet(false, true)) return@withContext "Update läuft bereits"
|
||||||
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
try {
|
||||||
val file = File(dir, "castarr-update.apk")
|
runCatching {
|
||||||
(URL(apkUrl).openConnection() as HttpURLConnection).run {
|
if (apkUrl.isEmpty()) check(state)
|
||||||
connectTimeout = 15_000
|
require(apkUrl.isNotEmpty()) { "no update available" }
|
||||||
readTimeout = 120_000
|
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||||
instanceFollowRedirects = true
|
val file = File(dir, "castarr-update.apk")
|
||||||
inputStream.use { input -> file.outputStream().use { input.copyTo(it) } }
|
val part = File(dir, "castarr-update.apk.part")
|
||||||
disconnect()
|
part.delete()
|
||||||
|
val connection = URL(apkUrl).openConnection() as HttpURLConnection
|
||||||
|
val expected = try {
|
||||||
|
connection.connectTimeout = 15_000
|
||||||
|
connection.readTimeout = 120_000
|
||||||
|
connection.instanceFollowRedirects = true
|
||||||
|
val code = connection.responseCode
|
||||||
|
require(code == HttpURLConnection.HTTP_OK) { "http $code" }
|
||||||
|
val announced = connection.contentLengthLong
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
part.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
announced
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
require(UpdateRules.isComplete(part.length(), expected)) {
|
||||||
|
"truncated: ${part.length()} of $expected"
|
||||||
|
}
|
||||||
|
file.delete()
|
||||||
|
require(part.renameTo(file)) { "rename failed" }
|
||||||
|
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
|
||||||
|
)
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
addFlags(
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
}.getOrElse {
|
||||||
|
Log.w(TAG, "install failed: ${it.javaClass.simpleName}: ${it.message}")
|
||||||
|
File(context.cacheDir, "updates/castarr-update.apk.part").delete()
|
||||||
|
"Update fehlgeschlagen — später erneut versuchen"
|
||||||
}
|
}
|
||||||
val uri = FileProvider.getUriForFile(
|
} finally {
|
||||||
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
|
busy.set(false)
|
||||||
)
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
// 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
|
|
||||||
}.getOrElse {
|
|
||||||
Log.w(TAG, "install failed: ${it.javaClass.simpleName}")
|
|
||||||
"Update fehlgeschlagen — später erneut versuchen"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isNewer(remote: String, local: String): Boolean {
|
|
||||||
fun parts(v: String) = v.split(".").mapNotNull { it.toIntOrNull() }
|
|
||||||
val r = parts(remote)
|
|
||||||
val l = parts(local)
|
|
||||||
for (i in 0 until maxOf(r.size, l.size)) {
|
|
||||||
val a = r.getOrElse(i) { 0 }
|
|
||||||
val b = l.getOrElse(i) { 0 }
|
|
||||||
if (a != b) return a > b
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun get(url: String): String {
|
private fun get(url: String): String {
|
||||||
val connection = URL(url).openConnection() as HttpURLConnection
|
val connection = URL(url).openConnection() as HttpURLConnection
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
45
app/src/main/java/dev/castarr/tv/update/UpdateRules.kt
Normal file
45
app/src/main/java/dev/castarr/tv/update/UpdateRules.kt
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package dev.castarr.tv.update
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two decisions the updater makes, without Android or a network in the
|
||||||
|
* way: is the offered release newer, and did the download arrive whole.
|
||||||
|
*
|
||||||
|
* Both shipped as bugs once — a version compare that reads "0.11.10" as
|
||||||
|
* older than "0.11.9", and a half-written APK handed to the package
|
||||||
|
* installer, which then sits on a spinner with nothing to report.
|
||||||
|
*/
|
||||||
|
object UpdateRules {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares dotted numeric versions segment by segment, missing segments
|
||||||
|
* counting as zero ("0.12" == "0.12.0"). A leading "v" is tolerated on
|
||||||
|
* either side; anything non-numeric is ignored rather than throwing,
|
||||||
|
* because a release tag is user input.
|
||||||
|
*/
|
||||||
|
fun isNewer(remote: String, local: String): Boolean {
|
||||||
|
val r = segments(remote)
|
||||||
|
val l = segments(local)
|
||||||
|
for (i in 0 until maxOf(r.size, l.size)) {
|
||||||
|
val a = r.getOrElse(i) { 0 }
|
||||||
|
val b = l.getOrElse(i) { 0 }
|
||||||
|
if (a != b) return a > b
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the bytes on disk match what the server announced.
|
||||||
|
*
|
||||||
|
* A server that announces nothing (chunked transfer, `announced <= 0`)
|
||||||
|
* cannot be checked against — then any non-empty file has to pass, which
|
||||||
|
* is the honest answer rather than a guess.
|
||||||
|
*/
|
||||||
|
fun isComplete(actualBytes: Long, announcedBytes: Long): Boolean {
|
||||||
|
if (actualBytes <= 0) return false
|
||||||
|
if (announcedBytes <= 0) return true
|
||||||
|
return actualBytes == announcedBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun segments(version: String) =
|
||||||
|
version.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() }
|
||||||
|
}
|
||||||
115
tests/demo/make-demo-data.py
Executable file
115
tests/demo/make-demo-data.py
Executable file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Neutral demo line-up for screenshots and manual testing.
|
||||||
|
|
||||||
|
Invented channels and programmes only — no real provider data ever ends up
|
||||||
|
in a screenshot. One club match is placed a few minutes ahead so the club
|
||||||
|
menu shows its "kick-off imminent" state.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--out", default=os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
p.add_argument("--kickoff-in", type=int, default=18,
|
||||||
|
help="minutes until the club match starts")
|
||||||
|
p.add_argument("--now", default=None,
|
||||||
|
help="base time as YYYY-mm-ddTHH:MM:SS; defaults to this "
|
||||||
|
"machine's clock. An emulator restored from a snapshot "
|
||||||
|
"runs on the clock it was saved with, so pass its time "
|
||||||
|
"here or every programme lands in the wrong hour")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
if args.now:
|
||||||
|
now = datetime.datetime.strptime(args.now, "%Y-%m-%dT%H:%M:%S").astimezone()
|
||||||
|
else:
|
||||||
|
now = datetime.datetime.now().astimezone()
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(dt):
|
||||||
|
# Local offset, so the emulator's clock agrees with ours (CEST vs CET).
|
||||||
|
return dt.strftime("%Y%m%d%H%M%S %z")
|
||||||
|
|
||||||
|
|
||||||
|
CHANNELS = [
|
||||||
|
("Unterhaltung", "Blau TV HD", "blau"),
|
||||||
|
("Unterhaltung", "Nordlicht HD", "nordlicht"),
|
||||||
|
("Unterhaltung", "Kanal Sieben", "sieben"),
|
||||||
|
("Filme", "Kinohaus HD", "kinohaus"),
|
||||||
|
("Filme", "Filmwerk Classic", "filmwerk"),
|
||||||
|
("Filme", "Nachtkino HD", "nachtkino"),
|
||||||
|
("Serien", "Serienbox HD", "serienbox"),
|
||||||
|
("Serien", "Staffel Eins", "staffel"),
|
||||||
|
("Sport", "Arena Sport 1 HD", "arena1"),
|
||||||
|
("Sport", "Arena Sport 2 HD", "arena2"),
|
||||||
|
("Sport", "Liga Live UHD", "ligalive"),
|
||||||
|
("Sport", "Stadionkanal HD", "stadion"),
|
||||||
|
("Nachrichten", "Tagesblick HD", "tagesblick"),
|
||||||
|
("Nachrichten", "Weltbericht", "weltbericht"),
|
||||||
|
("Doku & Wissen", "Terra Doku HD", "terra"),
|
||||||
|
("Doku & Wissen", "Wissenswelt", "wissen"),
|
||||||
|
("Kinder", "Krümelkiste", "kruemel"),
|
||||||
|
("Musik", "Tonspur HD", "tonspur"),
|
||||||
|
("Regional", "Hafenkanal HD", "hafen"),
|
||||||
|
("International", "Globus TV", "globus"),
|
||||||
|
]
|
||||||
|
|
||||||
|
PROGRAMMES = {
|
||||||
|
"blau": [("Morgenmagazin", -40, 80), ("Kochduell", 40, 60)],
|
||||||
|
"nordlicht": [("Küstenrevier", -20, 70), ("Nordlicht Talk", 50, 45)],
|
||||||
|
"sieben": [("Quiz um Sieben", -10, 60), ("Sieben Reportage", 50, 45)],
|
||||||
|
"kinohaus": [("Der weite Weg", -55, 125), ("Sturmhöhe", 70, 110)],
|
||||||
|
"filmwerk": [("Schwarzweiß", -30, 95), ("Die Erbschaft", 65, 100)],
|
||||||
|
"nachtkino": [("Mitternachtszug", -15, 105), ("Letzte Runde", 90, 95)],
|
||||||
|
"serienbox": [("Praxis Ostwind (3/12)", -25, 45), ("Praxis Ostwind (4/12)", 20, 45)],
|
||||||
|
"staffel": [("Hafenwache (7/10)", -35, 50), ("Hafenwache (8/10)", 15, 50)],
|
||||||
|
"arena1": [("Handball: Pokalrunde", -45, 105), ("Sport am Abend", 60, 60)],
|
||||||
|
"arena2": [("Radsport: Etappe 4", -50, 130), ("Motorsport kompakt", 80, 55)],
|
||||||
|
"ligalive": [("Sport aktuell", -25, 43), ("__CLUB__", None, 120)],
|
||||||
|
"stadion": [("Stadionmagazin", -20, 60), ("FC St. Pauli: Der Rückblick", 40, 45)],
|
||||||
|
"tagesblick": [("Tagesblick am Mittag", -12, 30), ("Wetter und Verkehr", 18, 15)],
|
||||||
|
"weltbericht": [("Weltbericht Spezial", -35, 75), ("Auslandsjournal", 40, 45)],
|
||||||
|
"terra": [("Wale der Arktis", -40, 90), ("Vulkane Islands", 50, 45)],
|
||||||
|
"wissen": [("Wie Brücken halten", -20, 45), ("Chemie des Alltags", 25, 45)],
|
||||||
|
"kruemel": [("Bärenbande", -15, 25), ("Malstunde", 10, 25)],
|
||||||
|
"tonspur": [("Charts der Woche", -60, 120), ("Akustik-Session", 60, 60)],
|
||||||
|
"hafen": [("Hafenrundfahrt", -30, 60), ("Regional um sechs", 30, 30)],
|
||||||
|
"globus": [("Globus Reportage", -45, 90), ("Fernweh", 45, 60)],
|
||||||
|
}
|
||||||
|
|
||||||
|
CLUB_TITLE = "Pokal: Nordstadt - FC St. Pauli, 1. Runde"
|
||||||
|
|
||||||
|
os.makedirs(args.out, exist_ok=True)
|
||||||
|
m3u = os.path.join(args.out, "playlist.m3u")
|
||||||
|
xml = os.path.join(args.out, "epg.xml")
|
||||||
|
|
||||||
|
with open(m3u, "w", encoding="utf-8") as f:
|
||||||
|
f.write("#EXTM3U\n")
|
||||||
|
for group, name, cid in CHANNELS:
|
||||||
|
f.write(f'#EXTINF:-1 tvg-id="{cid}" group-title="{group}",{name}\n')
|
||||||
|
f.write(f"http://10.0.2.2:8099/stream/{cid}.ts\n")
|
||||||
|
|
||||||
|
|
||||||
|
def escape(text):
|
||||||
|
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
with open(xml, "w", encoding="utf-8") as f:
|
||||||
|
f.write('<?xml version="1.0" encoding="UTF-8"?>\n<tv>\n')
|
||||||
|
for _, name, cid in CHANNELS:
|
||||||
|
f.write(f' <channel id="{cid}"><display-name>{escape(name)}</display-name></channel>\n')
|
||||||
|
for cid, items in PROGRAMMES.items():
|
||||||
|
for title, offset, length in items:
|
||||||
|
if title == "__CLUB__":
|
||||||
|
title, offset = CLUB_TITLE, args.kickoff_in
|
||||||
|
start = now + datetime.timedelta(minutes=offset)
|
||||||
|
stop = start + datetime.timedelta(minutes=length)
|
||||||
|
f.write(
|
||||||
|
f' <programme start="{fmt(start)}" stop="{fmt(stop)}" channel="{cid}">'
|
||||||
|
f"<title>{escape(title)}</title></programme>\n"
|
||||||
|
)
|
||||||
|
f.write("</tv>\n")
|
||||||
|
|
||||||
|
print(f"{len(CHANNELS)} Sender, {sum(len(v) for v in PROGRAMMES.values())} Sendungen")
|
||||||
|
print(f"Anstoß in {args.kickoff_in} Min: {CLUB_TITLE}")
|
||||||
|
print(f"-> {m3u}\n-> {xml}")
|
||||||
78
tests/dispatcharr-checkliste.md
Normal file
78
tests/dispatcharr-checkliste.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Dispatcharr-Pfad: Ablauf vor einem Release
|
||||||
|
|
||||||
|
Was hier steht, deckt kein Test ab: Anmeldung, Backend-Abruf und alles, was
|
||||||
|
ein echtes Konto braucht. `tests/smoke.sh` hilft dabei nicht — er
|
||||||
|
deinstalliert die App und damit die Anmeldung.
|
||||||
|
|
||||||
|
Dauer: etwa zehn Minuten. Am Emulator oder auf einem echten Fernseher.
|
||||||
|
|
||||||
|
## Vorbereitet
|
||||||
|
|
||||||
|
- Emulator läuft (`tests/helpers/emulator.sh start`)
|
||||||
|
- Aktuelles APK installiert
|
||||||
|
- Jemand ist erreichbar, der die Anmeldung im Identity Provider bestätigen
|
||||||
|
kann — der Gerätecode lebt derzeit 60 Sekunden (siehe Issue #23)
|
||||||
|
|
||||||
|
## 1. Anmelden
|
||||||
|
|
||||||
|
- [ ] App zurücksetzen: `adb shell pm clear dev.castarr.tv`
|
||||||
|
- [ ] App starten — der Willkommensbildschirm zeigt QR, Adresse **und** den
|
||||||
|
vierstelligen Kopplungscode
|
||||||
|
- [ ] Fernbedienung am Handy öffnen, Server eintragen
|
||||||
|
- [ ] Der Fernseher zeigt „Anmeldung am Handy bestätigen" mit Code
|
||||||
|
- [ ] Code bestätigen; der Fernseher wechselt von allein in die Senderliste
|
||||||
|
- [ ] Läuft der Code ab, holt der Fernseher selbstständig einen neuen —
|
||||||
|
er darf **nicht** kommentarlos auf Schritt 1 zurückfallen
|
||||||
|
|
||||||
|
## 2. Senderliste
|
||||||
|
|
||||||
|
- [ ] Die Zahl neben „Alle Sender" entspricht der Senderzahl im Backend
|
||||||
|
- [ ] Sender tragen Logos und Namen aus dem Backend
|
||||||
|
- [ ] Sender mit Programm zeigen es rechts, Sender ohne zeigen ihre Gruppe
|
||||||
|
- [ ] Die Kopfleiste zeigt **keinen** Hinweis „Server nicht erreichbar"
|
||||||
|
- [ ] Sendertasten springen zum nächsten Anfangsbuchstaben
|
||||||
|
- [ ] Rechts auf einer Senderzeile öffnet den Tagesplan; keine Sendung steht
|
||||||
|
doppelt drin, höchstens eine ist als „läuft" markiert
|
||||||
|
|
||||||
|
## 3. Favoriten (die Stelle, an der es zuletzt still kaputt war)
|
||||||
|
|
||||||
|
- [ ] „★ Favoriten" zeigt die Zahl, die das Backend kennt — nicht 0
|
||||||
|
- [ ] Langer Druck auf einen Sender setzt den Stern, die Zahl steigt sofort
|
||||||
|
- [ ] Erneuter langer Druck entfernt ihn, die Zahl sinkt
|
||||||
|
- [ ] App neu starten: die Favoriten sind noch da (sie kommen vom Backend)
|
||||||
|
- [ ] Am Handy: derselbe Stern, dieselbe Zahl
|
||||||
|
|
||||||
|
## 4. Vereinsmenüs
|
||||||
|
|
||||||
|
- [ ] Für einen Verein mit Spiel heute steht ein Menü mit Kurznamen in der
|
||||||
|
Leiste („Stuttgart", nicht „1893")
|
||||||
|
- [ ] Läuft ein Spiel, steht „läuft" im Chip, sonst „ab HH:MM"
|
||||||
|
- [ ] In der Vereinsansicht steht die Paarung, nicht der EPG-Rohtitel —
|
||||||
|
und der Vereinsname ist vollständig zu lesen
|
||||||
|
- [ ] Ein Titel ohne Paarung („Vereinsprofil VfB Stuttgart") wird unverändert
|
||||||
|
gezeigt, nicht zu einer Begegnung verbogen
|
||||||
|
|
||||||
|
## 5. Wiedergabe und Profile
|
||||||
|
|
||||||
|
- [ ] Ein Sender startet und zeigt Bild
|
||||||
|
- [ ] Sendertasten hoch/runter wechseln den Sender
|
||||||
|
- [ ] Die Sender-Zurück-Taste springt zum vorherigen Sender
|
||||||
|
- [ ] Einstellungen → Stream-Qualität listet die Profile des Backends
|
||||||
|
- [ ] Ein anderes Profil auswählen, Sender neu starten — er läuft weiter
|
||||||
|
|
||||||
|
## 6. Abmelden
|
||||||
|
|
||||||
|
- [ ] Einstellungen → Abmelden führt zurück zum Willkommensbildschirm
|
||||||
|
- [ ] Nach erneutem Start bleibt der Willkommensbildschirm stehen
|
||||||
|
(keine Senderliste aus dem Zwischenspeicher)
|
||||||
|
|
||||||
|
## Wenn etwas hakt
|
||||||
|
|
||||||
|
Logcat mitlesen:
|
||||||
|
|
||||||
|
```
|
||||||
|
adb -s emulator-5554 logcat -s DispatcharrRepo DeviceAuth Onboarding
|
||||||
|
```
|
||||||
|
|
||||||
|
Fehler beim Abruf tauchen dort auf; ein stiller Fehlschlag ohne Logzeile ist
|
||||||
|
selbst ein Befund und gehört als Issue aufgeschrieben.
|
||||||
70
tests/helpers/emulator.sh
Executable file
70
tests/helpers/emulator.sh
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Headless Google-TV emulator for test runs.
|
||||||
|
#
|
||||||
|
# The AVD keeps a "default_boot" snapshot, so a start restores a booted
|
||||||
|
# system in seconds instead of cold-booting for minutes. "stop" refreshes
|
||||||
|
# that snapshot before killing, so the next start stays fast.
|
||||||
|
#
|
||||||
|
# tests/helpers/emulator.sh start # boot (or restore) and wait
|
||||||
|
# tests/helpers/emulator.sh stop # snapshot, then kill
|
||||||
|
# tests/helpers/emulator.sh status
|
||||||
|
# tests/helpers/emulator.sh install <apk>
|
||||||
|
# tests/helpers/emulator.sh shot <name> # screenshot into tests/runs/screenshots
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
AVD="${CASTARR_AVD:-castarr-googletv}"
|
||||||
|
SERIAL="${CASTARR_SERIAL:-emulator-5554}"
|
||||||
|
SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
|
||||||
|
ADB="$SDK/platform-tools/adb"
|
||||||
|
EMULATOR="$SDK/emulator/emulator"
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
RUNS="$ROOT/tests/runs"
|
||||||
|
LOG="$RUNS/emulator.log"
|
||||||
|
|
||||||
|
booted() { [ "$("$ADB" -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; }
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if booted; then echo "already up: $SERIAL"; return 0; fi
|
||||||
|
mkdir -p "$RUNS"
|
||||||
|
# No -no-snapshot here: that is what makes a start slow.
|
||||||
|
nohup "$EMULATOR" -avd "$AVD" \
|
||||||
|
-no-window -no-audio -no-boot-anim \
|
||||||
|
-gpu swiftshader_indirect \
|
||||||
|
>"$LOG" 2>&1 &
|
||||||
|
local start_ts=$SECONDS
|
||||||
|
"$ADB" -s "$SERIAL" wait-for-device
|
||||||
|
for _ in $(seq 1 180); do
|
||||||
|
booted && { echo "up after $((SECONDS - start_ts))s"; return 0; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "timeout waiting for boot; see $LOG" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if ! pgrep -f "qemu-system-.*-avd $AVD" >/dev/null 2>&1 && ! booted; then
|
||||||
|
echo "not running"; return 0
|
||||||
|
fi
|
||||||
|
# Refresh the snapshot so the next start is a restore, not a cold boot.
|
||||||
|
"$ADB" -s "$SERIAL" emu avd snapshot save default_boot
|
||||||
|
"$ADB" -s "$SERIAL" emu kill
|
||||||
|
echo "stopped, snapshot saved"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
start) start ;;
|
||||||
|
stop) stop ;;
|
||||||
|
status) booted && echo "up: $SERIAL" || echo "down" ;;
|
||||||
|
install)
|
||||||
|
[ $# -ge 2 ] || { echo "usage: $0 install <apk>" >&2; exit 2; }
|
||||||
|
"$ADB" -s "$SERIAL" install -r "$2"
|
||||||
|
;;
|
||||||
|
shot)
|
||||||
|
name="${2:-shot}"
|
||||||
|
mkdir -p "$RUNS/screenshots"
|
||||||
|
"$ADB" -s "$SERIAL" exec-out screencap -p > "$RUNS/screenshots/$name.png"
|
||||||
|
echo "$RUNS/screenshots/$name.png"
|
||||||
|
;;
|
||||||
|
*) echo "usage: $0 {start|stop|status|install <apk>|shot <name>}" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
38
tests/helpers/jdk.sh
Executable file
38
tests/helpers/jdk.sh
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Picks a JDK that Gradle can actually run on and exports JAVA_HOME.
|
||||||
|
#
|
||||||
|
# Ubuntu moved default-java to 25, which the Gradle version in this repo
|
||||||
|
# refuses with a bare "IllegalArgumentException: 25.0.4" — an unhelpful
|
||||||
|
# message for a build that worked yesterday. Sourced by the scripts under
|
||||||
|
# tests/ and tools/; harmless when JAVA_HOME is already a supported JDK.
|
||||||
|
#
|
||||||
|
# . tests/helpers/jdk.sh
|
||||||
|
set -u
|
||||||
|
|
||||||
|
_jdk_major() {
|
||||||
|
"$1/bin/java" -version 2>&1 | head -1 |
|
||||||
|
sed -E 's/.*version "([0-9]+).*/\1/'
|
||||||
|
}
|
||||||
|
|
||||||
|
_jdk_pick() {
|
||||||
|
local candidate major
|
||||||
|
if [ -n "${JAVA_HOME:-}" ] && [ -x "${JAVA_HOME}/bin/java" ]; then
|
||||||
|
major="$(_jdk_major "$JAVA_HOME")"
|
||||||
|
if [ "$major" -ge 17 ] 2>/dev/null && [ "$major" -le 21 ] 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
for candidate in \
|
||||||
|
/usr/lib/jvm/java-21-openjdk-amd64 \
|
||||||
|
/usr/lib/jvm/java-17-openjdk-amd64 \
|
||||||
|
"$HOME"/jdk-21* \
|
||||||
|
"$HOME"/jdk-17*; do
|
||||||
|
[ -x "$candidate/bin/java" ] || continue
|
||||||
|
export JAVA_HOME="$candidate"
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
echo "no JDK 17-21 found; Gradle cannot run on $(java -version 2>&1 | head -1)" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_jdk_pick
|
||||||
44
tests/onboarding-beobachtung.md
Normal file
44
tests/onboarding-beobachtung.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Onboarding: Beobachtungsbogen
|
||||||
|
|
||||||
|
Für Issue #21. Eine Person, die Castarr nicht kennt, richtet den Fernseher
|
||||||
|
ein. Du sitzt daneben und **sagst nichts** — auch nicht, wenn es weh tut.
|
||||||
|
Notieren, nicht helfen. Erst wenn jemand endgültig feststeckt, abbrechen und
|
||||||
|
den Punkt aufschreiben.
|
||||||
|
|
||||||
|
## Vorbereitung (vor dem Termin, ohne Publikum)
|
||||||
|
|
||||||
|
- [ ] Aktuelles Release auf dem Fernseher installiert
|
||||||
|
- [ ] App zurückgesetzt, sodass der Willkommensbildschirm kommt
|
||||||
|
- [ ] Jemand ist erreichbar, der die Anmeldung im Identity Provider
|
||||||
|
bestätigen kann — oder die testende Person hat selbst ein Konto
|
||||||
|
- [ ] Handy der testenden Person ist im selben WLAN
|
||||||
|
- [ ] Uhrzeit notieren, wenn es losgeht
|
||||||
|
|
||||||
|
## Was zu notieren ist
|
||||||
|
|
||||||
|
Keine Bewertung, nur was passiert ist.
|
||||||
|
|
||||||
|
| Schritt | Beobachtung |
|
||||||
|
|---|---|
|
||||||
|
| QR entdeckt? | scannt / tippt Adresse ab / sucht Anleitung |
|
||||||
|
| Kamera-App oder Handy-Browser? | |
|
||||||
|
| Erste Reaktion auf „Server angeben" | |
|
||||||
|
| Was wurde eingetippt? | |
|
||||||
|
| Anmeldung: Code rechtzeitig bestätigt? | ja / abgelaufen (wie oft) |
|
||||||
|
| Hat der Hinweis „Der alte Code war abgelaufen" geholfen? | |
|
||||||
|
| Wo wurde gezögert (länger als ~10 s)? | |
|
||||||
|
| Wo wurde laut gefragt? | |
|
||||||
|
| Ende: Senderliste erreicht? | ja / nein, hängen geblieben bei … |
|
||||||
|
| Gesamtdauer | |
|
||||||
|
|
||||||
|
## Anschließend fragen
|
||||||
|
|
||||||
|
- Was war der unklarste Moment?
|
||||||
|
- Was hättest du erwartet, das nicht passiert ist?
|
||||||
|
- Würdest du das noch mal allein machen?
|
||||||
|
|
||||||
|
## Was daraus wird
|
||||||
|
|
||||||
|
Jede Zeile mit einer Beobachtung, die nicht „lief glatt" heißt, wird ein
|
||||||
|
Issue. Erst wenn jemand ohne Zuruf bei der Senderliste landet, ist #21
|
||||||
|
erledigt — beim ersten Versuch, nicht beim dritten.
|
||||||
172
tests/smoke.sh
Executable file
172
tests/smoke.sh
Executable file
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Smoke test on the headless Google TV emulator.
|
||||||
|
#
|
||||||
|
# Builds the debug APK, seeds the demo playlist as its source, installs it
|
||||||
|
# and walks from the channel list into the player with the D-pad. Fails on
|
||||||
|
# anything the unit tests cannot see: a crash on startup, a crash while
|
||||||
|
# navigating, an ANR, or a channel list that stayed empty. The 0.11.0
|
||||||
|
# startup crash and the rail crash on "Verein hinzufügen" would both have
|
||||||
|
# been caught here.
|
||||||
|
#
|
||||||
|
# tests/smoke.sh # build, run, leave the emulator up
|
||||||
|
# tests/smoke.sh --apk <path> # skip the build, test this APK
|
||||||
|
# tests/smoke.sh --stop # stop the emulator when done
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
HELPERS="$ROOT/tests/helpers"
|
||||||
|
RUNS="$ROOT/tests/runs"
|
||||||
|
SERIAL="${CASTARR_SERIAL:-emulator-5554}"
|
||||||
|
ADB="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}/platform-tools/adb"
|
||||||
|
PKG="dev.castarr.tv"
|
||||||
|
DEMO_PORT="${CASTARR_DEMO_PORT:-8099}"
|
||||||
|
ACTIVITY="$PKG/.MainActivity"
|
||||||
|
|
||||||
|
APK=""
|
||||||
|
STOP_AFTER=0
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--apk) APK="$2"; shift 2 ;;
|
||||||
|
--stop) STOP_AFTER=1; shift ;;
|
||||||
|
*) echo "usage: $0 [--apk <path>] [--stop]" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p "$RUNS/screenshots"
|
||||||
|
LOG="$RUNS/smoke.log"
|
||||||
|
: > "$LOG"
|
||||||
|
# The player retries the fake demo stream for as long as it is on screen, so
|
||||||
|
# without this the request log grows by a few hundred KB per run.
|
||||||
|
: > "$RUNS/http.log"
|
||||||
|
|
||||||
|
step() { printf '\n== %s\n' "$1" | tee -a "$LOG"; }
|
||||||
|
fail() { printf '\nFAIL: %s\n' "$1" | tee -a "$LOG" >&2; exit 1; }
|
||||||
|
|
||||||
|
if [ -z "$APK" ]; then
|
||||||
|
step "Debug-APK bauen"
|
||||||
|
# shellcheck source=tests/helpers/jdk.sh
|
||||||
|
. "$HELPERS/jdk.sh"
|
||||||
|
(cd "$ROOT" && ./gradlew assembleDebug --no-daemon -q) >>"$LOG" 2>&1 ||
|
||||||
|
fail "Build fehlgeschlagen, siehe $LOG"
|
||||||
|
APK="$ROOT/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
|
fi
|
||||||
|
[ -f "$APK" ] || fail "APK nicht gefunden: $APK"
|
||||||
|
|
||||||
|
step "Emulator starten"
|
||||||
|
"$HELPERS/emulator.sh" start | tee -a "$LOG"
|
||||||
|
|
||||||
|
step "Demo-Quelle bereitstellen"
|
||||||
|
# The emulator restores its snapshot with the clock frozen at save time, so
|
||||||
|
# the demo programme has to be built around *its* idea of now, not ours.
|
||||||
|
EMU_NOW="$("$ADB" -s "$SERIAL" shell date +%Y-%m-%dT%H:%M:%S | tr -d '\r')"
|
||||||
|
echo " Emulator-Uhr: $EMU_NOW" | tee -a "$LOG"
|
||||||
|
python3 "$ROOT/tests/demo/make-demo-data.py" --now "$EMU_NOW" >>"$LOG" 2>&1 ||
|
||||||
|
fail "Demo-Daten fehlgeschlagen"
|
||||||
|
# The emulator reaches the host at 10.0.2.2, which is the address baked into
|
||||||
|
# the demo playlist.
|
||||||
|
# A leftover server from an earlier run happily answers on this port while
|
||||||
|
# serving a directory that no longer exists — every request a 404, and the
|
||||||
|
# wait below would spin forever.
|
||||||
|
if ss -ltn "sport = :$DEMO_PORT" 2>/dev/null | grep -q LISTEN; then
|
||||||
|
fail "Port $DEMO_PORT ist belegt: $(ss -ltnp "sport = :$DEMO_PORT" 2>/dev/null | tail -1)"
|
||||||
|
fi
|
||||||
|
# 10.0.2.2 inside the emulator is the host loopback, so binding there is enough.
|
||||||
|
(cd "$ROOT/tests/demo" && exec python3 -m http.server "$DEMO_PORT" --bind 127.0.0.1) \
|
||||||
|
>>"$RUNS/http.log" 2>&1 &
|
||||||
|
DEMO_PID=$!
|
||||||
|
trap 'kill "$DEMO_PID" 2>/dev/null || true' EXIT
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
curl -sf "http://127.0.0.1:$DEMO_PORT/playlist.m3u" -o /dev/null && break
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
curl -sf "http://127.0.0.1:$DEMO_PORT/playlist.m3u" -o /dev/null ||
|
||||||
|
fail "Demo-Server antwortet nicht auf Port $DEMO_PORT"
|
||||||
|
|
||||||
|
step "Installieren"
|
||||||
|
# A debug build over a signed release needs the old one gone first.
|
||||||
|
"$ADB" -s "$SERIAL" uninstall "$PKG" >/dev/null 2>&1 || true
|
||||||
|
"$ADB" -s "$SERIAL" install -r "$APK" >>"$LOG" 2>&1 || fail "Installation fehlgeschlagen"
|
||||||
|
|
||||||
|
# Onboarding runs on a phone (ADR-0007), which a test has no way to be, so
|
||||||
|
# the source is seeded straight into the preferences the first run reads.
|
||||||
|
# Only a debug build allows this, which is the build under test.
|
||||||
|
"$ADB" -s "$SERIAL" shell run-as "$PKG" sh -c "'mkdir -p shared_prefs && cat > shared_prefs/source.xml'" <<XML || fail "Quelle konnte nicht gesetzt werden"
|
||||||
|
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
|
||||||
|
<map>
|
||||||
|
<string name="m3u_url">http://10.0.2.2:$DEMO_PORT/playlist.m3u</string>
|
||||||
|
<string name="epg_url">http://10.0.2.2:$DEMO_PORT/epg.xml</string>
|
||||||
|
</map>
|
||||||
|
XML
|
||||||
|
|
||||||
|
step "Starten"
|
||||||
|
"$ADB" -s "$SERIAL" logcat -c
|
||||||
|
"$ADB" -s "$SERIAL" shell am start -W -n "$ACTIVITY" >>"$LOG" 2>&1 ||
|
||||||
|
fail "am start fehlgeschlagen"
|
||||||
|
|
||||||
|
# The first frame is not the point — the crash usually lands a moment later,
|
||||||
|
# when state loads.
|
||||||
|
sleep 6
|
||||||
|
|
||||||
|
running() { [ -n "$("$ADB" -s "$SERIAL" shell pidof "$PKG" | tr -d '\r')" ]; }
|
||||||
|
crashed() {
|
||||||
|
"$ADB" -s "$SERIAL" logcat -d -b crash,main 2>/dev/null |
|
||||||
|
grep -E "FATAL EXCEPTION|ANR in $PKG|Process $PKG .* has died" | head -20
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local where="$1" trace
|
||||||
|
trace="$(crashed || true)"
|
||||||
|
if [ -n "$trace" ]; then
|
||||||
|
printf '%s\n' "$trace" >>"$LOG"
|
||||||
|
printf '%s\n' "$trace" | head -5
|
||||||
|
fail "Absturz $where — vollständig in $LOG"
|
||||||
|
fi
|
||||||
|
running || fail "Prozess weg $where (kein Stacktrace im Log)"
|
||||||
|
}
|
||||||
|
|
||||||
|
check "beim Start"
|
||||||
|
"$HELPERS/emulator.sh" shot smoke-start >/dev/null
|
||||||
|
|
||||||
|
# Reaching the channel list is the point: it is the screen the demo source
|
||||||
|
# feeds, and a blank one means the playlist never arrived.
|
||||||
|
step "Senderliste prüfen"
|
||||||
|
DUMP="$RUNS/ui-dump.xml"
|
||||||
|
"$ADB" -s "$SERIAL" shell uiautomator dump /sdcard/ui.xml >>"$LOG" 2>&1 || true
|
||||||
|
"$ADB" -s "$SERIAL" pull /sdcard/ui.xml "$DUMP" >>"$LOG" 2>&1 || true
|
||||||
|
if grep -q "Blau TV HD" "$DUMP" 2>/dev/null; then
|
||||||
|
echo " Sender aus der Demo-Playlist sichtbar"
|
||||||
|
else
|
||||||
|
fail "Senderliste zeigt die Demo-Playlist nicht — Dump in $DUMP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "D-Pad-Navigation"
|
||||||
|
# Down/right walks the rail and opens whatever has focus; back returns.
|
||||||
|
for key in DPAD_DOWN DPAD_RIGHT DPAD_RIGHT DPAD_DOWN DPAD_CENTER BACK DPAD_UP; do
|
||||||
|
"$ADB" -s "$SERIAL" shell input keyevent "$key"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
sleep 2
|
||||||
|
check "bei der Navigation"
|
||||||
|
"$HELPERS/emulator.sh" shot smoke-nav >/dev/null
|
||||||
|
|
||||||
|
step "Wiedereintritt"
|
||||||
|
"$ADB" -s "$SERIAL" shell am start -n "$ACTIVITY" >/dev/null 2>&1
|
||||||
|
sleep 2
|
||||||
|
check "nach dem Wiedereintritt"
|
||||||
|
|
||||||
|
if [ "$STOP_AFTER" = 1 ]; then
|
||||||
|
step "Emulator stoppen"
|
||||||
|
"$HELPERS/emulator.sh" stop | tee -a "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
OK — kein Absturz, App läuft.
|
||||||
|
Screenshots: $RUNS/screenshots/smoke-start.png, smoke-nav.png
|
||||||
|
Log: $LOG
|
||||||
|
|
||||||
|
Abgedeckt: Start mit gesetzter Quelle, Senderliste aus der Demo-Playlist,
|
||||||
|
D-Pad bis in den Player, Wiedereintritt. Nicht abgedeckt: echte Wiedergabe
|
||||||
|
(die Demo-Streams sind Attrappen), Onboarding am Handy, Kopplung und alles,
|
||||||
|
was ein Dispatcharr-Backend braucht.
|
||||||
|
EOF
|
||||||
101
tests/unit/AttemptBudgetTest.kt
Normal file
101
tests/unit/AttemptBudgetTest.kt
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package dev.castarr.tv.server
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AttemptBudgetTest {
|
||||||
|
|
||||||
|
private var clock = 0L
|
||||||
|
private fun budget(
|
||||||
|
windowMs: Long = 60_000L,
|
||||||
|
maxFailures: Int = 5,
|
||||||
|
maxTracked: Int = 64,
|
||||||
|
) = AttemptBudget(windowMs, maxFailures, maxTracked) { clock }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 0.11.0 bug: the remote reconnects on every network hiccup, and
|
||||||
|
* counting those successful handshakes threw the phone out after five.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `successful connections never cost budget`() {
|
||||||
|
val budget = budget()
|
||||||
|
repeat(50) {
|
||||||
|
assertTrue(budget.allows("10.0.0.5"))
|
||||||
|
budget.clear("10.0.0.5")
|
||||||
|
}
|
||||||
|
assertTrue(budget.allows("10.0.0.5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `five failures in the window lock the address out`() {
|
||||||
|
val budget = budget()
|
||||||
|
repeat(5) {
|
||||||
|
assertTrue(budget.allows("10.0.0.5"))
|
||||||
|
budget.recordFailure("10.0.0.5")
|
||||||
|
}
|
||||||
|
assertFalse(budget.allows("10.0.0.5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a success after failures wipes the slate`() {
|
||||||
|
val budget = budget()
|
||||||
|
repeat(4) { budget.recordFailure("10.0.0.5") }
|
||||||
|
budget.clear("10.0.0.5")
|
||||||
|
repeat(4) {
|
||||||
|
assertTrue(budget.allows("10.0.0.5"))
|
||||||
|
budget.recordFailure("10.0.0.5")
|
||||||
|
}
|
||||||
|
assertTrue(budget.allows("10.0.0.5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `failures expire once the window has passed`() {
|
||||||
|
val budget = budget()
|
||||||
|
repeat(5) { budget.recordFailure("10.0.0.5") }
|
||||||
|
assertFalse(budget.allows("10.0.0.5"))
|
||||||
|
clock += 60_001
|
||||||
|
assertTrue(budget.allows("10.0.0.5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the window slides, it does not reset in blocks`() {
|
||||||
|
val budget = budget()
|
||||||
|
repeat(4) {
|
||||||
|
budget.recordFailure("10.0.0.5")
|
||||||
|
clock += 20_000
|
||||||
|
}
|
||||||
|
// Two of the four are older than a minute by now, so there is room.
|
||||||
|
assertTrue(budget.allows("10.0.0.5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one address cannot spend another's budget`() {
|
||||||
|
val budget = budget()
|
||||||
|
repeat(5) { budget.recordFailure("10.0.0.9") }
|
||||||
|
assertFalse(budget.allows("10.0.0.9"))
|
||||||
|
assertTrue(budget.allows("10.0.0.5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An attacker cycling source addresses must not grow the map forever. */
|
||||||
|
@Test
|
||||||
|
fun `tracked addresses stay bounded under a flood of fresh ones`() {
|
||||||
|
val budget = budget(maxTracked = 8)
|
||||||
|
repeat(500) { i ->
|
||||||
|
budget.recordFailure("10.0.0.$i")
|
||||||
|
clock += 10
|
||||||
|
}
|
||||||
|
assertTrue(budget.trackedAddresses() <= 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `eviction drops the stale addresses, not the active one`() {
|
||||||
|
val budget = budget(maxTracked = 4)
|
||||||
|
repeat(4) { i -> budget.recordFailure("10.0.1.$i") }
|
||||||
|
clock += 60_001
|
||||||
|
repeat(5) { budget.recordFailure("10.0.0.5") }
|
||||||
|
assertFalse(budget.allows("10.0.0.5"))
|
||||||
|
assertEquals(1, budget.trackedAddresses())
|
||||||
|
}
|
||||||
|
}
|
||||||
47
tests/unit/CrashLogTest.kt
Normal file
47
tests/unit/CrashLogTest.kt
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package dev.castarr.tv
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CrashLogTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an entry names the time, the version and the top of the stack`() {
|
||||||
|
val error = IllegalStateException("Senderliste leer")
|
||||||
|
// 26.08.2026, 19:12 (MESZ)
|
||||||
|
val entry = CrashLog.render(1787764320000L, "0.12.0", error)
|
||||||
|
val lines = entry.lines()
|
||||||
|
assertTrue(lines.first().contains("0.12.0"))
|
||||||
|
assertTrue(lines.first().contains("26.08.2026"))
|
||||||
|
assertTrue(entry.contains("IllegalStateException"))
|
||||||
|
assertTrue(entry.contains("Senderliste leer"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a long stack is cut down to something readable`() {
|
||||||
|
val error = RuntimeException("tief")
|
||||||
|
val entry = CrashLog.render(0L, "0.12.0", error)
|
||||||
|
// Kopfzeile plus höchstens TRACE_LINES Zeilen Stack.
|
||||||
|
assertTrue(entry.lines().size <= CrashLog.TRACE_LINES + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only the newest entries are kept`() {
|
||||||
|
val entries = (1..15).map { "Absturz $it" }
|
||||||
|
val kept = CrashLog.trim(entries)
|
||||||
|
assertEquals(CrashLog.KEEP, kept.size)
|
||||||
|
assertEquals("Absturz 15", kept.last())
|
||||||
|
assertEquals("Absturz 6", kept.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blank entries are dropped instead of counting`() {
|
||||||
|
assertEquals(listOf("echt"), CrashLog.trim(listOf("", " ", "echt")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing recorded stays nothing`() {
|
||||||
|
assertEquals(emptyList<String>(), CrashLog.trim(emptyList()))
|
||||||
|
}
|
||||||
|
}
|
||||||
145
tests/unit/DispatcharrJsonTest.kt
Normal file
145
tests/unit/DispatcharrJsonTest.kt
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shapes taken from the household's own Dispatcharr fork. The backend path
|
||||||
|
* had no coverage at all, and the first run against a real instance found
|
||||||
|
* three bugs — these are the shapes that produced them.
|
||||||
|
*/
|
||||||
|
class DispatcharrJsonTest {
|
||||||
|
|
||||||
|
private fun streamUrl(uuid: String) = "https://tv.example/proxy/ts/stream/$uuid"
|
||||||
|
private fun logoUrl(id: Int) = "https://tv.example/api/channels/logos/$id/cache/"
|
||||||
|
|
||||||
|
private val groups = mapOf(1 to "Free TV / HD+", 2 to "Sky Sport")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a paginated page yields its channels`() {
|
||||||
|
val body = """
|
||||||
|
{"count": 2, "next": null, "results": [
|
||||||
|
{"id": 12, "uuid": "aaa", "name": "Das Erste", "effective_name": "Das Erste HD",
|
||||||
|
"channel_group_id": 1, "effective_logo_id": 7, "effective_tvg_id": "ard.de"},
|
||||||
|
{"id": 13, "uuid": "bbb", "name": "ZDF HD", "channel_group_id": 1, "logo_id": 8}
|
||||||
|
]}
|
||||||
|
""".trimIndent()
|
||||||
|
val channels = DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl)
|
||||||
|
assertEquals(2, channels.size)
|
||||||
|
val first = channels.first()
|
||||||
|
assertEquals("Das Erste HD", first.name)
|
||||||
|
assertEquals("Free TV / HD+", first.group)
|
||||||
|
assertEquals("ard.de", first.tvgId)
|
||||||
|
assertEquals(12, first.backendId)
|
||||||
|
assertEquals("https://tv.example/proxy/ts/stream/aaa", first.url)
|
||||||
|
assertEquals("https://tv.example/api/channels/logos/7/cache/", first.logo)
|
||||||
|
// Without an effective name the plain one is used, same for the logo.
|
||||||
|
assertEquals("ZDF HD", channels[1].name)
|
||||||
|
assertEquals("https://tv.example/api/channels/logos/8/cache/", channels[1].logo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The proxy resolves by uuid; an integer id gives a 404. */
|
||||||
|
@Test
|
||||||
|
fun `a channel without uuid is dropped`() {
|
||||||
|
val body = """{"results": [{"id": 1, "uuid": "", "name": "Kaputt"}]}"""
|
||||||
|
assertTrue(DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a channel hidden from output is dropped`() {
|
||||||
|
val body = """
|
||||||
|
{"results": [{"id": 1, "uuid": "aaa", "name": "Intern", "hidden_from_output": true}]}
|
||||||
|
""".trimIndent()
|
||||||
|
assertTrue(DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unknown group leaves the group empty instead of failing`() {
|
||||||
|
val body = """{"results": [{"id": 1, "uuid": "aaa", "name": "X", "channel_group_id": 99}]}"""
|
||||||
|
assertEquals("", DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).first().group)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bare array works like a paginated page`() {
|
||||||
|
val body = """[{"id": 5, "uuid": "ccc", "name": "Bar"}]"""
|
||||||
|
assertEquals(1, DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the next page is followed only while there is one`() {
|
||||||
|
assertEquals(
|
||||||
|
"https://tv.example/api/channels/channels/?page=2",
|
||||||
|
DispatcharrJson.nextPage("""{"next": "https://tv.example/api/channels/channels/?page=2"}"""),
|
||||||
|
)
|
||||||
|
assertNull(DispatcharrJson.nextPage("""{"next": null}"""))
|
||||||
|
assertNull(DispatcharrJson.nextPage("""{"next": ""}"""))
|
||||||
|
assertNull(DispatcharrJson.nextPage("""[{"id": 1}]"""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `groups come back as id to name`() {
|
||||||
|
val body = """{"results": [{"id": 1, "name": "Free TV / HD+"}, {"id": 2, "name": "Sky Sport"}]}"""
|
||||||
|
assertEquals(groups, DispatcharrJson.parseGroups(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `favourites are the ids under channels`() {
|
||||||
|
assertEquals(setOf(46, 118), DispatcharrJson.parseFavorites("""{"channels": [46, 118]}"""))
|
||||||
|
assertEquals(emptySet<Int>(), DispatcharrJson.parseFavorites("""{"channels": []}"""))
|
||||||
|
// A user who never starred anything gets an envelope without the key.
|
||||||
|
assertEquals(emptySet<Int>(), DispatcharrJson.parseFavorites("""{}"""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `inactive output profiles are not offered`() {
|
||||||
|
val body = """
|
||||||
|
{"results": [
|
||||||
|
{"name": "raw", "is_active": true},
|
||||||
|
{"name": "alt", "is_active": false},
|
||||||
|
{"name": "default"}
|
||||||
|
]}
|
||||||
|
""".trimIndent()
|
||||||
|
assertEquals(listOf("raw", "default"), DispatcharrJson.parseProfiles(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the epg grid is keyed by tvg id and sorted`() {
|
||||||
|
val body = """
|
||||||
|
{"data": [
|
||||||
|
{"tvg_id": "ard.de", "title": "Tagesschau",
|
||||||
|
"start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"},
|
||||||
|
{"tvg_id": "ard.de", "title": "Sportschau",
|
||||||
|
"start_time": "2026-08-26T17:00:00Z", "end_time": "2026-08-26T18:00:00Z"}
|
||||||
|
]}
|
||||||
|
""".trimIndent()
|
||||||
|
val grid = DispatcharrJson.parseEpgGrid(body)
|
||||||
|
assertEquals(listOf("Sportschau", "Tagesschau"), grid.getValue("ard.de").map { it.title })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `grid entries without a usable time or id are skipped`() {
|
||||||
|
val body = """
|
||||||
|
{"data": [
|
||||||
|
{"tvg_id": "", "title": "Ohne Sender",
|
||||||
|
"start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"},
|
||||||
|
{"tvg_id": "zdf.de", "title": "Kaputte Zeit",
|
||||||
|
"start_time": "kein Datum", "end_time": "2026-08-26T18:15:00Z"},
|
||||||
|
{"tvg_id": "zdf.de", "title": "Ende vor Start",
|
||||||
|
"start_time": "2026-08-26T18:15:00Z", "end_time": "2026-08-26T18:00:00Z"}
|
||||||
|
]}
|
||||||
|
""".trimIndent()
|
||||||
|
assertTrue(DispatcharrJson.parseEpgGrid(body).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fork has shipped "data", "results" and a bare array over time. */
|
||||||
|
@Test
|
||||||
|
fun `all three grid envelopes are accepted`() {
|
||||||
|
val entry = """{"tvg_id": "ard.de", "title": "X",
|
||||||
|
"start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"}"""
|
||||||
|
listOf("""{"data": [$entry]}""", """{"results": [$entry]}""", """[$entry]""").forEach { body ->
|
||||||
|
assertEquals(1, DispatcharrJson.parseEpgGrid(body).getValue("ard.de").size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
tests/unit/DuplicateProgrammesTest.kt
Normal file
74
tests/unit/DuplicateProgrammesTest.kt
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DuplicateProgrammesTest {
|
||||||
|
|
||||||
|
private fun p(startMin: Int, stopMin: Int, title: String) =
|
||||||
|
Programme(startMin * 60_000L, stopMin * 60_000L, title)
|
||||||
|
|
||||||
|
/** The real case: one channel, one episode, three EPG entries. */
|
||||||
|
@Test
|
||||||
|
fun `repeats a few minutes apart collapse into one`() {
|
||||||
|
val collapsed = XmltvParser.collapseDuplicates(
|
||||||
|
listOf(
|
||||||
|
p(1090, 1140, "The Big Bang Theory"),
|
||||||
|
p(1095, 1145, "The Big Bang Theory"),
|
||||||
|
p(1120, 1170, "The Big Bang Theory"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(1, collapsed.size)
|
||||||
|
assertEquals(1090 * 60_000L, collapsed.first().start)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a genuine repeat later in the evening survives`() {
|
||||||
|
val collapsed = XmltvParser.collapseDuplicates(
|
||||||
|
listOf(
|
||||||
|
p(1090, 1140, "Tagesschau"),
|
||||||
|
p(1300, 1320, "Tagesschau"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(2, collapsed.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `different programmes at the same time both stay`() {
|
||||||
|
val collapsed = XmltvParser.collapseDuplicates(
|
||||||
|
listOf(
|
||||||
|
p(1090, 1140, "Sportschau"),
|
||||||
|
p(1092, 1142, "Tagesschau"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(2, collapsed.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a repeat inside the first slot collapses even beyond the tolerance`() {
|
||||||
|
val collapsed = XmltvParser.collapseDuplicates(
|
||||||
|
listOf(
|
||||||
|
p(1000, 1200, "Fußball: Konferenz"),
|
||||||
|
p(1100, 1300, "Fußball: Konferenz"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(1, collapsed.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unsorted list is handled in time order`() {
|
||||||
|
val collapsed = XmltvParser.collapseDuplicates(
|
||||||
|
listOf(
|
||||||
|
p(1095, 1145, "Two and a Half Men"),
|
||||||
|
p(1090, 1140, "Two and a Half Men"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(1, collapsed.size)
|
||||||
|
assertEquals(1090 * 60_000L, collapsed.first().start)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty list stays empty`() {
|
||||||
|
assertEquals(emptyList<Programme>(), XmltvParser.collapseDuplicates(emptyList()))
|
||||||
|
}
|
||||||
|
}
|
||||||
104
tests/unit/FixtureTest.kt
Normal file
104
tests/unit/FixtureTest.kt
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FixtureTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `competition prefix and round suffix fall away`() {
|
||||||
|
assertEquals(
|
||||||
|
"Nordstadt - FC St. Pauli",
|
||||||
|
TeamFilters.fixture("Pokal: Nordstadt - FC St. Pauli, 1. Runde"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bare pairing survives unchanged`() {
|
||||||
|
assertEquals(
|
||||||
|
"FC Bayern München - Borussia Dortmund",
|
||||||
|
TeamFilters.fixture("FC Bayern München - Borussia Dortmund"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `en dash and vs count as separators`() {
|
||||||
|
assertEquals("Schalke - HSV", TeamFilters.fixture("2. Liga: Schalke – HSV"))
|
||||||
|
assertEquals("Kiel - Rostock", TeamFilters.fixture("Kiel vs. Rostock"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `trailing channel detail is dropped`() {
|
||||||
|
assertEquals(
|
||||||
|
"Werder - Union Berlin",
|
||||||
|
TeamFilters.fixture("Bundesliga: Werder - Union Berlin | live"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A magazine about the club is a hit, but has no pairing to shorten. */
|
||||||
|
@Test
|
||||||
|
fun `a title without a pairing keeps the original`() {
|
||||||
|
assertNull(TeamFilters.fixture("FC St. Pauli: Der Rückblick"))
|
||||||
|
assertNull(TeamFilters.fixture("Sportschau"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a dangling separator is not a pairing`() {
|
||||||
|
assertNull(TeamFilters.fixture("Pokal: - , 1. Runde"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hyphenated club names must not be mistaken for the separator. */
|
||||||
|
@Test
|
||||||
|
fun `hyphens inside a name do not split`() {
|
||||||
|
assertEquals(
|
||||||
|
"Rot-Weiss Essen - Preußen Münster",
|
||||||
|
TeamFilters.fixture("3. Liga: Rot-Weiss Essen - Preußen Münster"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Titel aus echten EPG-Daten (Sky, DAZN, ran) ----------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a real Sky title yields both clubs`() {
|
||||||
|
assertEquals(
|
||||||
|
"Bayer Leverkusen - VfB Stuttgart",
|
||||||
|
TeamFilters.fixture(
|
||||||
|
"BL: Bayer Leverkusen - VfB Stuttgart, tipico Topspiel der Woche, 16. Spieltag"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one that gave "VfB Stuttgart - Saison 25-26" as a fixture. */
|
||||||
|
@Test
|
||||||
|
fun `a season is not an opponent`() {
|
||||||
|
assertNull(TeamFilters.fixture("BL: VfB Stuttgart - Saison 25-26"))
|
||||||
|
assertNull(TeamFilters.fixture("BL: Vereinsprofil VfB Stuttgart"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a trailing half is dropped, not treated as a third team`() {
|
||||||
|
assertEquals(
|
||||||
|
"Bundesliga Eröffnungsspiel FC Bayern München - VfB Stuttgart",
|
||||||
|
TeamFilters.fixture(
|
||||||
|
"ran Fußball: Bundesliga Eröffnungsspiel FC Bayern München - VfB Stuttgart - 1. Halbzeit"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `youth teams keep their suffix`() {
|
||||||
|
assertEquals(
|
||||||
|
"VfB Stuttgart U19 - SC Freiburg U19",
|
||||||
|
TeamFilters.fixture("Live DFB-Pokal Junioren: VfB Stuttgart U19 - SC Freiburg U19, 2. Runde"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a live prefix falls away with the competition`() {
|
||||||
|
assertEquals(
|
||||||
|
"VfB Stuttgart - Hamburger SV",
|
||||||
|
TeamFilters.fixture("LIVE: VfB Stuttgart - Hamburger SV"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
tests/unit/TeamFiltersTest.kt
Normal file
80
tests/unit/TeamFiltersTest.kt
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TeamFiltersTest {
|
||||||
|
|
||||||
|
private val hansa = TeamFilters.byKey("hansa")!!
|
||||||
|
private val vfb = TeamFilters.byKey("stuttgart")!!
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `matches the club regardless of case and surrounding text`() {
|
||||||
|
assertTrue(hansa.matches("3. Liga: FC Hansa Rostock - Dynamo Dresden"))
|
||||||
|
assertTrue(hansa.matches("HANSA ROSTOCK KOMPAKT"))
|
||||||
|
assertTrue(vfb.matches("Bundesliga: VfB Stuttgart - Bayern München"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not match unrelated programmes`() {
|
||||||
|
assertFalse(hansa.matches("Tagesschau"))
|
||||||
|
assertFalse(vfb.matches("Hansa Rostock - Saarbrücken"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scans sport and free-to-air groups`() {
|
||||||
|
listOf("DAZN Event", "Sky Sport", "Magenta Sport", "Amazon Prime", "Free TV / HD+", "DYN Sport")
|
||||||
|
.forEach { assertTrue(it, TeamFilters.scansGroup(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips NFL and unrelated groups`() {
|
||||||
|
assertFalse(TeamFilters.scansGroup("DAZN Event NFL"))
|
||||||
|
assertFalse(TeamFilters.scansGroup("Kids"))
|
||||||
|
assertFalse(TeamFilters.scansGroup("Musik"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `assigns each viewer their own club by default`() {
|
||||||
|
assertEquals(listOf("hansa"), TeamFilters.defaultKeysFor("benjamin"))
|
||||||
|
assertEquals(listOf("stuttgart"), TeamFilters.defaultKeysFor("TobiasB"))
|
||||||
|
assertTrue(TeamFilters.defaultKeysFor("someone-else").isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `club keys are unique and every club has needles`() {
|
||||||
|
val keys = TeamFilters.all.map { it.key }
|
||||||
|
assertEquals(keys.size, keys.toSet().size)
|
||||||
|
assertTrue(TeamFilters.all.all { it.needles.isNotEmpty() })
|
||||||
|
assertTrue(TeamFilters.all.all { c -> c.needles.all { it == it.lowercase() } })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `window reaches midnight but never falls below three hours`() {
|
||||||
|
val cal = java.util.Calendar.getInstance()
|
||||||
|
// Morning: the window must reach into the evening.
|
||||||
|
cal.set(java.util.Calendar.HOUR_OF_DAY, 9)
|
||||||
|
cal.set(java.util.Calendar.MINUTE, 0)
|
||||||
|
val morning = cal.timeInMillis
|
||||||
|
val eveningKickoff = morning + 12 * 60 * 60 * 1000L
|
||||||
|
assertTrue(TeamFilters.windowEnd(morning) > eveningKickoff)
|
||||||
|
|
||||||
|
// Late at night: still at least three hours ahead.
|
||||||
|
cal.set(java.util.Calendar.HOUR_OF_DAY, 23)
|
||||||
|
cal.set(java.util.Calendar.MINUTE, 30)
|
||||||
|
val lateNight = cal.timeInMillis
|
||||||
|
assertTrue(
|
||||||
|
TeamFilters.windowEnd(lateNight) >= lateNight + TeamFilters.MIN_WINDOW_MS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `covers all three divisions`() {
|
||||||
|
assertTrue(TeamFilters.all.size > 50)
|
||||||
|
listOf("bayern", "schalke", "hansa").forEach {
|
||||||
|
assertTrue(it, TeamFilters.byKey(it) != null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
tests/unit/UpdateRulesTest.kt
Normal file
76
tests/unit/UpdateRulesTest.kt
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package dev.castarr.tv.update
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class UpdateRulesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a higher patch level is newer`() {
|
||||||
|
assertTrue(UpdateRules.isNewer("0.11.5", "0.11.4"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same version is not newer`() {
|
||||||
|
assertFalse(UpdateRules.isNewer("0.11.5", "0.11.5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an older release never offers itself as an update`() {
|
||||||
|
assertFalse(UpdateRules.isNewer("0.11.4", "0.11.5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Segments are numbers, not text — "10" beats "9". */
|
||||||
|
@Test
|
||||||
|
fun `double digit segments compare numerically`() {
|
||||||
|
assertTrue(UpdateRules.isNewer("0.11.10", "0.11.9"))
|
||||||
|
assertFalse(UpdateRules.isNewer("0.11.9", "0.11.10"))
|
||||||
|
assertTrue(UpdateRules.isNewer("0.12.0", "0.9.99"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a missing segment counts as zero`() {
|
||||||
|
assertFalse(UpdateRules.isNewer("0.12", "0.12.0"))
|
||||||
|
assertTrue(UpdateRules.isNewer("0.12.1", "0.12"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a leading v on the tag makes no difference`() {
|
||||||
|
assertTrue(UpdateRules.isNewer("v0.11.5", "0.11.4"))
|
||||||
|
assertFalse(UpdateRules.isNewer("v0.11.4", "v0.11.4"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage in a tag does not offer an update`() {
|
||||||
|
assertFalse(UpdateRules.isNewer("", "0.11.5"))
|
||||||
|
assertFalse(UpdateRules.isNewer("nightly", "0.11.5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a complete download passes`() {
|
||||||
|
assertTrue(UpdateRules.isComplete(actualBytes = 6_515_429, announcedBytes = 6_515_429))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 0.11.5 bug: a truncated APK left the installer hanging. */
|
||||||
|
@Test
|
||||||
|
fun `a truncated download is rejected`() {
|
||||||
|
assertFalse(UpdateRules.isComplete(actualBytes = 3_000_000, announcedBytes = 6_515_429))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty download is rejected even when nothing was announced`() {
|
||||||
|
assertFalse(UpdateRules.isComplete(actualBytes = 0, announcedBytes = -1))
|
||||||
|
assertFalse(UpdateRules.isComplete(actualBytes = 0, announcedBytes = 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unannounced length cannot be checked, so any content passes`() {
|
||||||
|
assertTrue(UpdateRules.isComplete(actualBytes = 6_515_429, announcedBytes = -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `more bytes than announced is rejected too`() {
|
||||||
|
assertFalse(UpdateRules.isComplete(actualBytes = 7_000_000, announcedBytes = 6_515_429))
|
||||||
|
}
|
||||||
|
}
|
||||||
34
tests/unit/XmltvWindowTest.kt
Normal file
34
tests/unit/XmltvWindowTest.kt
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class XmltvWindowTest {
|
||||||
|
|
||||||
|
private fun p(startMin: Long, endMin: Long, title: String) =
|
||||||
|
Programme(startMin * 60_000, endMin * 60_000, title)
|
||||||
|
|
||||||
|
private val schedule = listOf(
|
||||||
|
p(0, 60, "Läuft gerade"),
|
||||||
|
p(60, 120, "Gleich danach"),
|
||||||
|
p(150, 210, "In zweieinhalb Stunden"),
|
||||||
|
p(300, 360, "Deutlich später"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns programmes overlapping the window`() {
|
||||||
|
val titles = XmltvParser.programmesIn(schedule, 30 * 60_000, 180 * 60_000).map { it.title }
|
||||||
|
assertEquals(listOf("Läuft gerade", "Gleich danach", "In zweieinhalb Stunden"), titles)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `excludes programmes outside the window`() {
|
||||||
|
val titles = XmltvParser.programmesIn(schedule, 0, 60 * 60_000).map { it.title }
|
||||||
|
assertEquals(listOf("Läuft gerade"), titles)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handles a missing schedule`() {
|
||||||
|
assertEquals(emptyList<Programme>(), XmltvParser.programmesIn(null, 0, 1_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
58
tools/crest-sources.json
Normal file
58
tools/crest-sources.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"aachen": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/94/Alemannia_Aachen_2010.svg/langde-330px-Alemannia_Aachen_2010.svg.png",
|
||||||
|
"aue": "https://thumb.wikimedia.org/wikipedia/de/thumb/1/13/Fc_erzgebirge_aue.svg/langde-330px-Fc_erzgebirge_aue.svg.png",
|
||||||
|
"augsburg": "https://upload.wikimedia.org/wikipedia/de/thumb/b/b5/Logo_FC_Augsburg.svg/langde-330px-Logo_FC_Augsburg.svg.png",
|
||||||
|
"bayern": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/FC_Bayern_M%C3%BCnchen_logo_%282024%29.svg/langde-330px-FC_Bayern_M%C3%BCnchen_logo_%282024%29.svg.png",
|
||||||
|
"bielefeld": "https://thumb.wikimedia.org/wikipedia/commons/thumb/f/fd/Arminia_Bielefeld_Logo_2021%E2%80%93.svg/langde-330px-Arminia_Bielefeld_Logo_2021%E2%80%93.svg.png",
|
||||||
|
"bochum": "https://thumb.wikimedia.org/wikipedia/commons/thumb/7/72/VfL_Bochum_logo.svg/langde-330px-VfL_Bochum_logo.svg.png",
|
||||||
|
"braunschweig": "https://thumb.wikimedia.org/wikipedia/de/thumb/4/45/Logo_Eintracht_Braunschweig.svg/langde-330px-Logo_Eintracht_Braunschweig.svg.png",
|
||||||
|
"bremen": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/be/SV-Werder-Bremen-Logo.svg/langde-330px-SV-Werder-Bremen-Logo.svg.png",
|
||||||
|
"bvb": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/67/Borussia_Dortmund_logo.svg/langde-330px-Borussia_Dortmund_logo.svg.png",
|
||||||
|
"cottbus": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Logo_Energie_Cottbus.svg/langde-330px-Logo_Energie_Cottbus.svg.png",
|
||||||
|
"darmstadt": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/SV_Darmstadt_98_Logo.svg/langde-330px-SV_Darmstadt_98_Logo.svg.png",
|
||||||
|
"dresden": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Logo_SG_Dynamo_Dresden_neu.svg/langde-330px-Logo_SG_Dynamo_Dresden_neu.svg.png",
|
||||||
|
"duesseldorf": "https://thumb.wikimedia.org/wikipedia/commons/thumb/9/94/Fortuna_D%C3%BCsseldorf.svg/langde-330px-Fortuna_D%C3%BCsseldorf.svg.png",
|
||||||
|
"duisburg": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/Msv_duisburg_%282017%29.svg/langde-330px-Msv_duisburg_%282017%29.svg.png",
|
||||||
|
"elversberg": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d4/SV_Elversberg_Logo_2021.svg/langde-330px-SV_Elversberg_Logo_2021.svg.png",
|
||||||
|
"essen": "https://upload.wikimedia.org/wikipedia/de/thumb/8/8a/Logo_Rot-Weiss_Essen.svg/langde-330px-Logo_Rot-Weiss_Essen.svg.png",
|
||||||
|
"frankfurt": "https://upload.wikimedia.org/wikipedia/de/thumb/3/32/Logo_Eintracht_Frankfurt_1998.svg/langde-330px-Logo_Eintracht_Frankfurt_1998.svg.png",
|
||||||
|
"freiburg": "https://upload.wikimedia.org/wikipedia/de/thumb/b/bf/SC_Freiburg_Logo.svg/langde-330px-SC_Freiburg_Logo.svg.png",
|
||||||
|
"fuerth": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b1/SpVgg_Greuther_F%C3%BCrth_2017.svg/langde-330px-SpVgg_Greuther_F%C3%BCrth_2017.svg.png",
|
||||||
|
"gladbach": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/81/Borussia_M%C3%B6nchengladbach_logo.svg/langde-330px-Borussia_M%C3%B6nchengladbach_logo.svg.png",
|
||||||
|
"hannover": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cd/Hannover_96_Logo.svg/langde-330px-Hannover_96_Logo.svg.png",
|
||||||
|
"hansa": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/8f/F.C._Hansa_Rostock_Logo.svg/langde-330px-F.C._Hansa_Rostock_Logo.svg.png",
|
||||||
|
"havelse": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/89/TSV_Havelse_logo.svg/langde-330px-TSV_Havelse_logo.svg.png",
|
||||||
|
"heidenheim": "https://thumb.wikimedia.org/wikipedia/commons/thumb/9/9d/1._FC_Heidenheim_1846.svg/langde-330px-1._FC_Heidenheim_1846.svg.png",
|
||||||
|
"hertha": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/81/Hertha_BSC_Logo_2012.svg/langde-330px-Hertha_BSC_Logo_2012.svg.png",
|
||||||
|
"hoffenheim": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Logo_TSG_Hoffenheim.svg/langde-330px-Logo_TSG_Hoffenheim.svg.png",
|
||||||
|
"hoffenheim-ii": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Logo_TSG_Hoffenheim.svg/langde-330px-Logo_TSG_Hoffenheim.svg.png",
|
||||||
|
"holstein": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/Holstein_Kiel_Logo.svg/langde-330px-Holstein_Kiel_Logo.svg.png",
|
||||||
|
"hsv": "https://thumb.wikimedia.org/wikipedia/commons/thumb/f/f7/Hamburger_SV_logo.svg/langde-330px-Hamburger_SV_logo.svg.png",
|
||||||
|
"ingolstadt": "https://thumb.wikimedia.org/wikipedia/de/thumb/5/55/FC-Ingolstadt_logo.svg/langde-330px-FC-Ingolstadt_logo.svg.png",
|
||||||
|
"kaiserslautern": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d3/Logo_1_FC_Kaiserslautern.svg/langde-330px-Logo_1_FC_Kaiserslautern.svg.png",
|
||||||
|
"karlsruhe": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Karlsruher_SC_Logo_2.svg/langde-330px-Karlsruher_SC_Logo_2.svg.png",
|
||||||
|
"koeln": "https://thumb.wikimedia.org/wikipedia/commons/thumb/0/01/1._FC_Koeln_Logo_2014%E2%80%93.svg/langde-330px-1._FC_Koeln_Logo_2014%E2%80%93.svg.png",
|
||||||
|
"leipzig": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/2019-07-12_Fu%C3%9Fball%3B_Freundschaftsspiel_RB_Leipzig_-_FC_Z%C3%BCrich_1DX_0881_by_Stepro_2.png/330px-2019-07-12_Fu%C3%9Fball%3B_Freundschaftsspiel_RB_Leipzig_-_FC_Z%C3%BCrich_1DX_0881_by_Stepro_2.png",
|
||||||
|
"leverkusen": "https://upload.wikimedia.org/wikipedia/de/thumb/f/f7/Bayer_Leverkusen_Logo.svg/langde-330px-Bayer_Leverkusen_Logo.svg.png",
|
||||||
|
"magdeburg": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/84/1._FC_Magdeburg.svg/langde-330px-1._FC_Magdeburg.svg.png",
|
||||||
|
"mainz": "https://thumb.wikimedia.org/wikipedia/commons/thumb/9/9e/Logo_Mainz_05.svg/langde-330px-Logo_Mainz_05.svg.png",
|
||||||
|
"mannheim": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/Svwaldhof.svg/langde-330px-Svwaldhof.svg.png",
|
||||||
|
"muenster": "https://thumb.wikimedia.org/wikipedia/de/thumb/7/7e/SC_Preussen_Muenster_Logo_2018.svg/langde-330px-SC_Preussen_Muenster_Logo_2018.svg.png",
|
||||||
|
"nuernberg": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/1._FC_N%C3%BCrnberg_logo.svg/langde-330px-1._FC_N%C3%BCrnberg_logo.svg.png",
|
||||||
|
"osnabrueck": "https://thumb.wikimedia.org/wikipedia/commons/thumb/4/4e/VfL_Osnabrueck_Logo_2021%E2%80%93.svg/langde-330px-VfL_Osnabrueck_Logo_2021%E2%80%93.svg.png",
|
||||||
|
"paderborn": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/67/SC_Paderborn_07_Logo_new.svg/langde-330px-SC_Paderborn_07_Logo_new.svg.png",
|
||||||
|
"regensburg": "https://thumb.wikimedia.org/wikipedia/commons/thumb/3/3d/Jahn_Regensburg_logo2014.svg/langde-330px-Jahn_Regensburg_logo2014.svg.png",
|
||||||
|
"saarbruecken": "https://thumb.wikimedia.org/wikipedia/de/thumb/f/ff/1._FC_Saarbr%C3%BCcken.svg/langde-330px-1._FC_Saarbr%C3%BCcken.svg.png",
|
||||||
|
"schalke": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/FC_Schalke_04_Logo.svg/langde-330px-FC_Schalke_04_Logo.svg.png",
|
||||||
|
"schweinfurt": "https://thumb.wikimedia.org/wikipedia/de/thumb/7/7c/1._FC_Schweinfurt_05.svg/langde-330px-1._FC_Schweinfurt_05.svg.png",
|
||||||
|
"st-pauli": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b3/Fc_st_pauli_logo.svg/langde-330px-Fc_st_pauli_logo.svg.png",
|
||||||
|
"stuttgart": "https://thumb.wikimedia.org/wikipedia/commons/thumb/e/eb/VfB_Stuttgart_1893_Logo.svg/langde-330px-VfB_Stuttgart_1893_Logo.svg.png",
|
||||||
|
"stuttgart-ii": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/eb/VfB_Stuttgart_1893_Logo.svg/langde-330px-VfB_Stuttgart_1893_Logo.svg.png",
|
||||||
|
"ulm": "https://thumb.wikimedia.org/wikipedia/commons/thumb/6/6c/SSV_Ulm_1846_Fussball.svg/langde-330px-SSV_Ulm_1846_Fussball.svg.png",
|
||||||
|
"union": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/1._FC_Union_Berlin_Logo.svg/langde-330px-1._FC_Union_Berlin_Logo.svg.png",
|
||||||
|
"verl": "https://thumb.wikimedia.org/wikipedia/commons/thumb/c/ce/SC_Verl_Logo.svg/langde-330px-SC_Verl_Logo.svg.png",
|
||||||
|
"viktoria": "https://thumb.wikimedia.org/wikipedia/commons/thumb/d/dc/FC_Viktoria_K%C3%B6ln_1904_Logo.svg/langde-330px-FC_Viktoria_K%C3%B6ln_1904_Logo.svg.png",
|
||||||
|
"wehen": "https://upload.wikimedia.org/wikipedia/de/e/e0/SV_Wehen_Logo.png",
|
||||||
|
"wolfsburg": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c5/VfL_Wolfsburg_logo_2026.svg/langde-330px-VfL_Wolfsburg_logo_2026.svg.png",
|
||||||
|
"wuppertal": "https://thumb.wikimedia.org/wikipedia/de/thumb/9/9b/WuppertalerSVLogo.svg/langde-330px-WuppertalerSVLogo.svg.png"
|
||||||
|
}
|
||||||
91
tools/fetch-crests.py
Normal file
91
tools/fetch-crests.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Downloads club crests into the APK's assets at build time.
|
||||||
|
|
||||||
|
The badges are trademarks: they are fetched into a generated, git-ignored
|
||||||
|
folder so the repository stays free of them while the app ships with them
|
||||||
|
and needs no network at runtime.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
OUT = sys.argv[1] if len(sys.argv) > 1 else "app/src/main/assets/crests"
|
||||||
|
URLS = "tools/crest-sources.json"
|
||||||
|
SRC = "app/src/main/java/dev/castarr/tv/data/TeamFilters.kt"
|
||||||
|
SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/"
|
||||||
|
UA = {"User-Agent": "Castarr build script (private use)"}
|
||||||
|
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
# Image URLs are plain text and safe to commit; the artwork itself is not.
|
||||||
|
sources = {}
|
||||||
|
if os.path.exists(URLS):
|
||||||
|
sources = json.load(open(URLS, encoding="utf-8"))
|
||||||
|
kotlin = open(SRC, encoding="utf-8").read()
|
||||||
|
entries = re.findall(r'club\((.*?)\)\s*,\s*(?://.*)?$', kotlin, re.M | re.S)
|
||||||
|
clubs = []
|
||||||
|
# Wikipedia serves ~330px thumbnails. The rail draws them at 20dp, which on a
|
||||||
|
# 1080p TV is 40px -- an 8x downscale that Android does with a cheap filter,
|
||||||
|
# and the edges come out ragged. Resampling once here with a proper filter
|
||||||
|
# fixes that and shrinks the APK.
|
||||||
|
CREST_PX = 128
|
||||||
|
|
||||||
|
|
||||||
|
def downscale(data: bytes) -> bytes:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
return data
|
||||||
|
import io
|
||||||
|
|
||||||
|
image = Image.open(io.BytesIO(data)).convert("RGBA")
|
||||||
|
if max(image.size) <= CREST_PX:
|
||||||
|
return data
|
||||||
|
scale = CREST_PX / max(image.size)
|
||||||
|
size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
|
||||||
|
out = io.BytesIO()
|
||||||
|
image.resize(size, Image.LANCZOS).save(out, format="PNG", optimize=True)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
for raw in re.findall(r'club\(\s*"([^"]+)",\s*"[^"]*",\s*"([^"]+)"[^\n]*', kotlin):
|
||||||
|
clubs.append(raw)
|
||||||
|
# an explicit article = "..." wins over the club name
|
||||||
|
overrides = dict(re.findall(r'club\(\s*"([^"]+)"[^\n]*article = "([^"]+)"', kotlin))
|
||||||
|
clubs = [(k, overrides.get(k, n)) for k, n in clubs]
|
||||||
|
|
||||||
|
fetched = skipped = failed = 0
|
||||||
|
for key, full_name in clubs:
|
||||||
|
target = os.path.join(OUT, f"{key}.png")
|
||||||
|
if os.path.exists(target) and os.path.getsize(target) > 0:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
thumb = sources.get(key)
|
||||||
|
if not thumb:
|
||||||
|
url = SUMMARY + urllib.parse.quote(full_name)
|
||||||
|
with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=20) as r:
|
||||||
|
thumb = json.load(r).get("thumbnail", {}).get("source")
|
||||||
|
if not thumb:
|
||||||
|
raise ValueError("no thumbnail")
|
||||||
|
sources[key] = thumb.split("?")[0]
|
||||||
|
thumb = sources[key]
|
||||||
|
with urllib.request.urlopen(urllib.request.Request(thumb, headers=UA), timeout=20) as r:
|
||||||
|
data = r.read()
|
||||||
|
with open(target, "wb") as f:
|
||||||
|
f.write(downscale(data))
|
||||||
|
fetched += 1
|
||||||
|
except Exception as exc: # noqa: BLE001 - best effort, shield is the fallback
|
||||||
|
print(f" {key}: {exc}", file=sys.stderr)
|
||||||
|
failed += 1
|
||||||
|
# Wikipedia rate-limits bursts; this runs rarely and caches.
|
||||||
|
time.sleep(1.2)
|
||||||
|
|
||||||
|
with open(URLS, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(dict(sorted(sources.items())), f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
print(f"crests: {fetched} geladen, {skipped} vorhanden, {failed} fehlgeschlagen")
|
||||||
171
tools/release.sh
Executable file
171
tools/release.sh
Executable file
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Cuts a release: version bump, tests, signed build, tag, apk branch, Gitea
|
||||||
|
# release. Every step was done by hand before, fifteen times in one day.
|
||||||
|
#
|
||||||
|
# tools/release.sh 0.11.6 --notes notes.md
|
||||||
|
# tools/release.sh 0.11.6 --dry-run # show what would happen
|
||||||
|
# tools/release.sh 0.11.6 --no-smoke # skip the emulator run
|
||||||
|
#
|
||||||
|
# Release notes are never generated: the commit subjects since the last tag
|
||||||
|
# are only a starting point, written to a file for you to edit. Pass --notes
|
||||||
|
# to supply them directly.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
REMOTE="${CASTARR_REMOTE:-gitea}"
|
||||||
|
REPO="${CASTARR_REPO:-be-nj/castarr}"
|
||||||
|
API="${CASTARR_API:-https://git.beckm4nn.net/api/v1}"
|
||||||
|
SIGNING_ENV="${CASTARR_SIGNING_ENV:-$HOME/.keys/castarr-release.env}"
|
||||||
|
GRADLE="./gradlew --no-daemon -q"
|
||||||
|
|
||||||
|
VERSION=""
|
||||||
|
NOTES_FILE=""
|
||||||
|
DRY=0
|
||||||
|
SMOKE=1
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--notes) NOTES_FILE="$2"; shift 2 ;;
|
||||||
|
--dry-run) DRY=1; shift ;;
|
||||||
|
--no-smoke) SMOKE=0; shift ;;
|
||||||
|
-*) echo "unbekannte Option: $1" >&2; exit 2 ;;
|
||||||
|
*) VERSION="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$VERSION" ] || { echo "usage: $0 <version> [--notes <file>] [--dry-run] [--no-smoke]" >&2; exit 2; }
|
||||||
|
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Version muss X.Y.Z sein, nicht '$VERSION'" >&2; exit 2; }
|
||||||
|
|
||||||
|
say() { printf '\n== %s\n' "$1"; }
|
||||||
|
run() { if [ "$DRY" = 1 ]; then printf ' would run: %s\n' "$*"; else "$@"; fi; }
|
||||||
|
die() { printf 'ABBRUCH: %s\n' "$1" >&2; exit 1; }
|
||||||
|
|
||||||
|
# --- Vorbedingungen ---------------------------------------------------------
|
||||||
|
say "Prüfen"
|
||||||
|
[ -z "$(git status --porcelain)" ] || die "Arbeitsbaum nicht sauber"
|
||||||
|
[ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || die "nicht auf main"
|
||||||
|
git fetch -q "$REMOTE" main
|
||||||
|
[ "$(git rev-parse HEAD)" = "$(git rev-parse "$REMOTE/main")" ] ||
|
||||||
|
die "main weicht von $REMOTE/main ab — erst pushen oder pullen"
|
||||||
|
git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null &&
|
||||||
|
die "Tag v$VERSION existiert schon"
|
||||||
|
|
||||||
|
CURRENT="$(sed -nE 's/.*versionName = "(.*)".*/\1/p' app/build.gradle.kts)"
|
||||||
|
CODE="$(sed -nE 's/.*versionCode = ([0-9]+).*/\1/p' app/build.gradle.kts)"
|
||||||
|
[ -n "$CURRENT" ] && [ -n "$CODE" ] || die "Version aus app/build.gradle.kts nicht lesbar"
|
||||||
|
NEXT_CODE=$((CODE + 1))
|
||||||
|
echo " $CURRENT (code $CODE) -> $VERSION (code $NEXT_CODE)"
|
||||||
|
|
||||||
|
# shellcheck source=tests/helpers/jdk.sh
|
||||||
|
. tests/helpers/jdk.sh
|
||||||
|
echo " JDK: $(basename "${JAVA_HOME:-system}")"
|
||||||
|
|
||||||
|
# --- Notizen ----------------------------------------------------------------
|
||||||
|
LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)"
|
||||||
|
if [ -z "$NOTES_FILE" ]; then
|
||||||
|
NOTES_FILE="$(mktemp -t castarr-notes-XXXX.md)"
|
||||||
|
{
|
||||||
|
echo "# Notizen für $VERSION — diese Zeilen sind ein Entwurf, keine Release-Notes."
|
||||||
|
echo "#"
|
||||||
|
[ -n "$LAST_TAG" ] && echo "# Commits seit $LAST_TAG:" || echo "# Commits:"
|
||||||
|
if [ -n "$LAST_TAG" ]; then
|
||||||
|
git log --format='# %s' "$LAST_TAG..HEAD"
|
||||||
|
else
|
||||||
|
git log --format='# %s' -10
|
||||||
|
fi
|
||||||
|
} > "$NOTES_FILE"
|
||||||
|
if [ -n "${EDITOR:-}" ] && [ -t 0 ]; then
|
||||||
|
"$EDITOR" "$NOTES_FILE"
|
||||||
|
else
|
||||||
|
say "Notizen"
|
||||||
|
echo " Entwurf liegt in $NOTES_FILE."
|
||||||
|
echo " Schreib die Notes dort hinein und ruf erneut auf:"
|
||||||
|
echo " $0 $VERSION --notes $NOTES_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -f "$NOTES_FILE" ] || die "Notes-Datei fehlt: $NOTES_FILE"
|
||||||
|
NOTES="$(grep -v '^#' "$NOTES_FILE" | sed -e '/./,$!d')"
|
||||||
|
[ -n "${NOTES//[[:space:]]/}" ] || die "Notes sind leer"
|
||||||
|
|
||||||
|
# --- Tests ------------------------------------------------------------------
|
||||||
|
say "Unit-Tests"
|
||||||
|
run $GRADLE testDebugUnitTest
|
||||||
|
|
||||||
|
if [ "$SMOKE" = 1 ]; then
|
||||||
|
say "Smoke-Test auf dem Emulator"
|
||||||
|
run tests/smoke.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Version bumpen ---------------------------------------------------------
|
||||||
|
say "Version setzen"
|
||||||
|
if [ "$DRY" = 0 ]; then
|
||||||
|
sed -i -E "s/versionCode = $CODE/versionCode = $NEXT_CODE/; s/versionName = \"$CURRENT\"/versionName = \"$VERSION\"/" \
|
||||||
|
app/build.gradle.kts
|
||||||
|
grep -q "versionName = \"$VERSION\"" app/build.gradle.kts || die "Bump hat nicht gegriffen"
|
||||||
|
else
|
||||||
|
echo " would set versionCode=$NEXT_CODE versionName=$VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Signierter Build -------------------------------------------------------
|
||||||
|
say "Release-APK bauen"
|
||||||
|
if [ -f "$SIGNING_ENV" ]; then
|
||||||
|
# Signing secrets stay in the file; only this shell sees them.
|
||||||
|
set -a; . "$SIGNING_ENV"; set +a
|
||||||
|
echo " signiert (Konfiguration aus $SIGNING_ENV)"
|
||||||
|
else
|
||||||
|
echo " WARNUNG: $SIGNING_ENV fehlt — der Build wäre unsigniert"
|
||||||
|
[ "$DRY" = 1 ] || die "ohne Signatur kein Release (Updater lehnt Signaturwechsel ab)"
|
||||||
|
fi
|
||||||
|
run $GRADLE assembleRelease
|
||||||
|
APK="app/build/outputs/apk/release/app-release.apk"
|
||||||
|
[ "$DRY" = 1 ] || [ -f "$APK" ] || die "APK nicht gebaut: $APK"
|
||||||
|
|
||||||
|
# --- Commit, Tag, Push ------------------------------------------------------
|
||||||
|
say "Commit und Tag"
|
||||||
|
run git add app/build.gradle.kts
|
||||||
|
run git commit -q -m "Release $VERSION"
|
||||||
|
run git tag "v$VERSION"
|
||||||
|
run git push -q "$REMOTE" main "v$VERSION"
|
||||||
|
|
||||||
|
# --- APK-Branch -------------------------------------------------------------
|
||||||
|
say "APK auf den Branch apk"
|
||||||
|
if [ "$DRY" = 0 ]; then
|
||||||
|
git fetch -q "$REMOTE" apk
|
||||||
|
WORKTREE="$(mktemp -d -t castarr-apk-XXXX)"
|
||||||
|
trap 'git worktree remove --force "$WORKTREE" 2>/dev/null || true' EXIT
|
||||||
|
git worktree add -q -B apk "$WORKTREE" "$REMOTE/apk"
|
||||||
|
cp "$APK" "$WORKTREE/castarr.apk"
|
||||||
|
echo "$VERSION" > "$WORKTREE/VERSION"
|
||||||
|
git -C "$WORKTREE" add castarr.apk VERSION
|
||||||
|
git -C "$WORKTREE" commit -q -m "castarr $VERSION"
|
||||||
|
git -C "$WORKTREE" push -q "$REMOTE" apk
|
||||||
|
git worktree remove --force "$WORKTREE"
|
||||||
|
trap - EXIT
|
||||||
|
else
|
||||||
|
echo " would push $APK as castarr.apk"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Gitea-Release ----------------------------------------------------------
|
||||||
|
say "Gitea-Release"
|
||||||
|
TOKEN="${GITEA_TOKEN:-${CASTARR_GITEA_TOKEN:-}}"
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo " Kein GITEA_TOKEN gesetzt — Release bitte in der Weboberfläche anlegen:"
|
||||||
|
echo " ${API%/api/v1}/$REPO/releases/new?tag=v$VERSION"
|
||||||
|
echo " Notes liegen in $NOTES_FILE"
|
||||||
|
elif [ "$DRY" = 1 ]; then
|
||||||
|
echo " would create release v$VERSION"
|
||||||
|
else
|
||||||
|
BODY="$(NOTES="$NOTES" python3 -c 'import json,os; print(json.dumps({
|
||||||
|
"tag_name": "v" + os.environ["V"], "name": os.environ["V"],
|
||||||
|
"body": os.environ["NOTES"], "target_commitish": "main"}))' V="$VERSION")"
|
||||||
|
curl -sS -X POST "$API/repos/$REPO/releases" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$BODY" > /dev/null || die "Release-API fehlgeschlagen"
|
||||||
|
echo " angelegt: ${API%/api/v1}/$REPO/releases/tag/v$VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "Fertig: $VERSION"
|
||||||
|
echo " Die App findet das Update über die Release-API, die APK über den Branch apk."
|
||||||
Reference in New Issue
Block a user