Bootstrap Castarr from NodeCast prototype
Imported the native TV app (Kotlin, ExoPlayer, embedded remote server, QR pairing) plus CONTEXT.md and ADRs 0001-0005. Rename, Compose for TV UI and the Dispatcharr integration follow as tracked issues. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
27
.github/workflows/build.yml
vendored
Normal file
27
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: Build TV app
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "17"
|
||||||
|
|
||||||
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
|
- name: Build debug APK
|
||||||
|
run: ./gradlew assembleDebug --stacktrace
|
||||||
|
|
||||||
|
- name: Upload APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: castarr-debug-apk
|
||||||
|
path: app/build/outputs/apk/debug/app-debug.apk
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.kotlin/
|
||||||
70
CONTEXT.md
Normal file
70
CONTEXT.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Castarr
|
||||||
|
|
||||||
|
Native Kotlin app for Google TV that lets every user of a Dispatcharr backend
|
||||||
|
watch live IPTV comfortably on their own TV: full D-pad UI, login via OIDC
|
||||||
|
Device Flow (Authentik), plus an optional phone Remote paired via QR code.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
**TV-App (Castarr)**:
|
||||||
|
The native Kotlin app running on Google TV; full TV UI, plays streams, serves
|
||||||
|
the Remote.
|
||||||
|
_Avoid_: receiver, player app, NodeCast (old name)
|
||||||
|
|
||||||
|
**Device Flow**:
|
||||||
|
OIDC device authorization grant: TV shows QR/code, user confirms on the phone
|
||||||
|
at Authentik, app receives Bearer tokens for the Backend API.
|
||||||
|
_Avoid_: pairing (that word is reserved for the Remote)
|
||||||
|
|
||||||
|
**Backend (Dispatcharr)**:
|
||||||
|
The self-hosted stream manager owning sources, aggregation, failover, EPG and
|
||||||
|
stream profiles; forked at be-nj with native OIDC.
|
||||||
|
_Avoid_: server (ambiguous), nodecast (dropped, see ADR-0003)
|
||||||
|
|
||||||
|
**Quelle (Source)**:
|
||||||
|
A configured backend the TV-App reads channels from — primary type Dispatcharr
|
||||||
|
fork (Bearer API), fallback type generic M3U+XMLTV without login (Threadfin,
|
||||||
|
Tunarr, ErsatzTV).
|
||||||
|
|
||||||
|
**Remote**:
|
||||||
|
The phone web UI served by the TV-App over HTTP; talks to the TV-App via
|
||||||
|
WebSocket. Never talks to the Backend directly.
|
||||||
|
_Avoid_: app (it is not an installed app), controller
|
||||||
|
|
||||||
|
**Pairing**:
|
||||||
|
Trusting a Remote via the QR token (128-bit) or the rate-limited 4-digit code.
|
||||||
|
|
||||||
|
**Channel**:
|
||||||
|
A playable live entry the Backend exposes via Xtream API or M3U.
|
||||||
|
_Avoid_: station, sender (in code/docs)
|
||||||
|
|
||||||
|
**Now/Next**:
|
||||||
|
The EPG scope of the Remote in phase 1: current + upcoming programme per
|
||||||
|
Channel, no full guide timeline.
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- The **TV-App** is a client of the **Backend**; the **Remote** only ever
|
||||||
|
talks to the TV-App.
|
||||||
|
- One **User** logs into one **TV-App** via **Device Flow**; favorites belong
|
||||||
|
to the User on the Backend, not to the device.
|
||||||
|
- A **Remote** controls exactly one **TV-App**; a TV-App accepts multiple
|
||||||
|
paired Remotes.
|
||||||
|
- Aggregation, failover and stream shaping happen in the **Backend**, not in
|
||||||
|
the TV-App.
|
||||||
|
|
||||||
|
## Example dialogue
|
||||||
|
|
||||||
|
> **Dev:** "Does the **Remote** need Backend credentials?"
|
||||||
|
> **Domain expert:** "No — the user enters the Xtream credentials of a
|
||||||
|
> **Quelle** once via the Remote, the **TV-App** stores them and is the only
|
||||||
|
> one talking to the **Backend**."
|
||||||
|
|
||||||
|
## Flagged ambiguities
|
||||||
|
|
||||||
|
- "nativ" means: no Flutter/WebView wrapper — Kotlin app. It does not mean
|
||||||
|
"no embedded web content" (the Remote is deliberately a web page).
|
||||||
|
- "Passthrough" means the fork's `raw` stream profile (ffmpeg copy), selected
|
||||||
|
per request via profile parameter — not a bypass of the Backend proxy.
|
||||||
|
- Favorites are per **User** in the Dispatcharr fork (resolved; app-local and
|
||||||
|
channel-group approaches were rejected).
|
||||||
21
README.md
Normal file
21
README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Castarr
|
||||||
|
|
||||||
|
Native Google TV client for a [Dispatcharr](https://github.com/be-nj/Dispatcharr)
|
||||||
|
backend: live IPTV with a full D-pad UI, login via OIDC device flow
|
||||||
|
(Authentik), per-user favorites, and an optional phone remote paired by QR
|
||||||
|
code (served by the TV itself, no phone app install).
|
||||||
|
|
||||||
|
Status: bootstrap. The code base was imported from the NodeCast prototype
|
||||||
|
(phone-remote-first, M3U standalone) and is being reshaped per the decisions
|
||||||
|
in [CONTEXT.md](CONTEXT.md) and [docs/adr/](docs/adr/) — see the issue tracker
|
||||||
|
for the phase-1 slices.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Requires JDK 17+ and the Android SDK (platform 35).
|
||||||
|
|
||||||
|
```
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
APK output: `app/build/outputs/apk/debug/app-debug.apk`.
|
||||||
51
app/build.gradle.kts
Normal file
51
app/build.gradle.kts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.nodecast.tv"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.nodecast.tv"
|
||||||
|
minSdk = 23
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
allWarningsAsErrors = true
|
||||||
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
// Media3's UI classes are @UnstableApi; the opt-in lint check would
|
||||||
|
// otherwise fail release builds.
|
||||||
|
disable += "UnsafeOptInUsageError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.15.0")
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.4.1")
|
||||||
|
implementation("androidx.media3:media3-exoplayer-hls:1.4.1")
|
||||||
|
implementation("androidx.media3:media3-ui:1.4.1")
|
||||||
|
implementation("androidx.media3:media3-session:1.4.1")
|
||||||
|
implementation("org.nanohttpd:nanohttpd:2.3.1")
|
||||||
|
implementation("org.nanohttpd:nanohttpd-websocket:2.3.1")
|
||||||
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
|
}
|
||||||
3
app/proguard-rules.pro
vendored
Normal file
3
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# NanoHTTPD loads its mime-type tables reflectively.
|
||||||
|
-keep class fi.iki.elonen.** { *; }
|
||||||
|
-dontwarn fi.iki.elonen.**
|
||||||
36
app/src/main/AndroidManifest.xml
Normal file
36
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:banner="@drawable/tv_banner"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:theme="@style/Theme.NodeCast">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:screenOrientation="landscape"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize|density">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
502
app/src/main/assets/remote/index.html
Normal file
502
app/src/main/assets/remote/index.html
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#0a0b0d">
|
||||||
|
<title>NodeCast Remote</title>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600&display=swap">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||||
|
background: #0a0b0d; color: #f2f3f5;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
user-select: none; -webkit-user-select: none;
|
||||||
|
}
|
||||||
|
button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
|
||||||
|
input { font: inherit; color: #f2f3f5; }
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 18px 24px 0;
|
||||||
|
}
|
||||||
|
.wordmark { display: flex; align-items: center; gap: 9px; font-size: 14px; font-weight: 500;
|
||||||
|
letter-spacing: .2em; text-transform: uppercase; }
|
||||||
|
.tvchip { display: flex; align-items: center; gap: 8px; border: 1px solid #1e2126;
|
||||||
|
border-radius: 999px; padding: 8px 14px; font-size: 12px; color: #9aa0a8; letter-spacing: .03em; }
|
||||||
|
.dot { width: 6px; height: 6px; border-radius: 50%; background: #5fd4c4; }
|
||||||
|
.dot.off { background: #e5484d; }
|
||||||
|
|
||||||
|
main { flex: 1 1 auto; overflow-y: auto; display: flex; flex-direction: column; }
|
||||||
|
.view { flex: 1 1 auto; display: none; flex-direction: column; }
|
||||||
|
.view.active { display: flex; }
|
||||||
|
|
||||||
|
/* --- Steuerung --- */
|
||||||
|
#view-remote { justify-content: center; gap: 44px; padding: 24px; }
|
||||||
|
.nowcard {
|
||||||
|
position: relative; height: 200px; border-radius: 24px; overflow: hidden;
|
||||||
|
background: radial-gradient(130% 110% at 70% 20%, #16324a 0%, #0d1b2e 55%, #080c14 100%);
|
||||||
|
box-shadow: 0 0 0 1px rgba(255,255,255,.06);
|
||||||
|
}
|
||||||
|
.nowcard .glow { position: absolute; top: -40px; right: -20px; width: 220px; height: 220px;
|
||||||
|
border-radius: 50%; background: radial-gradient(circle, rgba(95,212,196,.18) 0%, rgba(95,212,196,0) 60%); }
|
||||||
|
.nowcard .stop {
|
||||||
|
position: absolute; top: 14px; right: 14px; width: 44px; height: 44px; border-radius: 50%;
|
||||||
|
display: none; align-items: center; justify-content: center;
|
||||||
|
background: rgba(10,11,13,.45); border: 1px solid rgba(255,255,255,.1);
|
||||||
|
}
|
||||||
|
.nowcard.playing .stop { display: flex; }
|
||||||
|
.nowcard .info {
|
||||||
|
position: absolute; left: 0; right: 0; bottom: 0; padding: 44px 22px 20px;
|
||||||
|
display: flex; flex-direction: column; gap: 5px;
|
||||||
|
background: linear-gradient(to top, rgba(8,12,20,.85) 0%, rgba(8,12,20,0) 100%);
|
||||||
|
}
|
||||||
|
.liverow { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 500;
|
||||||
|
letter-spacing: .16em; color: #b8bdc4; }
|
||||||
|
.livedot { width: 6px; height: 6px; border-radius: 50%; background: #e5484d; }
|
||||||
|
#now-title { font-size: 24px; font-weight: 500; letter-spacing: -.01em; }
|
||||||
|
#now-sub { font-size: 13px; font-weight: 300; color: #9aa0a8; }
|
||||||
|
|
||||||
|
.transport { display: flex; align-items: center; justify-content: center; gap: 34px; }
|
||||||
|
.btn-side { width: 56px; height: 56px; border-radius: 50%; border: 1px solid #1e2126;
|
||||||
|
display: flex; align-items: center; justify-content: center; }
|
||||||
|
.btn-side:active { background: #16181c; }
|
||||||
|
.btn-main { width: 88px; height: 88px; border-radius: 50%; background: #5fd4c4;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
box-shadow: 0 12px 44px rgba(95,212,196,.28); transition: transform .1s; }
|
||||||
|
.btn-main:active { transform: scale(.94); }
|
||||||
|
.btn-main:disabled { opacity: .35; box-shadow: none; }
|
||||||
|
|
||||||
|
.volrow { display: flex; align-items: center; gap: 16px; padding: 0 8px; }
|
||||||
|
input[type=range] { flex: 1; appearance: none; -webkit-appearance: none; height: 3px;
|
||||||
|
border-radius: 2px; background: #1e2126; outline: none; }
|
||||||
|
input[type=range]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none;
|
||||||
|
width: 22px; height: 22px; border-radius: 50%; background: #f2f3f5;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.5); }
|
||||||
|
|
||||||
|
/* --- Sender --- */
|
||||||
|
#view-channels { padding: 18px 12px 0; gap: 14px; }
|
||||||
|
.chead { display: flex; align-items: center; gap: 10px; padding: 0 12px; }
|
||||||
|
.chead h1 { font-size: 26px; font-weight: 500; letter-spacing: -.01em; flex: 1; }
|
||||||
|
.iconbtn { width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center;
|
||||||
|
justify-content: center; }
|
||||||
|
.iconbtn:active { background: #16181c; }
|
||||||
|
.searchrow { display: flex; align-items: center; gap: 10px; background: #121418;
|
||||||
|
border-radius: 12px; padding: 0 16px; margin: 0 12px; }
|
||||||
|
.searchrow input { flex: 1; background: none; border: none; outline: none; height: 48px;
|
||||||
|
font-size: 15px; font-weight: 300; }
|
||||||
|
.searchrow input::placeholder { color: #6b717a; }
|
||||||
|
#setup { display: none; flex-direction: column; gap: 10px; margin: 0 12px; padding: 16px;
|
||||||
|
background: #101216; border-radius: 14px; }
|
||||||
|
#setup.open { display: flex; }
|
||||||
|
#setup label { font-size: 12px; color: #9aa0a8; letter-spacing: .04em; }
|
||||||
|
#setup input { background: #0a0b0d; border: 1px solid #1e2126; border-radius: 10px;
|
||||||
|
height: 46px; padding: 0 14px; font-size: 14px; outline: none; width: 100%; }
|
||||||
|
#setup input:focus { border-color: #5fd4c4; }
|
||||||
|
.btn-accent { height: 46px; border-radius: 10px; background: #5fd4c4; color: #07110f;
|
||||||
|
font-weight: 500; font-size: 14px; }
|
||||||
|
.btn-accent:active { opacity: .85; }
|
||||||
|
#channel-list { display: flex; flex-direction: column; padding-bottom: 12px; }
|
||||||
|
.chan { display: flex; align-items: center; gap: 14px; padding: 12px; border-radius: 14px;
|
||||||
|
text-align: left; width: 100%; }
|
||||||
|
.chan:active { background: #121418; }
|
||||||
|
.chan.current { background: rgba(95,212,196,.07); }
|
||||||
|
.chan .num { width: 44px; height: 44px; border-radius: 12px; background: #17191e; flex: none;
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 500;
|
||||||
|
color: #7d838c; overflow: hidden; }
|
||||||
|
.chan.current .num { background: #16324a; color: #9fc3d8; }
|
||||||
|
.chan .num img { width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.chan .meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.chan .name { font-size: 16px; font-weight: 400; color: #e6e8ea; white-space: nowrap;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.chan.current .name { color: #5fd4c4; font-weight: 500; }
|
||||||
|
.chan .grp { font-size: 12px; font-weight: 300; color: #6b717a; white-space: nowrap;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.empty { padding: 40px 24px; text-align: center; color: #6b717a; font-size: 14px;
|
||||||
|
font-weight: 300; line-height: 1.5; }
|
||||||
|
|
||||||
|
/* --- Tabs --- */
|
||||||
|
nav { display: flex; border-top: 1px solid #16181c; padding: 10px 24px;
|
||||||
|
padding-bottom: calc(14px + env(safe-area-inset-bottom)); gap: 12px; flex: none; }
|
||||||
|
nav button { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 5px;
|
||||||
|
padding: 6px 0; font-size: 11px; color: #6b717a; letter-spacing: .04em; }
|
||||||
|
nav button.active { color: #5fd4c4; font-weight: 500; }
|
||||||
|
nav button svg { stroke: #6b717a; }
|
||||||
|
nav button.active svg { stroke: #5fd4c4; }
|
||||||
|
|
||||||
|
/* --- Pairing --- */
|
||||||
|
#view-pair { justify-content: center; align-items: center; gap: 26px; padding: 24px; text-align: center; }
|
||||||
|
#view-pair h1 { font-size: 24px; font-weight: 500; }
|
||||||
|
#view-pair p { font-size: 14px; color: #9aa0a8; font-weight: 300; max-width: 280px; line-height: 1.5; }
|
||||||
|
#code-input { background: #0a0b0d; border: 1px solid #1e2126; border-radius: 12px; height: 56px;
|
||||||
|
width: 180px; text-align: center; font-size: 26px; letter-spacing: .3em; outline: none; }
|
||||||
|
#code-input:focus { border-color: #5fd4c4; }
|
||||||
|
#pair-btn { width: 180px; }
|
||||||
|
#pair-error { color: #e5484d; font-size: 13px; min-height: 18px; }
|
||||||
|
|
||||||
|
#toast { position: fixed; left: 50%; bottom: 110px; transform: translateX(-50%) translateY(20px);
|
||||||
|
background: #1a1d22; border: 1px solid #23262b; color: #e6e8ea; font-size: 13px;
|
||||||
|
padding: 12px 20px; border-radius: 999px; opacity: 0; pointer-events: none;
|
||||||
|
transition: all .25s; white-space: nowrap; max-width: 90vw; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="wordmark">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#5fd4c4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="13" rx="2"/><path d="M8.5 22h7"/></svg>
|
||||||
|
NodeCast
|
||||||
|
</div>
|
||||||
|
<div class="tvchip"><span class="dot off" id="conn-dot"></span><span id="tv-name">Getrennt</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- Pairing -->
|
||||||
|
<section class="view" id="view-pair">
|
||||||
|
<h1>Mit dem TV koppeln</h1>
|
||||||
|
<p>Gib den 4-stelligen Code ein, der unten auf dem TV-Bildschirm steht.</p>
|
||||||
|
<input id="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="4" placeholder="····">
|
||||||
|
<button class="btn-accent" id="pair-btn">Verbinden</button>
|
||||||
|
<div id="pair-error"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Steuerung -->
|
||||||
|
<section class="view" id="view-remote">
|
||||||
|
<div class="nowcard" id="nowcard">
|
||||||
|
<div class="glow"></div>
|
||||||
|
<button class="stop" id="btn-stop" aria-label="Stopp">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="#e6e8ea"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="info">
|
||||||
|
<div class="liverow" id="liverow" style="visibility:hidden"><span class="livedot"></span><span id="live-label">LIVE</span></div>
|
||||||
|
<div id="now-title">Nichts läuft</div>
|
||||||
|
<div id="now-sub">Wähle einen Sender oder starte eine URL</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="transport">
|
||||||
|
<button class="btn-side" id="btn-prev" aria-label="Vorheriger Sender">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#c8ccd2" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M19 20L9 12l10-8v16z"/><path d="M5 19V5"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn-main" id="btn-play" aria-label="Wiedergabe/Pause">
|
||||||
|
<svg id="ic-play" width="30" height="30" viewBox="0 0 24 24" fill="#07110f" style="display:none"><path d="M8 5v14l11-7L8 5z"/></svg>
|
||||||
|
<svg id="ic-pause" width="30" height="30" viewBox="0 0 24 24" fill="#07110f"><path d="M7 4.5h3.4v15H7zM13.6 4.5H17v15h-3.4z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn-side" id="btn-next" aria-label="Nächster Sender">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#c8ccd2" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4l10 8-10 8V4z"/><path d="M19 5v14"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="volrow">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6b717a" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H2v6h4l5 4V5z"/></svg>
|
||||||
|
<input type="range" id="volume" min="0" max="100" value="50" aria-label="Lautstärke">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6b717a" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 010 7"/><path d="M18.4 5.6a9 9 0 010 12.8"/></svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Sender -->
|
||||||
|
<section class="view" id="view-channels">
|
||||||
|
<div class="chead">
|
||||||
|
<h1>Sender</h1>
|
||||||
|
<button class="iconbtn" id="btn-setup" aria-label="Playlist einrichten">
|
||||||
|
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="#9aa0a8" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09a1.65 1.65 0 001.51-1 1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33h.09A1.65 1.65 0 0010 3.09V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51h.09a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82v.09a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="setup">
|
||||||
|
<label for="playlist-input">M3U-Playlist-URL</label>
|
||||||
|
<input id="playlist-input" type="url" placeholder="https://…/playlist.m3u" autocapitalize="off" autocorrect="off">
|
||||||
|
<button class="btn-accent" id="btn-load-playlist">Playlist laden</button>
|
||||||
|
<label for="url-input" style="margin-top:6px">Oder direkte Stream-URL</label>
|
||||||
|
<input id="url-input" type="url" placeholder="https://…/stream.m3u8" autocapitalize="off" autocorrect="off">
|
||||||
|
<button class="btn-accent" id="btn-play-url">Auf TV abspielen</button>
|
||||||
|
</div>
|
||||||
|
<div class="searchrow">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#6b717a" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
|
||||||
|
<input id="search" type="search" placeholder="Sender suchen" autocapitalize="off">
|
||||||
|
</div>
|
||||||
|
<div id="channel-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<nav id="tabs" style="display:none">
|
||||||
|
<button data-view="view-remote" class="active">
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18.5h2"/></svg>
|
||||||
|
Steuerung
|
||||||
|
</button>
|
||||||
|
<button data-view="view-channels">
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h10"/></svg>
|
||||||
|
Sender
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const state = {
|
||||||
|
ws: null, connected: false, authorized: false,
|
||||||
|
status: { state: 'idle', channel: '', group: '', live: false, volume: 0.5 },
|
||||||
|
channels: [], playlistUrl: '',
|
||||||
|
retryDelay: 1000, volumeDragging: false, searchTerm: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- pairing credentials ---
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
if (params.get('t')) {
|
||||||
|
localStorage.setItem('nodecast_token', params.get('t'));
|
||||||
|
history.replaceState(null, '', location.pathname);
|
||||||
|
}
|
||||||
|
const getToken = () => localStorage.getItem('nodecast_token') || '';
|
||||||
|
let manualCode = '';
|
||||||
|
|
||||||
|
const deviceName = (() => {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
if (/iPhone/.test(ua)) return 'iPhone';
|
||||||
|
if (/iPad/.test(ua)) return 'iPad';
|
||||||
|
if (/Android/.test(ua)) return 'Android-Handy';
|
||||||
|
return 'Browser';
|
||||||
|
})();
|
||||||
|
|
||||||
|
// --- views ---
|
||||||
|
function showView(id) {
|
||||||
|
document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === id));
|
||||||
|
document.querySelectorAll('nav button').forEach(b => b.classList.toggle('active', b.dataset.view === id));
|
||||||
|
$('tabs').style.display = id === 'view-pair' ? 'none' : 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('nav button').forEach(b =>
|
||||||
|
b.addEventListener('click', () => showView(b.dataset.view)));
|
||||||
|
|
||||||
|
// --- websocket ---
|
||||||
|
function connect() {
|
||||||
|
if (state.ws && (state.ws.readyState === 0 || state.ws.readyState === 1)) return;
|
||||||
|
const ws = new WebSocket('ws://' + location.host + '/');
|
||||||
|
state.ws = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
state.connected = true;
|
||||||
|
const hello = { type: 'hello', name: deviceName };
|
||||||
|
if (getToken()) hello.token = getToken();
|
||||||
|
else if (manualCode) hello.code = manualCode;
|
||||||
|
else { showView('view-pair'); ws.close(); return; }
|
||||||
|
ws.send(JSON.stringify(hello));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
|
||||||
|
handle(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
state.connected = false; state.authorized = false;
|
||||||
|
renderConnection();
|
||||||
|
setTimeout(connect, state.retryDelay);
|
||||||
|
state.retryDelay = Math.min(state.retryDelay * 1.6, 10000);
|
||||||
|
};
|
||||||
|
ws.onerror = () => ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(obj) {
|
||||||
|
if (state.ws && state.ws.readyState === 1 && state.authorized) {
|
||||||
|
state.ws.send(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'welcome':
|
||||||
|
state.authorized = true;
|
||||||
|
state.retryDelay = 1000;
|
||||||
|
if (msg.token) localStorage.setItem('nodecast_token', msg.token);
|
||||||
|
$('tv-name').textContent = msg.device || 'TV';
|
||||||
|
if (msg.status) { state.status = msg.status; }
|
||||||
|
if (msg.channels) { state.channels = msg.channels; }
|
||||||
|
state.playlistUrl = msg.playlistUrl || '';
|
||||||
|
$('pair-error').textContent = '';
|
||||||
|
showView('view-remote');
|
||||||
|
renderAll();
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
state.status = msg;
|
||||||
|
renderStatus();
|
||||||
|
renderChannels();
|
||||||
|
break;
|
||||||
|
case 'channels':
|
||||||
|
state.channels = msg.channels || [];
|
||||||
|
state.playlistUrl = msg.playlistUrl || '';
|
||||||
|
renderChannels();
|
||||||
|
break;
|
||||||
|
case 'toast':
|
||||||
|
toast(msg.message);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
if (msg.error === 'bad_code' || msg.error === 'rate_limited') {
|
||||||
|
localStorage.removeItem('nodecast_token');
|
||||||
|
manualCode = '';
|
||||||
|
$('pair-error').textContent = msg.error === 'rate_limited'
|
||||||
|
? 'Zu viele Versuche — kurz warten.' : 'Falscher Code.';
|
||||||
|
showView('view-pair');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rendering ---
|
||||||
|
function renderConnection() {
|
||||||
|
$('conn-dot').classList.toggle('off', !state.authorized);
|
||||||
|
if (!state.authorized) $('tv-name').textContent = 'Getrennt';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatus() {
|
||||||
|
renderConnection();
|
||||||
|
const s = state.status;
|
||||||
|
const playing = s.state === 'playing' || s.state === 'buffering';
|
||||||
|
const hasMedia = s.state !== 'idle' && s.channel;
|
||||||
|
$('nowcard').classList.toggle('playing', !!hasMedia);
|
||||||
|
$('now-title').textContent = hasMedia ? s.channel : 'Nichts läuft';
|
||||||
|
$('now-sub').textContent = hasMedia
|
||||||
|
? (s.state === 'buffering' ? 'Lädt…' : s.state === 'paused' ? 'Pausiert'
|
||||||
|
: s.state === 'error' ? 'Wiedergabefehler' : (s.group || 'Wird abgespielt'))
|
||||||
|
: 'Wähle einen Sender oder starte eine URL';
|
||||||
|
$('liverow').style.visibility = s.live ? 'visible' : 'hidden';
|
||||||
|
$('ic-play').style.display = playing ? 'none' : 'block';
|
||||||
|
$('ic-pause').style.display = playing ? 'block' : 'none';
|
||||||
|
$('btn-play').disabled = !hasMedia;
|
||||||
|
if (!state.volumeDragging && typeof s.volume === 'number') {
|
||||||
|
$('volume').value = Math.round(s.volume * 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChannels() {
|
||||||
|
const list = $('channel-list');
|
||||||
|
const term = state.searchTerm.toLowerCase();
|
||||||
|
const filtered = term
|
||||||
|
? state.channels.filter(c => (c.name + ' ' + c.group).toLowerCase().includes(term))
|
||||||
|
: state.channels;
|
||||||
|
if (!state.channels.length) {
|
||||||
|
list.innerHTML = '<div class="empty">Noch keine Sender.<br>Lade oben über das Zahnrad eine M3U-Playlist.</div>';
|
||||||
|
$('setup').classList.add('open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
filtered.slice(0, 500).forEach((c) => {
|
||||||
|
const idx = state.channels.indexOf(c);
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'chan' + (c.url === state.status.url || c.name === state.status.channel ? ' current' : '');
|
||||||
|
const num = document.createElement('span');
|
||||||
|
num.className = 'num';
|
||||||
|
if (c.logo) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = c.logo; img.loading = 'lazy'; img.alt = '';
|
||||||
|
img.onerror = () => { img.remove(); num.textContent = idx + 1; };
|
||||||
|
num.appendChild(img);
|
||||||
|
} else {
|
||||||
|
num.textContent = idx + 1;
|
||||||
|
}
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'meta';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'name'; name.textContent = c.name;
|
||||||
|
meta.appendChild(name);
|
||||||
|
if (c.group) {
|
||||||
|
const grp = document.createElement('span');
|
||||||
|
grp.className = 'grp'; grp.textContent = c.group;
|
||||||
|
meta.appendChild(grp);
|
||||||
|
}
|
||||||
|
btn.appendChild(num); btn.appendChild(meta);
|
||||||
|
btn.addEventListener('click', () => playChannel(c));
|
||||||
|
frag.appendChild(btn);
|
||||||
|
});
|
||||||
|
list.innerHTML = '';
|
||||||
|
list.appendChild(frag);
|
||||||
|
if (state.playlistUrl) $('playlist-input').value = state.playlistUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() { renderStatus(); renderChannels(); }
|
||||||
|
|
||||||
|
// --- actions ---
|
||||||
|
function playChannel(c) {
|
||||||
|
send({ type: 'play', url: c.url, name: c.name, group: c.group });
|
||||||
|
showView('view-remote');
|
||||||
|
toast(c.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(dir) {
|
||||||
|
if (!state.channels.length) return;
|
||||||
|
const cur = state.channels.findIndex(c => c.name === state.status.channel);
|
||||||
|
const next = cur < 0 ? 0 : (cur + dir + state.channels.length) % state.channels.length;
|
||||||
|
playChannel(state.channels[next]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$('btn-play').addEventListener('click', () => send({ type: 'toggle' }));
|
||||||
|
$('btn-stop').addEventListener('click', () => send({ type: 'stop' }));
|
||||||
|
$('btn-prev').addEventListener('click', () => step(-1));
|
||||||
|
$('btn-next').addEventListener('click', () => step(1));
|
||||||
|
|
||||||
|
let volTimer = null;
|
||||||
|
const vol = $('volume');
|
||||||
|
vol.addEventListener('input', () => {
|
||||||
|
state.volumeDragging = true;
|
||||||
|
clearTimeout(volTimer);
|
||||||
|
volTimer = setTimeout(() => {
|
||||||
|
send({ type: 'volume', value: vol.value / 100 });
|
||||||
|
state.volumeDragging = false;
|
||||||
|
}, 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('btn-setup').addEventListener('click', () => $('setup').classList.toggle('open'));
|
||||||
|
$('btn-load-playlist').addEventListener('click', () => {
|
||||||
|
const url = $('playlist-input').value.trim();
|
||||||
|
if (!url) return;
|
||||||
|
send({ type: 'set_playlist', url });
|
||||||
|
toast('Playlist wird geladen…');
|
||||||
|
});
|
||||||
|
$('btn-play-url').addEventListener('click', () => {
|
||||||
|
const url = $('url-input').value.trim();
|
||||||
|
if (!url) return;
|
||||||
|
send({ type: 'play', url, name: url.split('/').pop() || url, group: '' });
|
||||||
|
showView('view-remote');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('search').addEventListener('input', (e) => {
|
||||||
|
state.searchTerm = e.target.value;
|
||||||
|
renderChannels();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('pair-btn').addEventListener('click', () => {
|
||||||
|
const code = $('code-input').value.trim();
|
||||||
|
if (code.length !== 4) { $('pair-error').textContent = 'Bitte 4 Ziffern eingeben.'; return; }
|
||||||
|
manualCode = code;
|
||||||
|
$('pair-error').textContent = '';
|
||||||
|
if (state.ws) state.ws.close(); else connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
let toastTimer = null;
|
||||||
|
function toast(text) {
|
||||||
|
const t = $('toast');
|
||||||
|
t.textContent = text;
|
||||||
|
t.classList.add('show');
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => t.classList.remove('show'), 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
state.retryDelay = 1000;
|
||||||
|
connect();
|
||||||
|
send({ type: 'get_state' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- boot ---
|
||||||
|
if (getToken()) { connect(); showView('view-remote'); }
|
||||||
|
else { showView('view-pair'); }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
281
app/src/main/java/com/nodecast/tv/MainActivity.kt
Normal file
281
app/src/main/java/com/nodecast/tv/MainActivity.kt
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
package com.nodecast.tv
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import com.nodecast.tv.pairing.Pairing
|
||||||
|
import com.nodecast.tv.pairing.Qr
|
||||||
|
import com.nodecast.tv.player.PlayerController
|
||||||
|
import com.nodecast.tv.playlist.Channel
|
||||||
|
import com.nodecast.tv.playlist.PlaylistRepository
|
||||||
|
import com.nodecast.tv.server.ControlServer
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class MainActivity : Activity(), ControlServer.Listener {
|
||||||
|
|
||||||
|
private lateinit var playerController: PlayerController
|
||||||
|
private lateinit var server: ControlServer
|
||||||
|
private lateinit var playlist: PlaylistRepository
|
||||||
|
private lateinit var audioManager: AudioManager
|
||||||
|
|
||||||
|
private lateinit var pairingScreen: View
|
||||||
|
private lateinit var playerScreen: View
|
||||||
|
private lateinit var playerView: PlayerView
|
||||||
|
private lateinit var overlay: View
|
||||||
|
private lateinit var overlayChannel: TextView
|
||||||
|
private lateinit var overlayState: TextView
|
||||||
|
private lateinit var overlayLiveDot: View
|
||||||
|
private lateinit var overlayLiveLabel: TextView
|
||||||
|
private lateinit var overlayProgress: View
|
||||||
|
private lateinit var deviceChip: View
|
||||||
|
private lateinit var deviceName: TextView
|
||||||
|
private lateinit var pairingStatus: TextView
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val hideOverlay = Runnable { overlay.animate().alpha(0f).setDuration(400).start() }
|
||||||
|
private val ticker = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
updateProgress()
|
||||||
|
server.broadcastStatus()
|
||||||
|
mainHandler.postDelayed(this, TICK_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var connectedName: String? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
|
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
||||||
|
playlist = PlaylistRepository(this)
|
||||||
|
playerController = PlayerController(this) { onPlaybackChanged() }
|
||||||
|
|
||||||
|
bindViews()
|
||||||
|
setupPairingScreen()
|
||||||
|
|
||||||
|
server = ControlServer(this, this)
|
||||||
|
server.startServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindViews() {
|
||||||
|
pairingScreen = findViewById(R.id.pairing_screen)
|
||||||
|
playerScreen = findViewById(R.id.player_screen)
|
||||||
|
playerView = findViewById(R.id.player_view)
|
||||||
|
overlay = findViewById(R.id.overlay)
|
||||||
|
overlayChannel = findViewById(R.id.overlay_channel)
|
||||||
|
overlayState = findViewById(R.id.overlay_state)
|
||||||
|
overlayLiveDot = findViewById(R.id.overlay_live_dot)
|
||||||
|
overlayLiveLabel = findViewById(R.id.overlay_live_label)
|
||||||
|
overlayProgress = findViewById(R.id.overlay_progress)
|
||||||
|
deviceChip = findViewById(R.id.device_chip)
|
||||||
|
deviceName = findViewById(R.id.device_name)
|
||||||
|
pairingStatus = findViewById(R.id.pairing_status)
|
||||||
|
|
||||||
|
playerView.useController = false
|
||||||
|
playerView.player = playerController.player
|
||||||
|
overlayProgress.pivotX = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupPairingScreen() {
|
||||||
|
val address = Pairing.lanAddress()
|
||||||
|
val qrImage = findViewById<ImageView>(R.id.qr_image)
|
||||||
|
val urlText = findViewById<TextView>(R.id.pairing_url)
|
||||||
|
val codeText = findViewById<TextView>(R.id.pairing_code)
|
||||||
|
codeText.text = getString(R.string.pairing_code, Pairing.code(this))
|
||||||
|
if (address != null) {
|
||||||
|
urlText.text = Pairing.remoteUrl(address)
|
||||||
|
qrImage.setImageBitmap(
|
||||||
|
Qr.encode(Pairing.pairingUrl(this, address), QR_SIZE_PX, Color.parseColor("#101216"))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
urlText.text = getString(R.string.no_network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- playback state → UI + remote ---
|
||||||
|
|
||||||
|
private fun onPlaybackChanged() {
|
||||||
|
val showPlayer = playerController.hasMedia
|
||||||
|
pairingScreen.visibility = if (showPlayer) View.GONE else View.VISIBLE
|
||||||
|
playerScreen.visibility = if (showPlayer) View.VISIBLE else View.GONE
|
||||||
|
val active = playerController.state == "playing" || playerController.state == "buffering"
|
||||||
|
playerScreen.keepScreenOn = active
|
||||||
|
|
||||||
|
mainHandler.removeCallbacks(ticker)
|
||||||
|
if (showPlayer) {
|
||||||
|
overlayChannel.text = playerController.channelName
|
||||||
|
val live = playerController.player.isCurrentMediaItemLive
|
||||||
|
overlayLiveDot.visibility = if (live) View.VISIBLE else View.GONE
|
||||||
|
overlayLiveLabel.text = if (live) getString(R.string.live) else ""
|
||||||
|
overlayState.text = when (playerController.state) {
|
||||||
|
"paused" -> getString(R.string.paused)
|
||||||
|
"buffering" -> getString(R.string.buffering)
|
||||||
|
"error" -> getString(R.string.playback_error, playerController.errorMessage.orEmpty())
|
||||||
|
else -> getString(R.string.playing)
|
||||||
|
}
|
||||||
|
updateProgress()
|
||||||
|
showOverlay(autoHide = playerController.state == "playing")
|
||||||
|
if (active) mainHandler.postDelayed(ticker, TICK_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
onClientsChanged(-1, null)
|
||||||
|
server.broadcastStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateProgress() {
|
||||||
|
val player = playerController.player
|
||||||
|
val fraction = if (player.isCurrentMediaItemLive || player.duration <= 0) {
|
||||||
|
1f
|
||||||
|
} else {
|
||||||
|
(player.currentPosition.toFloat() / player.duration).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
overlayProgress.scaleX = fraction
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showOverlay(autoHide: Boolean) {
|
||||||
|
mainHandler.removeCallbacks(hideOverlay)
|
||||||
|
overlay.animate().alpha(1f).setDuration(200).start()
|
||||||
|
if (autoHide) mainHandler.postDelayed(hideOverlay, OVERLAY_HIDE_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ControlServer.Listener (called on main thread) ---
|
||||||
|
|
||||||
|
override fun onPlay(url: String, name: String, group: String) {
|
||||||
|
playerController.play(url, name, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTogglePlay() = playerController.toggle()
|
||||||
|
|
||||||
|
override fun onPause() = playerController.pause()
|
||||||
|
|
||||||
|
override fun onResume() = playerController.resume()
|
||||||
|
|
||||||
|
override fun onStopCast() = playerController.stop()
|
||||||
|
|
||||||
|
override fun onSeek(deltaSeconds: Long) {
|
||||||
|
playerController.seekBy(deltaSeconds)
|
||||||
|
showOverlay(autoHide = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVolume(value: Float) {
|
||||||
|
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, (value * max).toInt().coerceIn(0, max), 0)
|
||||||
|
server.broadcastStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSetPlaylist(url: String) {
|
||||||
|
playlist.refresh(url) { result ->
|
||||||
|
mainHandler.post {
|
||||||
|
result
|
||||||
|
.onSuccess { server.broadcastChannels() }
|
||||||
|
.onFailure { server.broadcastToast(getString(R.string.playlist_error)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClientsChanged(count: Int, newestName: String?) {
|
||||||
|
if (newestName != null) connectedName = newestName
|
||||||
|
if (count >= 0) {
|
||||||
|
pairingStatus.text = if (count > 0) {
|
||||||
|
getString(R.string.paired_with, connectedName.orEmpty())
|
||||||
|
} else {
|
||||||
|
getString(R.string.ready_to_pair)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val chipVisible = connectedName != null && playerController.hasMedia
|
||||||
|
deviceChip.visibility = if (chipVisible) View.VISIBLE else View.GONE
|
||||||
|
deviceName.text = connectedName.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun currentStatus(): JSONObject {
|
||||||
|
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
val vol = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||||
|
val player = playerController.player
|
||||||
|
return JSONObject()
|
||||||
|
.put("state", playerController.state)
|
||||||
|
.put("channel", playerController.channelName)
|
||||||
|
.put("group", playerController.channelGroup)
|
||||||
|
.put("live", player.isCurrentMediaItemLive)
|
||||||
|
.put("seekable", player.isCurrentMediaItemSeekable)
|
||||||
|
.put("position", player.currentPosition.coerceAtLeast(0))
|
||||||
|
.put("duration", player.duration.coerceAtLeast(0))
|
||||||
|
.put("volume", if (max > 0) vol.toDouble() / max else 0.0)
|
||||||
|
.put("error", playerController.errorMessage ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun currentChannels(): List<Channel> = playlist.channels
|
||||||
|
|
||||||
|
override fun currentPlaylistUrl(): String = playlist.playlistUrl
|
||||||
|
|
||||||
|
// --- TV remote keys (TV-PC / TV-PP) ---
|
||||||
|
|
||||||
|
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||||
|
if (playerController.hasMedia) {
|
||||||
|
when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_CENTER,
|
||||||
|
KeyEvent.KEYCODE_ENTER,
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||||
|
playerController.toggle()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
||||||
|
playerController.resume()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
||||||
|
playerController.pause()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||||
|
playerController.stop()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||||
|
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||||
|
onSeek(-SEEK_STEP_SECONDS)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||||
|
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||||
|
onSeek(SEEK_STEP_SECONDS)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_BACK -> {
|
||||||
|
playerController.stop()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.onKeyDown(keyCode, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- lifecycle ---
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
// TV-NP: video must not keep playing when the user leaves the app.
|
||||||
|
playerController.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
mainHandler.removeCallbacksAndMessages(null)
|
||||||
|
server.stopServer()
|
||||||
|
playerController.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val QR_SIZE_PX = 512
|
||||||
|
const val OVERLAY_HIDE_DELAY_MS = 4_000L
|
||||||
|
const val TICK_INTERVAL_MS = 2_000L
|
||||||
|
const val SEEK_STEP_SECONDS = 10L
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/src/main/java/com/nodecast/tv/pairing/Pairing.kt
Normal file
54
app/src/main/java/com/nodecast/tv/pairing/Pairing.kt
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package com.nodecast.tv.pairing
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
|
object Pairing {
|
||||||
|
|
||||||
|
const val PORT = 8765
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Four-digit pairing code, generated once and kept stable so an already
|
||||||
|
* paired phone survives app restarts. Human fallback only — the QR code
|
||||||
|
* carries the long token below.
|
||||||
|
*/
|
||||||
|
fun code(context: Context): String {
|
||||||
|
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE)
|
||||||
|
prefs.getString("code", null)?.let { return it }
|
||||||
|
val code = "%04d".format(SecureRandom().nextInt(10_000))
|
||||||
|
prefs.edit().putString("code", code).apply()
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 128-bit random token embedded in the QR code; not brute-forceable. */
|
||||||
|
fun token(context: Context): String {
|
||||||
|
val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE)
|
||||||
|
prefs.getString("token", null)?.let { return it }
|
||||||
|
val bytes = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||||
|
val token = bytes.joinToString("") { "%02x".format(it) }
|
||||||
|
prefs.edit().putString("token", token).apply()
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-guess LAN IPv4 address of this device, or null when offline. */
|
||||||
|
fun lanAddress(): String? {
|
||||||
|
val candidates = runCatching {
|
||||||
|
NetworkInterface.getNetworkInterfaces().asSequence()
|
||||||
|
.filter { it.isUp && !it.isLoopback }
|
||||||
|
.flatMap { it.inetAddresses.asSequence() }
|
||||||
|
.filterIsInstance<Inet4Address>()
|
||||||
|
.filter { it.isSiteLocalAddress }
|
||||||
|
.map { it.hostAddress }
|
||||||
|
.filterNotNull()
|
||||||
|
.toList()
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
return candidates.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remoteUrl(address: String): String = "http://$address:$PORT"
|
||||||
|
|
||||||
|
fun pairingUrl(context: Context, address: String): String =
|
||||||
|
"${remoteUrl(address)}/?t=${token(context)}"
|
||||||
|
}
|
||||||
26
app/src/main/java/com/nodecast/tv/pairing/Qr.kt
Normal file
26
app/src/main/java/com/nodecast/tv/pairing/Qr.kt
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package com.nodecast.tv.pairing
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
|
|
||||||
|
object Qr {
|
||||||
|
|
||||||
|
fun encode(content: String, size: Int, foreground: Int, background: Int = Color.WHITE): Bitmap {
|
||||||
|
val hints = mapOf(
|
||||||
|
EncodeHintType.MARGIN to 0,
|
||||||
|
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M,
|
||||||
|
)
|
||||||
|
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
|
||||||
|
val pixels = IntArray(size * size)
|
||||||
|
for (y in 0 until size) {
|
||||||
|
for (x in 0 until size) {
|
||||||
|
pixels[y * size + x] = if (matrix.get(x, y)) foreground else background
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
|
||||||
|
}
|
||||||
|
}
|
||||||
105
app/src/main/java/com/nodecast/tv/player/PlayerController.kt
Normal file
105
app/src/main/java/com/nodecast/tv/player/PlayerController.kt
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package com.nodecast.tv.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MediaMetadata
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.session.MediaSession
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause
|
||||||
|
* keys and Google Assistant work alongside the phone remote (TV-PP/TV-VC of
|
||||||
|
* the TV app quality guidelines).
|
||||||
|
*/
|
||||||
|
class PlayerController(
|
||||||
|
context: Context,
|
||||||
|
private val onChanged: () -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val player: ExoPlayer = ExoPlayer.Builder(context).build()
|
||||||
|
private val mediaSession: MediaSession = MediaSession.Builder(context, player).build()
|
||||||
|
|
||||||
|
var channelName: String = ""
|
||||||
|
private set
|
||||||
|
var channelGroup: String = ""
|
||||||
|
private set
|
||||||
|
var errorMessage: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
player.addListener(object : Player.Listener {
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) = onChanged()
|
||||||
|
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()
|
||||||
|
|
||||||
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
|
errorMessage = error.errorCodeName
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
val state: String
|
||||||
|
get() = when {
|
||||||
|
errorMessage != null -> "error"
|
||||||
|
player.playbackState == Player.STATE_BUFFERING -> "buffering"
|
||||||
|
player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing"
|
||||||
|
player.playbackState == Player.STATE_READY -> "paused"
|
||||||
|
else -> "idle"
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasMedia: Boolean
|
||||||
|
get() = player.mediaItemCount > 0 && player.playbackState != Player.STATE_IDLE
|
||||||
|
|
||||||
|
fun play(url: String, name: String, group: String) {
|
||||||
|
if (url.isEmpty()) return
|
||||||
|
errorMessage = null
|
||||||
|
channelName = name.ifEmpty { url }
|
||||||
|
channelGroup = group
|
||||||
|
val item = MediaItem.Builder()
|
||||||
|
.setUri(url)
|
||||||
|
.setMediaMetadata(MediaMetadata.Builder().setTitle(channelName).build())
|
||||||
|
.build()
|
||||||
|
player.setMediaItem(item)
|
||||||
|
player.prepare()
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggle() {
|
||||||
|
if (!hasMedia) return
|
||||||
|
if (player.isPlaying) player.pause() else resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
if (player.isPlaying) player.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resume() {
|
||||||
|
if (!hasMedia) return
|
||||||
|
if (player.playbackState == Player.STATE_ENDED) player.seekToDefaultPosition()
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
player.stop()
|
||||||
|
player.clearMediaItems()
|
||||||
|
channelName = ""
|
||||||
|
channelGroup = ""
|
||||||
|
errorMessage = null
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekBy(deltaSeconds: Long) {
|
||||||
|
if (!hasMedia || !player.isCurrentMediaItemSeekable) return
|
||||||
|
val target = (player.currentPosition + deltaSeconds * 1000)
|
||||||
|
.coerceIn(0, player.duration.coerceAtLeast(0))
|
||||||
|
player.seekTo(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
mediaSession.release()
|
||||||
|
player.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/src/main/java/com/nodecast/tv/playlist/Channel.kt
Normal file
35
app/src/main/java/com/nodecast/tv/playlist/Channel.kt
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package com.nodecast.tv.playlist
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
data class Channel(
|
||||||
|
val name: String,
|
||||||
|
val url: String,
|
||||||
|
val group: String,
|
||||||
|
val logo: String,
|
||||||
|
) {
|
||||||
|
fun toJson(): JSONObject = JSONObject()
|
||||||
|
.put("name", name)
|
||||||
|
.put("url", url)
|
||||||
|
.put("group", group)
|
||||||
|
.put("logo", logo)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromJson(obj: JSONObject): Channel = Channel(
|
||||||
|
name = obj.optString("name"),
|
||||||
|
url = obj.optString("url"),
|
||||||
|
group = obj.optString("group"),
|
||||||
|
logo = obj.optString("logo"),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun listToJson(channels: List<Channel>): JSONArray {
|
||||||
|
val arr = JSONArray()
|
||||||
|
channels.forEach { arr.put(it.toJson()) }
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
fun listFromJson(arr: JSONArray): List<Channel> =
|
||||||
|
(0 until arr.length()).map { fromJson(arr.getJSONObject(it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/src/main/java/com/nodecast/tv/playlist/M3uParser.kt
Normal file
48
app/src/main/java/com/nodecast/tv/playlist/M3uParser.kt
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package com.nodecast.tv.playlist
|
||||||
|
|
||||||
|
object M3uParser {
|
||||||
|
|
||||||
|
private const val MAX_CHANNELS = 5000
|
||||||
|
|
||||||
|
private val attrRegex = Regex("""([\w-]+)="([^"]*)"""")
|
||||||
|
|
||||||
|
fun parse(content: String): List<Channel> {
|
||||||
|
val channels = mutableListOf<Channel>()
|
||||||
|
var name = ""
|
||||||
|
var group = ""
|
||||||
|
var logo = ""
|
||||||
|
var pendingInfo = false
|
||||||
|
|
||||||
|
for (rawLine in content.lineSequence()) {
|
||||||
|
val line = rawLine.trim()
|
||||||
|
when {
|
||||||
|
line.startsWith("#EXTINF", ignoreCase = true) -> {
|
||||||
|
val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] }
|
||||||
|
group = attrs["group-title"].orEmpty()
|
||||||
|
logo = attrs["tvg-logo"].orEmpty()
|
||||||
|
name = line.substringAfterLast(',', "").trim()
|
||||||
|
if (name.isEmpty()) name = attrs["tvg-name"].orEmpty()
|
||||||
|
pendingInfo = true
|
||||||
|
}
|
||||||
|
line.startsWith("#EXTGRP", ignoreCase = true) -> {
|
||||||
|
group = line.substringAfter(':', "").trim()
|
||||||
|
}
|
||||||
|
line.isEmpty() || line.startsWith("#") -> Unit
|
||||||
|
pendingInfo -> {
|
||||||
|
channels.add(Channel(name.ifEmpty { line }, line, group, logo))
|
||||||
|
if (channels.size >= MAX_CHANNELS) return channels
|
||||||
|
name = ""
|
||||||
|
group = ""
|
||||||
|
logo = ""
|
||||||
|
pendingInfo = false
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Bare URL without #EXTINF — still a playable entry.
|
||||||
|
channels.add(Channel(line, line, "", ""))
|
||||||
|
if (channels.size >= MAX_CHANNELS) return channels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return channels
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.nodecast.tv.playlist
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and caches the M3U playlist. The channel list is persisted so the
|
||||||
|
* remote sees its channels again right after an app restart.
|
||||||
|
*/
|
||||||
|
class PlaylistRepository(context: Context) {
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences("playlist", Context.MODE_PRIVATE)
|
||||||
|
private val executor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
var channels: List<Channel> = loadCached()
|
||||||
|
private set
|
||||||
|
|
||||||
|
val playlistUrl: String
|
||||||
|
get() = prefs.getString(KEY_URL, "").orEmpty()
|
||||||
|
|
||||||
|
fun refresh(url: String, onResult: (Result<List<Channel>>) -> Unit) {
|
||||||
|
executor.execute {
|
||||||
|
val result = runCatching {
|
||||||
|
val content = download(url)
|
||||||
|
val parsed = M3uParser.parse(content)
|
||||||
|
require(parsed.isNotEmpty()) { "playlist is empty" }
|
||||||
|
parsed
|
||||||
|
}
|
||||||
|
result.onSuccess { parsed ->
|
||||||
|
channels = parsed
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_URL, url)
|
||||||
|
.putString(KEY_CACHE, Channel.listToJson(parsed).toString())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
onResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download(url: String): String {
|
||||||
|
val connection = URL(url).openConnection() as HttpURLConnection
|
||||||
|
return try {
|
||||||
|
connection.connectTimeout = 15_000
|
||||||
|
connection.readTimeout = 30_000
|
||||||
|
connection.instanceFollowRedirects = true
|
||||||
|
connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCached(): List<Channel> = runCatching {
|
||||||
|
Channel.listFromJson(JSONArray(prefs.getString(KEY_CACHE, "[]").orEmpty()))
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val KEY_URL = "url"
|
||||||
|
const val KEY_CACHE = "channels"
|
||||||
|
}
|
||||||
|
}
|
||||||
244
app/src/main/java/com/nodecast/tv/server/ControlServer.kt
Normal file
244
app/src/main/java/com/nodecast/tv/server/ControlServer.kt
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
package com.nodecast.tv.server
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import com.nodecast.tv.pairing.Pairing
|
||||||
|
import com.nodecast.tv.playlist.Channel
|
||||||
|
import fi.iki.elonen.NanoHTTPD
|
||||||
|
import fi.iki.elonen.NanoWSD
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.Timer
|
||||||
|
import java.util.TimerTask
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
|
||||||
|
* page) over HTTP and takes playback commands over a WebSocket. A client
|
||||||
|
* authorizes itself with the four-digit pairing code from the QR code.
|
||||||
|
*/
|
||||||
|
class ControlServer(
|
||||||
|
private val context: Context,
|
||||||
|
private val listener: Listener,
|
||||||
|
) : NanoWSD(Pairing.PORT) {
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
fun onPlay(url: String, name: String, group: String)
|
||||||
|
fun onTogglePlay()
|
||||||
|
fun onPause()
|
||||||
|
fun onResume()
|
||||||
|
fun onStopCast()
|
||||||
|
fun onSeek(deltaSeconds: Long)
|
||||||
|
fun onVolume(value: Float)
|
||||||
|
fun onSetPlaylist(url: String)
|
||||||
|
fun onClientsChanged(count: Int, newestName: String?)
|
||||||
|
fun currentStatus(): JSONObject
|
||||||
|
fun currentChannels(): List<Channel>
|
||||||
|
fun currentPlaylistUrl(): String
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val clients = CopyOnWriteArrayList<RemoteSocket>()
|
||||||
|
private val pairingCode = Pairing.code(context)
|
||||||
|
private val pairingToken = Pairing.token(context)
|
||||||
|
private var pingTimer: Timer? = null
|
||||||
|
|
||||||
|
// Rate limit for the human-typable 4-digit code (the QR token is not
|
||||||
|
// brute-forceable and stays exempt).
|
||||||
|
private val codeAttempts = ArrayDeque<Long>()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun codeAttemptAllowed(): Boolean {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
while (codeAttempts.isNotEmpty() && now - codeAttempts.first() > CODE_ATTEMPT_WINDOW_MS) {
|
||||||
|
codeAttempts.removeFirst()
|
||||||
|
}
|
||||||
|
if (codeAttempts.size >= CODE_ATTEMPT_MAX) return false
|
||||||
|
codeAttempts.addLast(now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startServer() {
|
||||||
|
start(0, true)
|
||||||
|
pingTimer = Timer("ws-ping", true).also {
|
||||||
|
it.schedule(object : TimerTask() {
|
||||||
|
override fun run() = pingClients()
|
||||||
|
}, PING_INTERVAL_MS, PING_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopServer() {
|
||||||
|
pingTimer?.cancel()
|
||||||
|
pingTimer = null
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP ---
|
||||||
|
|
||||||
|
override fun serveHttp(session: IHTTPSession): Response {
|
||||||
|
return when (session.uri) {
|
||||||
|
"/", "/index.html" -> {
|
||||||
|
val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() }
|
||||||
|
newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply {
|
||||||
|
addHeader("Cache-Control", "no-store")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WebSocket ---
|
||||||
|
|
||||||
|
override fun openWebSocket(handshake: IHTTPSession): WebSocket = RemoteSocket(handshake)
|
||||||
|
|
||||||
|
fun broadcast(message: JSONObject) {
|
||||||
|
val payload = message.toString()
|
||||||
|
clients.forEach { client ->
|
||||||
|
if (client.authorized) client.trySend(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun broadcastStatus() {
|
||||||
|
broadcast(listener.currentStatus().put("type", "status"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun broadcastChannels() {
|
||||||
|
broadcast(channelsMessage())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun broadcastToast(message: String) {
|
||||||
|
broadcast(JSONObject().put("type", "toast").put("message", message))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun channelsMessage(): JSONObject = JSONObject()
|
||||||
|
.put("type", "channels")
|
||||||
|
.put("playlistUrl", listener.currentPlaylistUrl())
|
||||||
|
.put("channels", Channel.listToJson(listener.currentChannels()))
|
||||||
|
|
||||||
|
private fun pingClients() {
|
||||||
|
clients.forEach { client ->
|
||||||
|
try {
|
||||||
|
client.ping(PING_PAYLOAD)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.d(TAG, "ping failed, dropping client", e)
|
||||||
|
clients.remove(client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyClientsChanged(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyClientsChanged(newestName: String?) {
|
||||||
|
val count = clients.count { it.authorized }
|
||||||
|
mainHandler.post { listener.onClientsChanged(count, newestName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class RemoteSocket(handshake: IHTTPSession) : WebSocket(handshake) {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var authorized = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var deviceName: String = ""
|
||||||
|
|
||||||
|
fun trySend(payload: String) {
|
||||||
|
try {
|
||||||
|
send(payload)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.d(TAG, "send failed, dropping client", e)
|
||||||
|
clients.remove(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOpen() {
|
||||||
|
clients.add(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClose(code: WebSocketFrame.CloseCode?, reason: String?, initiatedByRemote: Boolean) {
|
||||||
|
clients.remove(this)
|
||||||
|
notifyClientsChanged(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(message: WebSocketFrame) {
|
||||||
|
val msg = runCatching { JSONObject(message.textPayload) }.getOrNull() ?: return
|
||||||
|
val type = msg.optString("type")
|
||||||
|
if (!authorized) {
|
||||||
|
if (type == "hello") handleHello(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when (type) {
|
||||||
|
"play" -> post {
|
||||||
|
listener.onPlay(
|
||||||
|
msg.optString("url"),
|
||||||
|
msg.optString("name"),
|
||||||
|
msg.optString("group"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"toggle" -> post { listener.onTogglePlay() }
|
||||||
|
"pause" -> post { listener.onPause() }
|
||||||
|
"resume" -> post { listener.onResume() }
|
||||||
|
"stop" -> post { listener.onStopCast() }
|
||||||
|
"seek" -> post { listener.onSeek(msg.optLong("delta")) }
|
||||||
|
"volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) }
|
||||||
|
"set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
|
||||||
|
"get_state" -> {
|
||||||
|
trySend(listener.currentStatus().put("type", "status").toString())
|
||||||
|
trySend(channelsMessage().toString())
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPong(pong: WebSocketFrame?) = Unit
|
||||||
|
|
||||||
|
override fun onException(exception: IOException?) {
|
||||||
|
clients.remove(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleHello(msg: JSONObject) {
|
||||||
|
val token = msg.optString("token")
|
||||||
|
val tokenOk = token.isNotEmpty() && token == pairingToken
|
||||||
|
val codeOk = !tokenOk && msg.optString("code").let { code ->
|
||||||
|
code.isNotEmpty() && when {
|
||||||
|
!codeAttemptAllowed() -> {
|
||||||
|
trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
|
||||||
|
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
else -> code == pairingCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!tokenOk && !codeOk) {
|
||||||
|
trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
|
||||||
|
runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authorized = true
|
||||||
|
deviceName = msg.optString("name").ifEmpty { "Handy" }
|
||||||
|
trySend(
|
||||||
|
JSONObject()
|
||||||
|
.put("type", "welcome")
|
||||||
|
.put("device", android.os.Build.MODEL)
|
||||||
|
.put("token", pairingToken)
|
||||||
|
.put("status", listener.currentStatus())
|
||||||
|
.put("playlistUrl", listener.currentPlaylistUrl())
|
||||||
|
.put("channels", Channel.listToJson(listener.currentChannels()))
|
||||||
|
.toString()
|
||||||
|
)
|
||||||
|
notifyClientsChanged(deviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(action: () -> Unit) {
|
||||||
|
mainHandler.post(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ControlServer"
|
||||||
|
const val PING_INTERVAL_MS = 8_000L
|
||||||
|
const val CODE_ATTEMPT_WINDOW_MS = 60_000L
|
||||||
|
const val CODE_ATTEMPT_MAX = 5
|
||||||
|
val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/src/main/res/drawable/bg_chip.xml
Normal file
6
app/src/main/res/drawable/bg_chip.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<solid android:color="@color/chip_bg" />
|
||||||
|
<stroke android:width="1dp" android:color="@color/chip_stroke" />
|
||||||
|
<corners android:radius="100dp" />
|
||||||
|
</shape>
|
||||||
5
app/src/main/res/drawable/bg_qr_card.xml
Normal file
5
app/src/main/res/drawable/bg_qr_card.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<solid android:color="@color/card" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
||||||
4
app/src/main/res/drawable/dot_accent.xml
Normal file
4
app/src/main/res/drawable/dot_accent.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||||
|
<solid android:color="@color/accent" />
|
||||||
|
</shape>
|
||||||
4
app/src/main/res/drawable/dot_live.xml
Normal file
4
app/src/main/res/drawable/dot_live.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||||
|
<solid android:color="@color/live" />
|
||||||
|
</shape>
|
||||||
17
app/src/main/res/drawable/ic_cast.xml
Normal file
17
app/src/main/res/drawable/ic_cast.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M3,6.5 A1.5,1.5 0 0 1 4.5,5 L19.5,5 A1.5,1.5 0 0 1 21,6.5 L21,16.5 A1.5,1.5 0 0 1 19.5,18 L4.5,18 A1.5,1.5 0 0 1 3,16.5 Z"
|
||||||
|
android:strokeColor="#5FD4C4"
|
||||||
|
android:strokeWidth="1.6"
|
||||||
|
android:strokeLineCap="round" />
|
||||||
|
<path
|
||||||
|
android:pathData="M8.5,21.5 L15.5,21.5"
|
||||||
|
android:strokeColor="#5FD4C4"
|
||||||
|
android:strokeWidth="1.6"
|
||||||
|
android:strokeLineCap="round" />
|
||||||
|
</vector>
|
||||||
23
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
23
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<group
|
||||||
|
android:scaleX="1.8"
|
||||||
|
android:scaleY="1.8"
|
||||||
|
android:pivotX="54"
|
||||||
|
android:pivotY="54">
|
||||||
|
<path
|
||||||
|
android:pathData="M42,48.5 A1.5,1.5 0 0 1 43.5,47 L64.5,47 A1.5,1.5 0 0 1 66,48.5 L66,59.5 A1.5,1.5 0 0 1 64.5,61 L43.5,61 A1.5,1.5 0 0 1 42,59.5 Z"
|
||||||
|
android:strokeColor="#5FD4C4"
|
||||||
|
android:strokeWidth="1.8"
|
||||||
|
android:strokeLineCap="round" />
|
||||||
|
<path
|
||||||
|
android:pathData="M50,64.5 L58,64.5"
|
||||||
|
android:strokeColor="#5FD4C4"
|
||||||
|
android:strokeWidth="1.8"
|
||||||
|
android:strokeLineCap="round" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
5
app/src/main/res/drawable/progress_line.xml
Normal file
5
app/src/main/res/drawable/progress_line.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<solid android:color="@color/accent" />
|
||||||
|
<corners android:radius="1dp" />
|
||||||
|
</shape>
|
||||||
7
app/src/main/res/drawable/scrim_bottom.xml
Normal file
7
app/src/main/res/drawable/scrim_bottom.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<gradient
|
||||||
|
android:angle="90"
|
||||||
|
android:startColor="#EB05060A"
|
||||||
|
android:endColor="#0005060A" />
|
||||||
|
</shape>
|
||||||
26
app/src/main/res/drawable/tv_banner.xml
Normal file
26
app/src/main/res/drawable/tv_banner.xml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="@color/bg" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item android:gravity="center" android:width="80dp" android:height="80dp">
|
||||||
|
<vector
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M3,6.5 A1.5,1.5 0 0 1 4.5,5 L19.5,5 A1.5,1.5 0 0 1 21,6.5 L21,16.5 A1.5,1.5 0 0 1 19.5,18 L4.5,18 A1.5,1.5 0 0 1 3,16.5 Z"
|
||||||
|
android:strokeColor="#5FD4C4"
|
||||||
|
android:strokeWidth="1.6"
|
||||||
|
android:strokeLineCap="round" />
|
||||||
|
<path
|
||||||
|
android:pathData="M8.5,21.5 L15.5,21.5"
|
||||||
|
android:strokeColor="#5FD4C4"
|
||||||
|
android:strokeWidth="1.6"
|
||||||
|
android:strokeLineCap="round" />
|
||||||
|
</vector>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
BIN
app/src/main/res/font/space_grotesk.ttf
Normal file
BIN
app/src/main/res/font/space_grotesk.ttf
Normal file
Binary file not shown.
236
app/src/main/res/layout/activity_main.xml
Normal file
236
app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/bg">
|
||||||
|
|
||||||
|
<!-- Pairing screen -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/pairing_screen"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="center_horizontal">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_cast" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:text="@string/app_name"
|
||||||
|
android:textAllCaps="true"
|
||||||
|
android:textColor="@color/fg"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:letterSpacing="0.22" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="30dp"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:background="@drawable/bg_qr_card">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/qr_image"
|
||||||
|
android:layout_width="170dp"
|
||||||
|
android:layout_height="170dp"
|
||||||
|
android:importantForAccessibility="no" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="30dp"
|
||||||
|
android:text="@string/pair_title"
|
||||||
|
android:textColor="@color/fg"
|
||||||
|
android:textSize="26sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:maxWidth="440dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/pair_subtitle"
|
||||||
|
android:textColor="@color/muted"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:lineSpacingMultiplier="1.3" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingStart="36dp"
|
||||||
|
android:paddingEnd="36dp"
|
||||||
|
android:paddingBottom="26dp">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="7dp"
|
||||||
|
android:layout_height="7dp"
|
||||||
|
android:background="@drawable/dot_accent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/pairing_status"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="@string/ready_to_pair"
|
||||||
|
android:textColor="@color/faint"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/pairing_url"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textColor="@color/faint"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="1dp"
|
||||||
|
android:layout_height="12dp"
|
||||||
|
android:layout_marginStart="14dp"
|
||||||
|
android:layout_marginEnd="14dp"
|
||||||
|
android:background="@color/line" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/pairing_code"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/muted"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<!-- Player screen -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/player_screen"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:background="@color/bg_deep">
|
||||||
|
|
||||||
|
<androidx.media3.ui.PlayerView
|
||||||
|
android:id="@+id/player_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/overlay"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="200dp"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:background="@drawable/scrim_bottom" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/device_chip"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="top|end"
|
||||||
|
android:layout_marginTop="22dp"
|
||||||
|
android:layout_marginEnd="30dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingStart="14dp"
|
||||||
|
android:paddingEnd="14dp"
|
||||||
|
android:paddingTop="7dp"
|
||||||
|
android:paddingBottom="7dp"
|
||||||
|
android:background="@drawable/bg_chip"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/device_name"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/muted"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="5dp"
|
||||||
|
android:layout_height="5dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:background="@drawable/dot_accent" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="36dp"
|
||||||
|
android:paddingEnd="36dp"
|
||||||
|
android:paddingBottom="30dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/overlay_live_dot"
|
||||||
|
android:layout_width="6dp"
|
||||||
|
android:layout_height="6dp"
|
||||||
|
android:background="@drawable/dot_live" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/overlay_live_label"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:textColor="@color/muted"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:letterSpacing="0.18" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/overlay_state"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/muted"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/overlay_channel"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textColor="@color/fg"
|
||||||
|
android:textSize="29sp" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/overlay_progress"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="2dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:background="@drawable/progress_line" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
</FrameLayout>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/bg" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
14
app/src/main/res/values-de/strings.xml
Normal file
14
app/src/main/res/values-de/strings.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="pair_title">Mit dem Handy koppeln</string>
|
||||||
|
<string name="pair_subtitle">Richte die Kamera auf den Code — die Fernbedienung öffnet sich direkt im Browser.</string>
|
||||||
|
<string name="ready_to_pair">Bereit zum Koppeln</string>
|
||||||
|
<string name="paired_with">Gekoppelt mit %1$s</string>
|
||||||
|
<string name="pairing_code">Code %1$s</string>
|
||||||
|
<string name="no_network">Keine Netzwerkverbindung</string>
|
||||||
|
<string name="playing">Wird abgespielt</string>
|
||||||
|
<string name="paused">Pausiert</string>
|
||||||
|
<string name="buffering">Lädt…</string>
|
||||||
|
<string name="playback_error">Wiedergabefehler (%1$s)</string>
|
||||||
|
<string name="playlist_error">Playlist konnte nicht geladen werden</string>
|
||||||
|
</resources>
|
||||||
14
app/src/main/res/values/colors.xml
Normal file
14
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="bg">#0A0B0D</color>
|
||||||
|
<color name="bg_deep">#05060A</color>
|
||||||
|
<color name="fg">#F2F3F5</color>
|
||||||
|
<color name="muted">#9AA0A8</color>
|
||||||
|
<color name="faint">#6B717A</color>
|
||||||
|
<color name="accent">#5FD4C4</color>
|
||||||
|
<color name="card">#FBFCFD</color>
|
||||||
|
<color name="live">#E5484D</color>
|
||||||
|
<color name="line">#23262B</color>
|
||||||
|
<color name="chip_stroke">#14FFFFFF</color>
|
||||||
|
<color name="chip_bg">#8C0A0B0D</color>
|
||||||
|
</resources>
|
||||||
16
app/src/main/res/values/strings.xml
Normal file
16
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">NodeCast</string>
|
||||||
|
<string name="pair_title">Pair with your phone</string>
|
||||||
|
<string name="pair_subtitle">Point your camera at the code — the remote opens right in your browser.</string>
|
||||||
|
<string name="ready_to_pair">Ready to pair</string>
|
||||||
|
<string name="paired_with">Paired with %1$s</string>
|
||||||
|
<string name="pairing_code">Code %1$s</string>
|
||||||
|
<string name="no_network">No network connection</string>
|
||||||
|
<string name="live">LIVE</string>
|
||||||
|
<string name="playing">Playing</string>
|
||||||
|
<string name="paused">Paused</string>
|
||||||
|
<string name="buffering">Buffering…</string>
|
||||||
|
<string name="playback_error">Playback error (%1$s)</string>
|
||||||
|
<string name="playlist_error">Could not load playlist</string>
|
||||||
|
</resources>
|
||||||
12
app/src/main/res/values/themes.xml
Normal file
12
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.NodeCast" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/bg</item>
|
||||||
|
<item name="android:colorBackground">@color/bg</item>
|
||||||
|
<item name="android:textColorPrimary">@color/fg</item>
|
||||||
|
<item name="android:textColorSecondary">@color/muted</item>
|
||||||
|
<item name="android:colorAccent">@color/accent</item>
|
||||||
|
<item name="android:fontFamily">@font/space_grotesk</item>
|
||||||
|
<item name="android:windowAllowReturnTransitionOverlap">true</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
4
build.gradle.kts
Normal file
4
build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.7.3" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||||
|
}
|
||||||
14
docs/adr/0001-companion-to-nodecast-server.md
Normal file
14
docs/adr/0001-companion-to-nodecast-server.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
status: superseded by ADR-0003
|
||||||
|
---
|
||||||
|
|
||||||
|
# NodeCast is a companion to the nodecast-tv server, not standalone
|
||||||
|
|
||||||
|
The first cut of the native TV app parsed M3U playlists itself and needed no
|
||||||
|
backend. We decided to make it a thin companion instead: the TV app is the only
|
||||||
|
client of a nodecast-tv server, which owns sources, channels, EPG, favorites
|
||||||
|
and users. Rationale: those features already exist server-side and would
|
||||||
|
otherwise be rebuilt in the app; the household already runs the server.
|
||||||
|
Consequence: the app is not usable without a reachable nodecast-tv instance.
|
||||||
|
The app-side M3U parser is deliberately kept as a generic-source fallback so
|
||||||
|
M3U/XMLTV servers like ErsatzTV can be added later without rearchitecting.
|
||||||
12
docs/adr/0002-device-token-fork.md
Normal file
12
docs/adr/0002-device-token-fork.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
status: superseded by ADR-0003
|
||||||
|
---
|
||||||
|
|
||||||
|
# Long-lived device tokens via nodecast-tv fork
|
||||||
|
|
||||||
|
nodecast-tv issues JWTs with a 24h expiry, which would force the TV either to
|
||||||
|
store the user's password in plaintext or to re-prompt daily. We decided to
|
||||||
|
extend our nodecast-tv fork with non-expiring per-device tokens (API keys
|
||||||
|
scoped to a user, revocable server-side). The TV logs in once with credentials,
|
||||||
|
exchanges them for a device token, and never stores the password.
|
||||||
|
Consequence: the app requires the forked server until the feature is upstreamed.
|
||||||
16
docs/adr/0003-dispatcharr-backend-via-standards.md
Normal file
16
docs/adr/0003-dispatcharr-backend-via-standards.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
supersedes: ADR-0001, ADR-0002
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dispatcharr as backend, app speaks Xtream API + XMLTV
|
||||||
|
|
||||||
|
ADR-0001 made the app a companion to nodecast-tv; grilling the ecosystem
|
||||||
|
(Dispatcharr, Threadfin, Tunarr, ErsatzTV) showed they all emit standard
|
||||||
|
outputs, while nodecast-tv is the only backend with a proprietary API. We
|
||||||
|
decided to drop nodecast-tv and use Dispatcharr as the backend: it owns
|
||||||
|
sources, aggregation, failover, EPG and stream profiles. The TV app talks the
|
||||||
|
Xtream API (channels, categories, EPG as JSON) plus XMLTV, with a generic
|
||||||
|
M3U/XMLTV fallback so Threadfin/Tunarr/ErsatzTV work too. The nodecast-tv
|
||||||
|
fork plans (device tokens, ADR-0002) are void; the app-side M3U parser stays
|
||||||
|
as the fallback path.
|
||||||
13
docs/adr/0004-dispatcharr-fork-with-native-oidc.md
Normal file
13
docs/adr/0004-dispatcharr-fork-with-native-oidc.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
---
|
||||||
|
|
||||||
|
# Fork Dispatcharr to add native OIDC (Authentik) for the web UI
|
||||||
|
|
||||||
|
Dispatcharr has no SSO (open upstream request: issue #806). A reverse-proxy
|
||||||
|
forward-auth setup would work without code changes, but we decided to fork
|
||||||
|
Dispatcharr (be-nj) and build real OIDC login (Authentik as IdP, auto user
|
||||||
|
creation, group mapping) into its Django backend and React UI, offered
|
||||||
|
upstream as a PR. Rationale: proxy-auth needs bypass rules for every client
|
||||||
|
endpoint (Xtream/M3U/HDHomeRun) and gives no in-app user identity. The TV app
|
||||||
|
is unaffected either way — it authenticates with Xtream credentials.
|
||||||
20
docs/adr/0005-multiuser-tv-ui-oidc-device-flow.md
Normal file
20
docs/adr/0005-multiuser-tv-ui-oidc-device-flow.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
---
|
||||||
|
|
||||||
|
# Multi-user app with full TV UI; login via OIDC Device Flow
|
||||||
|
|
||||||
|
The app's audience is every user of the household's Dispatcharr backend, each
|
||||||
|
on their own Google TV — not a single phone-controlled screen. Two consequences:
|
||||||
|
|
||||||
|
1. **Full TV UI** (Compose for TV): channel list, zapping and favorites are
|
||||||
|
operable with the D-pad alone. The QR-paired phone Remote stays as an
|
||||||
|
optional convenience, no longer the primary control.
|
||||||
|
2. **Login via OIDC Device Flow** against Authentik: the TV shows a QR/code,
|
||||||
|
the user confirms on their phone; the app then calls the Dispatcharr fork's
|
||||||
|
API with Bearer tokens. No Xtream credentials for the primary backend.
|
||||||
|
|
||||||
|
The generic M3U/XMLTV source type (ADR-0003) stays as a login-less fallback
|
||||||
|
for Threadfin/Tunarr/ErsatzTV. The fork scope now bundles: OIDC UI login,
|
||||||
|
Device Flow + Bearer API, per-user favorites, per-request stream profile
|
||||||
|
parameter.
|
||||||
3
gradle.properties
Normal file
3
gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
251
gradlew
vendored
Executable file
251
gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
94
gradlew.bat
vendored
Normal file
94
gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
18
settings.gradle.kts
Normal file
18
settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "NodeCast"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user