0.2.2 + 0.3.0: remote threading fix, phone-first onboarding, ten-foot settings, self-healing errors, in-app updater, group filter
Some checks failed
Build TV app / build (push) Failing after 2s
Some checks failed
Build TV app / build (push) Failing after 2s
Squashed history rewrite: earlier revisions of the docs carried private hostnames; placeholders throughout history now. - Fix phone remote threading (welcome/get_state/broadcast) — remote connects and mirrors channels, Now/Next, favorites - Phone-first onboarding (ADR-0007): one QR, server URL + IdP login on the phone (closes #11) - Ten-foot settings + Erweitert sub-screen (closes #14), TvTextField D-pad focus fix (closes #9) - EPG grid envelope parsing, Now/Next live (closes #10) - Self-healing error states (closes #13), in-app updater (closes #12) - Channel group filter chips fed by Dispatcharr groups Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
21
CONTEXT.md
21
CONTEXT.md
@@ -18,8 +18,15 @@ _Avoid_: pairing (that word is reserved for the Remote)
|
|||||||
|
|
||||||
**Backend (Dispatcharr)**:
|
**Backend (Dispatcharr)**:
|
||||||
The self-hosted stream manager owning sources, aggregation, failover, EPG and
|
The self-hosted stream manager owning sources, aggregation, failover, EPG and
|
||||||
stream profiles; forked at be-nj with native OIDC.
|
stream profiles; forked at be-nj with native OIDC. Admin UI internal
|
||||||
_Avoid_: server (ambiguous), nodecast (dropped, see ADR-0003)
|
(<admin-host>), API external for TV clients (api.<viewer-host>).
|
||||||
|
_Avoid_: server (ambiguous)
|
||||||
|
|
||||||
|
**Viewer (nodecast)**:
|
||||||
|
The browser frontend for the family on <viewer-host> — OIDC login, plays
|
||||||
|
via its own transcode; consumes exactly one source, the Backend's M3U/EPG
|
||||||
|
output (ADR-0006).
|
||||||
|
_Avoid_: treating it as a second source of truth
|
||||||
|
|
||||||
**Quelle (Source)**:
|
**Quelle (Source)**:
|
||||||
A configured backend the TV-App reads channels from — primary type Dispatcharr
|
A configured backend the TV-App reads channels from — primary type Dispatcharr
|
||||||
@@ -38,6 +45,16 @@ Trusting a Remote via the QR token (128-bit) or the rate-limited 4-digit code.
|
|||||||
A playable live entry the Backend exposes via Xtream API or M3U.
|
A playable live entry the Backend exposes via Xtream API or M3U.
|
||||||
_Avoid_: station, sender (in code/docs)
|
_Avoid_: station, sender (in code/docs)
|
||||||
|
|
||||||
|
**Onboarding**:
|
||||||
|
First-run flow: the TV shows one QR (its own pairing server); server URL and
|
||||||
|
the IdP login then happen entirely on the phone via the Remote. Nothing is
|
||||||
|
ever typed on the TV.
|
||||||
|
|
||||||
|
**Ten-Foot-Regel**:
|
||||||
|
In the family path the TV UI has no text fields and no scrolling except the
|
||||||
|
channel list itself; every screen fits the screen. Text-heavy configuration
|
||||||
|
lives in an "Erweitert" sub-screen.
|
||||||
|
|
||||||
**Now/Next**:
|
**Now/Next**:
|
||||||
The EPG scope of the Remote in phase 1: current + upcoming programme per
|
The EPG scope of the Remote in phase 1: current + upcoming programme per
|
||||||
Channel, no full guide timeline.
|
Channel, no full guide timeline.
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "dev.castarr.tv"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 4
|
versionCode = 6
|
||||||
versionName = "0.2.1"
|
versionName = "0.3.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||||
@@ -53,6 +53,7 @@ android {
|
|||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
lint {
|
lint {
|
||||||
@@ -71,6 +72,7 @@ dependencies {
|
|||||||
implementation("androidx.tv:tv-material:1.0.0")
|
implementation("androidx.tv:tv-material:1.0.0")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
implementation("androidx.media3:media3-exoplayer:1.4.1")
|
implementation("androidx.media3:media3-exoplayer:1.4.1")
|
||||||
implementation("androidx.media3:media3-exoplayer-hls:1.4.1")
|
implementation("androidx.media3:media3-exoplayer-hls:1.4.1")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||||
@@ -32,5 +33,15 @@
|
|||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -129,6 +129,8 @@
|
|||||||
|
|
||||||
/* --- Pairing --- */
|
/* --- Pairing --- */
|
||||||
#view-pair { justify-content: center; align-items: center; gap: 26px; padding: 24px; text-align: center; }
|
#view-pair { justify-content: center; align-items: center; gap: 26px; padding: 24px; text-align: center; }
|
||||||
|
#view-setup { justify-content: center; align-items: center; gap: 18px; padding: 24px; }
|
||||||
|
#setup-url:focus { border-color: #5fd4c4; }
|
||||||
#view-pair h1 { font-size: 24px; font-weight: 500; }
|
#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; }
|
#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;
|
#code-input { background: #0a0b0d; border: 1px solid #1e2126; border-radius: 12px; height: 56px;
|
||||||
@@ -164,6 +166,20 @@
|
|||||||
<div id="pair-error"></div>
|
<div id="pair-error"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- TV-Einrichtung (Onboarding) -->
|
||||||
|
<section class="view" id="view-setup">
|
||||||
|
<h1 style="font-size:24px;font-weight:500;text-align:center">TV einrichten</h1>
|
||||||
|
<p style="font-size:14px;color:#9aa0a8;font-weight:300;max-width:300px;line-height:1.5;text-align:center">
|
||||||
|
Gib die Adresse deines TV-Servers an — den Rest erledigt die Anmeldung.
|
||||||
|
</p>
|
||||||
|
<input id="setup-url" type="url" placeholder="https://tv.example.com" autocapitalize="off" autocorrect="off"
|
||||||
|
style="background:#0a0b0d;border:1px solid #1e2126;border-radius:12px;height:52px;width:280px;padding:0 14px;font-size:15px;outline:none;text-align:center">
|
||||||
|
<button class="btn-accent" id="setup-send" style="width:280px">Verbinden</button>
|
||||||
|
<a id="setup-login" target="_blank" rel="noopener" class="btn-accent"
|
||||||
|
style="display:none;width:280px;text-align:center;line-height:46px;text-decoration:none">Jetzt anmelden</a>
|
||||||
|
<div id="setup-status" style="color:#9aa0a8;font-size:13px;min-height:18px;text-align:center"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Steuerung -->
|
<!-- Steuerung -->
|
||||||
<section class="view" id="view-remote">
|
<section class="view" id="view-remote">
|
||||||
<div class="nowcard" id="nowcard">
|
<div class="nowcard" id="nowcard">
|
||||||
@@ -319,7 +335,15 @@
|
|||||||
if (msg.extras) { state.extras = msg.extras; }
|
if (msg.extras) { state.extras = msg.extras; }
|
||||||
state.playlistUrl = msg.playlistUrl || '';
|
state.playlistUrl = msg.playlistUrl || '';
|
||||||
$('pair-error').textContent = '';
|
$('pair-error').textContent = '';
|
||||||
|
state.setupMode = !!msg.setup;
|
||||||
|
if (state.setupMode) {
|
||||||
|
$('setup-url').value = localStorage.getItem('castarr_server_url') || '';
|
||||||
|
$('setup-login').style.display = 'none';
|
||||||
|
$('setup-status').textContent = '';
|
||||||
|
showView('view-setup');
|
||||||
|
} else {
|
||||||
showView('view-remote');
|
showView('view-remote');
|
||||||
|
}
|
||||||
renderAll();
|
renderAll();
|
||||||
break;
|
break;
|
||||||
case 'status':
|
case 'status':
|
||||||
@@ -335,6 +359,21 @@
|
|||||||
break;
|
break;
|
||||||
case 'toast':
|
case 'toast':
|
||||||
toast(msg.message);
|
toast(msg.message);
|
||||||
|
if (state.setupMode) { $('setup-status').textContent = msg.message; $('setup-send').disabled = false; }
|
||||||
|
break;
|
||||||
|
case 'login_link': {
|
||||||
|
const link = $('setup-login');
|
||||||
|
link.href = msg.url;
|
||||||
|
link.style.display = 'block';
|
||||||
|
$('setup-status').textContent = 'Code: ' + (msg.code || '') + ' — nach der Anmeldung geht es hier automatisch weiter.';
|
||||||
|
showView('view-setup');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'setup_done':
|
||||||
|
state.setupMode = false;
|
||||||
|
localStorage.setItem('castarr_server_url', $('setup-url').value.trim());
|
||||||
|
toast('Fertig! Der Fernseher ist eingerichtet.');
|
||||||
|
showView('view-remote');
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
if (msg.error === 'bad_code' || msg.error === 'rate_limited') {
|
if (msg.error === 'bad_code' || msg.error === 'rate_limited') {
|
||||||
@@ -512,6 +551,14 @@
|
|||||||
renderChannels();
|
renderChannels();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('setup-send').addEventListener('click', () => {
|
||||||
|
const url = $('setup-url').value.trim();
|
||||||
|
if (!url) { $('setup-status').textContent = 'Bitte Server-Adresse angeben.'; return; }
|
||||||
|
$('setup-send').disabled = true;
|
||||||
|
$('setup-status').textContent = 'Verbinde mit Server…';
|
||||||
|
send({ type: 'configure_server', url });
|
||||||
|
});
|
||||||
|
|
||||||
$('pair-btn').addEventListener('click', () => {
|
$('pair-btn').addEventListener('click', () => {
|
||||||
const code = $('code-input').value.trim();
|
const code = $('code-input').value.trim();
|
||||||
if (code.length !== 4) { $('pair-error').textContent = 'Bitte 4 Ziffern eingeben.'; return; }
|
if (code.length !== 4) { $('pair-error').textContent = 'Bitte 4 Ziffern eingeben.'; return; }
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package dev.castarr.tv
|
package dev.castarr.tv
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -17,7 +19,7 @@ import dev.castarr.tv.playlist.Channel
|
|||||||
* main thread (activity callbacks, remote-control listener).
|
* main thread (activity callbacks, remote-control listener).
|
||||||
*/
|
*/
|
||||||
class AppState(
|
class AppState(
|
||||||
context: Context,
|
private val context: Context,
|
||||||
val player: PlayerController,
|
val player: PlayerController,
|
||||||
val source: SourceRepository,
|
val source: SourceRepository,
|
||||||
val auth: DeviceAuth,
|
val auth: DeviceAuth,
|
||||||
@@ -35,6 +37,17 @@ class AppState(
|
|||||||
var durationMs by mutableLongStateOf(0L)
|
var durationMs by mutableLongStateOf(0L)
|
||||||
var isLive by mutableStateOf(false)
|
var isLive by mutableStateOf(false)
|
||||||
var favoritesOnly by mutableStateOf(false)
|
var favoritesOnly by mutableStateOf(false)
|
||||||
|
var groupFilter by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
/** Phone-first onboarding progress (ADR-0007). */
|
||||||
|
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
|
||||||
|
var welcomeUserCode by mutableStateOf("")
|
||||||
|
|
||||||
|
/** Plain-language fullscreen error state (issue #13). */
|
||||||
|
var appError by mutableStateOf(AppError.NONE)
|
||||||
|
|
||||||
|
/** In-app updater state (issue #12). */
|
||||||
|
var updateAvailable by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
var sourceMode by mutableStateOf(
|
var sourceMode by mutableStateOf(
|
||||||
if (prefs.getString("source_mode", "generic") == "dispatcharr") SourceMode.DISPATCHARR
|
if (prefs.getString("source_mode", "generic") == "dispatcharr") SourceMode.DISPATCHARR
|
||||||
@@ -42,13 +55,18 @@ class AppState(
|
|||||||
)
|
)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
enum class Screen { LIVE, SETTINGS }
|
enum class Screen { WELCOME, LIVE, SETTINGS, ADVANCED }
|
||||||
enum class SourceMode { GENERIC, DISPATCHARR }
|
enum class SourceMode { GENERIC, DISPATCHARR }
|
||||||
|
enum class WelcomePhase { WAIT_PHONE, WAIT_URL, WAIT_LOGIN, DONE }
|
||||||
|
enum class AppError { NONE, OFFLINE, SERVER, RELOGIN }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
dispatcharr.outputProfile = prefs.getString("output_profile", "").orEmpty()
|
dispatcharr.outputProfile = prefs.getString("output_profile", "").orEmpty()
|
||||||
if (sourceMode == SourceMode.DISPATCHARR && auth.isLoggedIn) {
|
val configured = auth.isLoggedIn || source.m3uUrl.isNotEmpty()
|
||||||
dispatcharr.refresh()
|
if (!configured) {
|
||||||
|
screen = Screen.WELCOME
|
||||||
|
} else if (sourceMode == SourceMode.DISPATCHARR && auth.isLoggedIn) {
|
||||||
|
dispatcharr.refresh { classifyRefresh(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +76,9 @@ class AppState(
|
|||||||
"source_mode",
|
"source_mode",
|
||||||
if (mode == SourceMode.DISPATCHARR) "dispatcharr" else "generic",
|
if (mode == SourceMode.DISPATCHARR) "dispatcharr" else "generic",
|
||||||
).apply()
|
).apply()
|
||||||
if (mode == SourceMode.DISPATCHARR && auth.isLoggedIn) dispatcharr.refresh()
|
if (mode == SourceMode.DISPATCHARR && auth.isLoggedIn) {
|
||||||
|
dispatcharr.refresh { classifyRefresh(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var outputProfile: String
|
var outputProfile: String
|
||||||
@@ -79,6 +99,44 @@ class AppState(
|
|||||||
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
|
SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun refreshActive() {
|
||||||
|
when (sourceMode) {
|
||||||
|
SourceMode.GENERIC -> source.refresh()
|
||||||
|
SourceMode.DISPATCHARR -> dispatcharr.refresh { classifyRefresh(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a refresh failure to a family-friendly fullscreen state. */
|
||||||
|
fun classifyRefresh(result: Result<Int>) {
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { appError = AppError.NONE },
|
||||||
|
onFailure = { throwable ->
|
||||||
|
// Cached channels keep the app usable; only surface a
|
||||||
|
// fullscreen state when there is nothing to show.
|
||||||
|
if (activeChannels().isNotEmpty()) return
|
||||||
|
appError = when {
|
||||||
|
throwable.message == "not logged in" -> AppError.RELOGIN
|
||||||
|
!isOnline() -> AppError.OFFLINE
|
||||||
|
else -> AppError.SERVER
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isOnline(): Boolean {
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
|
||||||
|
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startOnboarding() {
|
||||||
|
auth.logout()
|
||||||
|
appError = AppError.NONE
|
||||||
|
welcomePhase = WelcomePhase.WAIT_PHONE
|
||||||
|
welcomeUserCode = ""
|
||||||
|
screen = Screen.WELCOME
|
||||||
|
}
|
||||||
|
|
||||||
fun play(channel: Channel) {
|
fun play(channel: Channel) {
|
||||||
currentChannel = channel
|
currentChannel = channel
|
||||||
playerVisible = true
|
playerVisible = true
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import android.os.Looper
|
|||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import dev.castarr.tv.update.UpdateChecker
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import dev.castarr.tv.auth.DeviceAuth
|
import dev.castarr.tv.auth.DeviceAuth
|
||||||
import dev.castarr.tv.data.DispatcharrRepository
|
import dev.castarr.tv.data.DispatcharrRepository
|
||||||
import dev.castarr.tv.data.SourceRepository
|
import dev.castarr.tv.data.SourceRepository
|
||||||
@@ -40,11 +44,12 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
player = PlayerController(this) { onPlaybackChanged() },
|
player = PlayerController(this) { onPlaybackChanged() },
|
||||||
source = SourceRepository(this),
|
source = SourceRepository(this),
|
||||||
auth = auth,
|
auth = auth,
|
||||||
dispatcharr = DispatcharrRepository(auth),
|
dispatcharr = DispatcharrRepository(this, auth),
|
||||||
)
|
)
|
||||||
server = ControlServer(this, this)
|
server = ControlServer(this, this)
|
||||||
server.startServer()
|
server.startServer()
|
||||||
setContent { CastarrApp(state) }
|
setContent { CastarrApp(state) }
|
||||||
|
lifecycleScope.launch { UpdateChecker.check(state) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onPlaybackChanged() {
|
private fun onPlaybackChanged() {
|
||||||
@@ -135,6 +140,49 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
|
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setupNeeded(): Boolean = state.screen == AppState.Screen.WELCOME
|
||||||
|
|
||||||
|
override fun onConfigureServer(url: String) {
|
||||||
|
if (state.screen != AppState.Screen.WELCOME || url.isBlank()) return
|
||||||
|
state.welcomePhase = AppState.WelcomePhase.WAIT_URL
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
state.auth.fetchServerConfig(url.trim())
|
||||||
|
val session = state.auth.startDeviceFlow()
|
||||||
|
state.welcomeUserCode = session.userCode
|
||||||
|
state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN
|
||||||
|
server.broadcastLoginLink(session.verificationUriComplete, session.userCode)
|
||||||
|
val deadline = System.currentTimeMillis() + session.expiresInSeconds * 1000L
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
delay(session.intervalSeconds * 1000L)
|
||||||
|
when (state.auth.poll(session)) {
|
||||||
|
is dev.castarr.tv.auth.DeviceAuth.PollResult.Success -> {
|
||||||
|
state.welcomePhase = AppState.WelcomePhase.DONE
|
||||||
|
server.broadcastSetupDone()
|
||||||
|
state.setMode(AppState.SourceMode.DISPATCHARR)
|
||||||
|
delay(1500)
|
||||||
|
state.screen = AppState.Screen.LIVE
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
dev.castarr.tv.auth.DeviceAuth.PollResult.Denied -> {
|
||||||
|
server.broadcastToast("Anmeldung abgelehnt — bitte erneut versuchen")
|
||||||
|
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
|
||||||
|
state.welcomeUserCode = ""
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
dev.castarr.tv.auth.DeviceAuth.PollResult.Pending -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.broadcastToast("Anmeldecode abgelaufen — bitte erneut versuchen")
|
||||||
|
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("Onboarding", "configure failed: ${e.javaClass.simpleName}")
|
||||||
|
server.broadcastToast("Server nicht erreichbar oder ohne Anmeldung")
|
||||||
|
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onToggleFavorite(channelId: Int) {
|
override fun onToggleFavorite(channelId: Int) {
|
||||||
val channel = state.activeChannels().firstOrNull { it.backendId == channelId } ?: return
|
val channel = state.activeChannels().firstOrNull { it.backendId == channelId } ?: return
|
||||||
state.dispatcharr.toggleFavorite(channel)
|
state.dispatcharr.toggleFavorite(channel)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package dev.castarr.tv.data
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import dev.castarr.tv.auth.DeviceAuth
|
import dev.castarr.tv.auth.DeviceAuth
|
||||||
import dev.castarr.tv.playlist.Channel
|
import dev.castarr.tv.playlist.Channel
|
||||||
@@ -22,8 +23,9 @@ import java.util.TimeZone
|
|||||||
* groups, EPG grid and per-user favorites. Stream URLs go through the
|
* groups, EPG grid and per-user favorites. Stream URLs go through the
|
||||||
* Dispatcharr proxy, optionally with a selected output profile.
|
* Dispatcharr proxy, optionally with a selected output profile.
|
||||||
*/
|
*/
|
||||||
class DispatcharrRepository(private val auth: DeviceAuth) {
|
class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences("dispatcharr", Context.MODE_PRIVATE)
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
val channels = MutableStateFlow<List<Channel>>(emptyList())
|
val channels = MutableStateFlow<List<Channel>>(emptyList())
|
||||||
@@ -35,6 +37,14 @@ class DispatcharrRepository(private val auth: DeviceAuth) {
|
|||||||
private var programmesByTvgId: Map<String, List<Programme>> = emptyMap()
|
private var programmesByTvgId: Map<String, List<Programme>> = emptyMap()
|
||||||
var outputProfile: String = ""
|
var outputProfile: String = ""
|
||||||
|
|
||||||
|
init {
|
||||||
|
runCatching {
|
||||||
|
prefs.getString("channels_cache", null)?.let {
|
||||||
|
channels.value = Channel.listFromJson(JSONArray(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun refresh(onDone: (Result<Int>) -> Unit = {}) {
|
fun refresh(onDone: (Result<Int>) -> Unit = {}) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val result = runCatching {
|
val result = runCatching {
|
||||||
@@ -43,15 +53,16 @@ class DispatcharrRepository(private val auth: DeviceAuth) {
|
|||||||
val groups = fetchGroups(token)
|
val groups = fetchGroups(token)
|
||||||
val list = fetchChannels(token, groups)
|
val list = fetchChannels(token, groups)
|
||||||
channels.value = list
|
channels.value = list
|
||||||
|
prefs.edit().putString("channels_cache", Channel.listToJson(list).toString()).apply()
|
||||||
launch { runCatching { refreshFavorites(token) } }
|
launch { runCatching { refreshFavorites(token) } }
|
||||||
launch { runCatching { refreshProfiles(token) } }
|
launch { runCatching { refreshProfiles(token) } }
|
||||||
launch {
|
launch {
|
||||||
runCatching { refreshEpg(token) }
|
runCatching { refreshEpg(token) }
|
||||||
.onFailure { Log.w(TAG, "epg failed", it) }
|
.onFailure { Log.w(TAG, "epg failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
|
||||||
}
|
}
|
||||||
list.size
|
list.size
|
||||||
}
|
}
|
||||||
result.onFailure { Log.w(TAG, "refresh failed", it) }
|
result.onFailure { Log.w(TAG, "refresh failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
|
||||||
status.value = if (result.isSuccess) "" else "channels_error"
|
status.value = if (result.isSuccess) "" else "channels_error"
|
||||||
onDone(result)
|
onDone(result)
|
||||||
}
|
}
|
||||||
@@ -146,8 +157,11 @@ class DispatcharrRepository(private val auth: DeviceAuth) {
|
|||||||
private suspend fun refreshEpg(token: String) {
|
private suspend fun refreshEpg(token: String) {
|
||||||
status.value = "loading_epg"
|
status.value = "loading_epg"
|
||||||
val body = request("${auth.serverUrl}/api/epg/grid/", "GET", token)
|
val body = request("${auth.serverUrl}/api/epg/grid/", "GET", token)
|
||||||
val results = runCatching { JSONObject(body).optJSONArray("results") }.getOrNull()
|
// Envelope is {"data": [...]}; tolerate bare arrays and "results" too.
|
||||||
?: JSONArray(body)
|
val root = runCatching { JSONObject(body) }.getOrNull()
|
||||||
|
val results = root?.optJSONArray("data")
|
||||||
|
?: root?.optJSONArray("results")
|
||||||
|
?: runCatching { JSONArray(body) }.getOrElse { error("unexpected EPG envelope") }
|
||||||
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply {
|
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply {
|
||||||
timeZone = TimeZone.getTimeZone("UTC")
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class PlayerController(
|
|||||||
private set
|
private set
|
||||||
var errorMessage: String? = null
|
var errorMessage: String? = null
|
||||||
private set
|
private set
|
||||||
|
private var retryCount = 0
|
||||||
|
private var lastUrl: String = ""
|
||||||
|
|
||||||
init {
|
init {
|
||||||
player.addListener(object : Player.Listener {
|
player.addListener(object : Player.Listener {
|
||||||
@@ -35,7 +37,15 @@ class PlayerController(
|
|||||||
override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()
|
override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
errorMessage = error.errorCodeName
|
// Self-heal: silently reconnect twice before surfacing (#13).
|
||||||
|
if (retryCount < 2 && lastUrl.isNotEmpty()) {
|
||||||
|
retryCount++
|
||||||
|
player.setMediaItem(MediaItem.fromUri(lastUrl))
|
||||||
|
player.prepare()
|
||||||
|
player.play()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errorMessage = "unreachable"
|
||||||
onChanged()
|
onChanged()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -56,6 +66,8 @@ class PlayerController(
|
|||||||
fun play(url: String, name: String, group: String) {
|
fun play(url: String, name: String, group: String) {
|
||||||
if (url.isEmpty()) return
|
if (url.isEmpty()) return
|
||||||
errorMessage = null
|
errorMessage = null
|
||||||
|
retryCount = 0
|
||||||
|
lastUrl = url
|
||||||
channelName = name.ifEmpty { url }
|
channelName = name.ifEmpty { url }
|
||||||
channelGroup = group
|
channelGroup = group
|
||||||
val item = MediaItem.Builder()
|
val item = MediaItem.Builder()
|
||||||
@@ -88,6 +100,8 @@ class PlayerController(
|
|||||||
channelName = ""
|
channelName = ""
|
||||||
channelGroup = ""
|
channelGroup = ""
|
||||||
errorMessage = null
|
errorMessage = null
|
||||||
|
retryCount = 0
|
||||||
|
lastUrl = ""
|
||||||
onChanged()
|
onChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import java.io.IOException
|
|||||||
import java.util.Timer
|
import java.util.Timer
|
||||||
import java.util.TimerTask
|
import java.util.TimerTask
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
|
* Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
|
||||||
@@ -34,6 +35,8 @@ class ControlServer(
|
|||||||
fun onVolume(value: Float)
|
fun onVolume(value: Float)
|
||||||
fun onSetPlaylist(url: String)
|
fun onSetPlaylist(url: String)
|
||||||
fun onToggleFavorite(channelId: Int)
|
fun onToggleFavorite(channelId: Int)
|
||||||
|
fun onConfigureServer(url: String)
|
||||||
|
fun setupNeeded(): Boolean
|
||||||
fun onClientsChanged(count: Int, newestName: String?)
|
fun onClientsChanged(count: Int, newestName: String?)
|
||||||
fun currentStatus(): JSONObject
|
fun currentStatus(): JSONObject
|
||||||
fun currentChannels(): List<Channel>
|
fun currentChannels(): List<Channel>
|
||||||
@@ -42,6 +45,10 @@ class ControlServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
// Socket writes must stay off the main thread (NetworkOnMainThread),
|
||||||
|
// while payloads reading player/UI state must be BUILT on it — so all
|
||||||
|
// sends are serialized through this worker.
|
||||||
|
private val sendExecutor = Executors.newSingleThreadExecutor()
|
||||||
private val clients = CopyOnWriteArrayList<RemoteSocket>()
|
private val clients = CopyOnWriteArrayList<RemoteSocket>()
|
||||||
private val pairingCode = Pairing.code(context)
|
private val pairingCode = Pairing.code(context)
|
||||||
private val pairingToken = Pairing.token(context)
|
private val pairingToken = Pairing.token(context)
|
||||||
@@ -74,6 +81,7 @@ class ControlServer(
|
|||||||
fun stopServer() {
|
fun stopServer() {
|
||||||
pingTimer?.cancel()
|
pingTimer?.cancel()
|
||||||
pingTimer = null
|
pingTimer = null
|
||||||
|
sendExecutor.shutdown()
|
||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,10 +105,12 @@ class ControlServer(
|
|||||||
|
|
||||||
fun broadcast(message: JSONObject) {
|
fun broadcast(message: JSONObject) {
|
||||||
val payload = message.toString()
|
val payload = message.toString()
|
||||||
|
sendExecutor.execute {
|
||||||
clients.forEach { client ->
|
clients.forEach { client ->
|
||||||
if (client.authorized) client.trySend(payload)
|
if (client.authorized) client.trySend(payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun broadcastStatus() {
|
fun broadcastStatus() {
|
||||||
broadcast(listener.currentStatus().put("type", "status"))
|
broadcast(listener.currentStatus().put("type", "status"))
|
||||||
@@ -114,6 +124,14 @@ class ControlServer(
|
|||||||
broadcast(JSONObject().put("type", "toast").put("message", message))
|
broadcast(JSONObject().put("type", "toast").put("message", message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun broadcastLoginLink(url: String, code: String) {
|
||||||
|
broadcast(JSONObject().put("type", "login_link").put("url", url).put("code", code))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun broadcastSetupDone() {
|
||||||
|
broadcast(JSONObject().put("type", "setup_done"))
|
||||||
|
}
|
||||||
|
|
||||||
private fun channelsMessage(): JSONObject = JSONObject()
|
private fun channelsMessage(): JSONObject = JSONObject()
|
||||||
.put("type", "channels")
|
.put("type", "channels")
|
||||||
.put("playlistUrl", listener.currentPlaylistUrl())
|
.put("playlistUrl", listener.currentPlaylistUrl())
|
||||||
@@ -186,9 +204,15 @@ class ControlServer(
|
|||||||
"volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) }
|
"volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) }
|
||||||
"set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
|
"set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
|
||||||
"set_favorite" -> post { listener.onToggleFavorite(msg.optInt("id")) }
|
"set_favorite" -> post { listener.onToggleFavorite(msg.optInt("id")) }
|
||||||
"get_state" -> {
|
"configure_server" -> post { listener.onConfigureServer(msg.optString("url")) }
|
||||||
trySend(listener.currentStatus().put("type", "status").toString())
|
"get_state" -> post {
|
||||||
trySend(channelsMessage().toString())
|
// Read state on main, write on the send worker.
|
||||||
|
val status = listener.currentStatus().put("type", "status").toString()
|
||||||
|
val channels = channelsMessage().toString()
|
||||||
|
sendExecutor.execute {
|
||||||
|
trySend(status)
|
||||||
|
trySend(channels)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
@@ -220,9 +244,12 @@ class ControlServer(
|
|||||||
}
|
}
|
||||||
authorized = true
|
authorized = true
|
||||||
deviceName = msg.optString("name").ifEmpty { "Handy" }
|
deviceName = msg.optString("name").ifEmpty { "Handy" }
|
||||||
trySend(
|
// Build the welcome payload on the main thread — it reads the
|
||||||
JSONObject()
|
// player and UI state, which must not happen on the WS thread.
|
||||||
|
post {
|
||||||
|
val welcome = JSONObject()
|
||||||
.put("type", "welcome")
|
.put("type", "welcome")
|
||||||
|
.put("setup", listener.setupNeeded())
|
||||||
.put("device", android.os.Build.MODEL)
|
.put("device", android.os.Build.MODEL)
|
||||||
.put("token", pairingToken)
|
.put("token", pairingToken)
|
||||||
.put("status", listener.currentStatus())
|
.put("status", listener.currentStatus())
|
||||||
@@ -230,7 +257,8 @@ class ControlServer(
|
|||||||
.put("channels", Channel.listToJson(listener.currentChannels()))
|
.put("channels", Channel.listToJson(listener.currentChannels()))
|
||||||
.put("extras", listener.channelsExtras())
|
.put("extras", listener.channelsExtras())
|
||||||
.toString()
|
.toString()
|
||||||
)
|
sendExecutor.execute { trySend(welcome) }
|
||||||
|
}
|
||||||
notifyClientsChanged(deviceName)
|
notifyClientsChanged(deviceName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
98
app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt
Normal file
98
app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import dev.castarr.tv.AppState
|
||||||
|
|
||||||
|
/** Text-heavy configuration, deliberately outside the family path (#14). */
|
||||||
|
@Composable
|
||||||
|
fun AdvancedScreen(state: AppState) {
|
||||||
|
var m3u by remember { mutableStateOf(state.source.m3uUrl) }
|
||||||
|
var epg by remember { mutableStateOf(state.source.epgUrl) }
|
||||||
|
var message by remember { mutableStateOf("") }
|
||||||
|
val status by state.source.status.collectAsState()
|
||||||
|
|
||||||
|
val fieldColors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = CastarrColors.fg,
|
||||||
|
unfocusedTextColor = CastarrColors.muted,
|
||||||
|
focusedBorderColor = CastarrColors.accent,
|
||||||
|
unfocusedBorderColor = CastarrColors.line,
|
||||||
|
focusedLabelColor = CastarrColors.accent,
|
||||||
|
unfocusedLabelColor = CastarrColors.faint,
|
||||||
|
cursorColor = CastarrColors.accent,
|
||||||
|
focusedContainerColor = CastarrColors.surface,
|
||||||
|
unfocusedContainerColor = CastarrColors.surface,
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 40.dp, vertical = 20.dp)
|
||||||
|
) {
|
||||||
|
Text("Erweitert", color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 22.sp)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
"Eigene M3U-Quelle (ErsatzTV, Threadfin, Tunarr …) — optionales XMLTV-EPG. " +
|
||||||
|
"Server: ${state.auth.serverUrl.ifEmpty { "—" }}",
|
||||||
|
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
TvTextField(
|
||||||
|
value = m3u,
|
||||||
|
onValueChange = { m3u = it },
|
||||||
|
label = { Text("M3U-URL", fontFamily = SpaceGrotesk) },
|
||||||
|
colors = fieldColors,
|
||||||
|
textStyle = TextStyle(fontSize = 14.sp),
|
||||||
|
modifier = Modifier.width(600.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
TvTextField(
|
||||||
|
value = epg,
|
||||||
|
onValueChange = { epg = it },
|
||||||
|
label = { Text("XMLTV-EPG-URL (optional)", fontFamily = SpaceGrotesk) },
|
||||||
|
colors = fieldColors,
|
||||||
|
textStyle = TextStyle(fontSize = 14.sp),
|
||||||
|
modifier = Modifier.width(600.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
Row {
|
||||||
|
ActionButton("Quelle laden") {
|
||||||
|
message = ""
|
||||||
|
state.source.configure(m3u.trim(), epg.trim()) { result ->
|
||||||
|
message = result.fold(
|
||||||
|
onSuccess = { "$it Sender geladen" },
|
||||||
|
onFailure = { "Fehler: Quelle nicht ladbar" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
ActionButton("Zurück") { state.screen = AppState.Screen.SETTINGS }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
val statusText = when {
|
||||||
|
status == "loading_channels" -> "Lade Senderliste…"
|
||||||
|
status == "loading_epg" -> "Lade EPG…"
|
||||||
|
else -> message
|
||||||
|
}
|
||||||
|
if (statusText.isNotEmpty()) {
|
||||||
|
Text(statusText, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,11 +32,17 @@ fun CastarrApp(state: AppState) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(CastarrColors.bg)
|
.background(CastarrColors.bg)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
when {
|
||||||
|
state.screen == AppState.Screen.WELCOME -> WelcomeScreen(state)
|
||||||
|
state.appError != AppState.AppError.NONE && !state.playerVisible -> ErrorScreen(state)
|
||||||
|
else -> Column(modifier = Modifier.fillMaxSize()) {
|
||||||
TopBar(state)
|
TopBar(state)
|
||||||
when (state.screen) {
|
when (state.screen) {
|
||||||
AppState.Screen.LIVE -> LiveScreen(state)
|
AppState.Screen.LIVE -> LiveScreen(state)
|
||||||
AppState.Screen.SETTINGS -> SettingsScreen(state)
|
AppState.Screen.SETTINGS -> SettingsScreen(state)
|
||||||
|
AppState.Screen.ADVANCED -> AdvancedScreen(state)
|
||||||
|
AppState.Screen.WELCOME -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.playerVisible) {
|
if (state.playerVisible) {
|
||||||
|
|||||||
86
app/src/main/java/dev/castarr/tv/ui/ErrorScreen.kt
Normal file
86
app/src/main/java/dev/castarr/tv/ui/ErrorScreen.kt
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import dev.castarr.tv.AppState
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/** One fullscreen plain-language message, one focused button (#13). */
|
||||||
|
@Composable
|
||||||
|
fun ErrorScreen(state: AppState) {
|
||||||
|
// Self-heal: keep retrying quietly while the screen is up.
|
||||||
|
LaunchedEffect(state.appError) {
|
||||||
|
while (state.appError == AppState.AppError.SERVER || state.appError == AppState.AppError.OFFLINE) {
|
||||||
|
delay(20_000)
|
||||||
|
state.refreshActive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(CastarrColors.bg),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
|
||||||
|
) {
|
||||||
|
val (title, subtitle, buttonLabel) = when (state.appError) {
|
||||||
|
AppState.AppError.OFFLINE -> Triple(
|
||||||
|
"Kein Internet",
|
||||||
|
"Der Fernseher ist gerade nicht mit dem Internet verbunden.\nEs wird automatisch weiter versucht.",
|
||||||
|
"Jetzt erneut versuchen",
|
||||||
|
)
|
||||||
|
AppState.AppError.SERVER -> Triple(
|
||||||
|
"Server nicht erreichbar",
|
||||||
|
"Der TV-Server antwortet gerade nicht.\nEs wird automatisch weiter versucht.",
|
||||||
|
"Jetzt erneut versuchen",
|
||||||
|
)
|
||||||
|
else -> Triple(
|
||||||
|
"Bitte neu anmelden",
|
||||||
|
"Deine Anmeldung ist abgelaufen.",
|
||||||
|
"Neu anmelden",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(title, color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 30.sp)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 15.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(26.dp))
|
||||||
|
Surface(
|
||||||
|
onClick = {
|
||||||
|
if (state.appError == AppState.AppError.RELOGIN) state.startOnboarding()
|
||||||
|
else state.refreshActive()
|
||||||
|
},
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = CastarrColors.accentDim,
|
||||||
|
contentColor = CastarrColors.accent,
|
||||||
|
focusedContainerColor = CastarrColors.accent,
|
||||||
|
focusedContentColor = CastarrColors.onAccent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
buttonLabel,
|
||||||
|
fontFamily = SpaceGrotesk, fontSize = 16.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 26.dp, vertical = 13.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,11 +13,13 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -45,11 +47,12 @@ fun LiveScreen(state: AppState) {
|
|||||||
val isDispatcharr = state.sourceMode == AppState.SourceMode.DISPATCHARR
|
val isDispatcharr = state.sourceMode == AppState.SourceMode.DISPATCHARR
|
||||||
val epgStamp = if (isDispatcharr) dispatcharrEpg else genericEpg
|
val epgStamp = if (isDispatcharr) dispatcharrEpg else genericEpg
|
||||||
val allChannels = if (isDispatcharr) dispatcharrChannels else genericChannels
|
val allChannels = if (isDispatcharr) dispatcharrChannels else genericChannels
|
||||||
val channels = if (isDispatcharr && state.favoritesOnly) {
|
val groups = remember(allChannels) {
|
||||||
allChannels.filter { it.backendId in favorites }
|
allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted()
|
||||||
} else {
|
|
||||||
allChannels
|
|
||||||
}
|
}
|
||||||
|
val channels = allChannels
|
||||||
|
.let { list -> if (isDispatcharr && state.favoritesOnly) list.filter { it.backendId in favorites } else list }
|
||||||
|
.let { list -> state.groupFilter?.let { g -> list.filter { it.group == g } } ?: list }
|
||||||
|
|
||||||
if (allChannels.isEmpty()) {
|
if (allChannels.isEmpty()) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
@@ -64,11 +67,28 @@ fun LiveScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier.padding(bottom = 6.dp),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 40.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
FilterChip("Alle", !state.favoritesOnly && state.groupFilter == null) {
|
||||||
|
state.favoritesOnly = false
|
||||||
|
state.groupFilter = null
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isDispatcharr) {
|
if (isDispatcharr) {
|
||||||
Row(Modifier.padding(start = 40.dp, bottom = 6.dp)) {
|
item {
|
||||||
FilterChip("Alle", !state.favoritesOnly) { state.favoritesOnly = false }
|
FilterChip("★ Favoriten", state.favoritesOnly) {
|
||||||
Spacer(Modifier.width(8.dp))
|
state.favoritesOnly = !state.favoritesOnly
|
||||||
FilterChip("★ Favoriten", state.favoritesOnly) { state.favoritesOnly = true }
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(groups, key = { it }) { group ->
|
||||||
|
FilterChip(group, state.groupFilter == group) {
|
||||||
|
state.groupFilter = if (state.groupFilter == group) null else group
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
|||||||
@@ -12,135 +12,127 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import dev.castarr.tv.AppState
|
import dev.castarr.tv.AppState
|
||||||
import dev.castarr.tv.auth.DeviceAuth
|
import dev.castarr.tv.BuildConfig
|
||||||
import dev.castarr.tv.pairing.Pairing
|
import dev.castarr.tv.pairing.Pairing
|
||||||
import dev.castarr.tv.pairing.Qr
|
import dev.castarr.tv.pairing.Qr
|
||||||
|
import dev.castarr.tv.update.UpdateChecker
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ten-Foot-Regel (#14): one screen, focusable buttons/chips only — no text
|
||||||
|
* fields, no scrolling. Everything text-heavy lives in the Erweitert screen.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(state: AppState) {
|
fun SettingsScreen(state: AppState) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var m3u by remember { mutableStateOf(state.source.m3uUrl) }
|
val scope = rememberCoroutineScope()
|
||||||
var epg by remember { mutableStateOf(state.source.epgUrl) }
|
val profiles by state.dispatcharr.profiles.collectAsState()
|
||||||
var message by remember { mutableStateOf("") }
|
var updateStatus by remember { mutableStateOf("") }
|
||||||
val status by state.source.status.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
val fieldColors = OutlinedTextFieldDefaults.colors(
|
|
||||||
focusedTextColor = CastarrColors.fg,
|
|
||||||
unfocusedTextColor = CastarrColors.muted,
|
|
||||||
focusedBorderColor = CastarrColors.accent,
|
|
||||||
unfocusedBorderColor = CastarrColors.line,
|
|
||||||
focusedLabelColor = CastarrColors.accent,
|
|
||||||
unfocusedLabelColor = CastarrColors.faint,
|
|
||||||
cursorColor = CastarrColors.accent,
|
|
||||||
focusedContainerColor = CastarrColors.surface,
|
|
||||||
unfocusedContainerColor = CastarrColors.surface,
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.padding(horizontal = 40.dp, vertical = 20.dp)
|
||||||
.padding(horizontal = 40.dp, vertical = 16.dp)
|
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1.2f)) {
|
||||||
Text("Quelle", color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
|
SectionTitle("Konto")
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
if (state.auth.isLoggedIn) {
|
||||||
Text(
|
Text(
|
||||||
"M3U-Playlist plus optionales XMLTV-EPG — funktioniert mit Dispatcharr, ErsatzTV, Threadfin, Tunarr.",
|
"Angemeldet als ${state.auth.username}",
|
||||||
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp,
|
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp,
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
if (state.source.m3uUrl.isNotEmpty()) {
|
||||||
|
Row {
|
||||||
|
Chip("Dispatcharr", state.sourceMode == AppState.SourceMode.DISPATCHARR) {
|
||||||
|
state.setMode(AppState.SourceMode.DISPATCHARR)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Chip("Eigene M3U", state.sourceMode == AppState.SourceMode.GENERIC) {
|
||||||
|
state.setMode(AppState.SourceMode.GENERIC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
}
|
||||||
|
Text("Stream-Qualität", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row {
|
||||||
|
Chip("Standard", state.outputProfile.isEmpty()) { state.outputProfile = "" }
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Chip("Original", state.outputProfile == "raw") { state.outputProfile = "raw" }
|
||||||
|
profiles.forEach { name ->
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Chip(name, state.outputProfile == name) { state.outputProfile = name }
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(18.dp))
|
Spacer(Modifier.height(18.dp))
|
||||||
OutlinedTextField(
|
ActionButton("Abmelden", danger = true) { state.startOnboarding() }
|
||||||
value = m3u,
|
} else {
|
||||||
onValueChange = { m3u = it },
|
Text(
|
||||||
label = { Text("M3U-URL", fontFamily = SpaceGrotesk) },
|
"Nicht angemeldet.",
|
||||||
singleLine = true,
|
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp,
|
||||||
textStyle = TextStyle(fontSize = 14.sp),
|
|
||||||
colors = fieldColors,
|
|
||||||
modifier = Modifier.width(560.dp),
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
OutlinedTextField(
|
ActionButton("Jetzt anmelden") { state.startOnboarding() }
|
||||||
value = epg,
|
|
||||||
onValueChange = { epg = it },
|
|
||||||
label = { Text("XMLTV-EPG-URL (optional)", fontFamily = SpaceGrotesk) },
|
|
||||||
singleLine = true,
|
|
||||||
textStyle = TextStyle(fontSize = 14.sp),
|
|
||||||
colors = fieldColors,
|
|
||||||
modifier = Modifier.width(560.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(18.dp))
|
|
||||||
Surface(
|
|
||||||
onClick = {
|
|
||||||
message = ""
|
|
||||||
state.source.configure(m3u.trim(), epg.trim()) { result ->
|
|
||||||
message = result.fold(
|
|
||||||
onSuccess = { "$it Sender geladen" },
|
|
||||||
onFailure = { "Fehler: Quelle nicht ladbar" },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
|
||||||
containerColor = CastarrColors.accentDim,
|
|
||||||
contentColor = CastarrColors.accent,
|
|
||||||
focusedContainerColor = CastarrColors.accent,
|
|
||||||
focusedContentColor = CastarrColors.onAccent,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Quelle laden",
|
|
||||||
fontFamily = SpaceGrotesk,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
modifier = Modifier.padding(horizontal = 22.dp, vertical = 11.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(10.dp))
|
|
||||||
val statusText = when {
|
|
||||||
status == "loading_channels" -> "Lade Senderliste…"
|
|
||||||
status == "loading_epg" -> "Lade EPG…"
|
|
||||||
message.isNotEmpty() -> message
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
if (statusText.isNotEmpty()) {
|
|
||||||
Text(statusText, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
DispatcharrAccount(state, fieldColors)
|
SectionTitle("App")
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Version ${BuildConfig.VERSION_NAME}" + (state.updateAvailable?.let { " · $it verfügbar" } ?: ""),
|
||||||
|
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row {
|
||||||
|
ActionButton(
|
||||||
|
if (state.updateAvailable != null) "Update installieren" else "Nach Update suchen"
|
||||||
|
) {
|
||||||
|
scope.launch {
|
||||||
|
updateStatus = if (state.updateAvailable != null) {
|
||||||
|
UpdateChecker.downloadAndInstall(context, state) ?: "Update wird geöffnet…"
|
||||||
|
} else {
|
||||||
|
when (val v = UpdateChecker.check(state)) {
|
||||||
|
null -> "App ist aktuell"
|
||||||
|
else -> "$v verfügbar — nochmal drücken zum Installieren"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
ActionButton("Erweitert") { state.screen = AppState.Screen.ADVANCED }
|
||||||
|
}
|
||||||
|
if (updateStatus.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(updateStatus, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.width(40.dp))
|
Spacer(Modifier.width(40.dp))
|
||||||
|
|
||||||
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text("Handy-Fernbedienung", color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
|
SectionTitle("Handy-Fernbedienung")
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
val address = remember { Pairing.lanAddress() }
|
val address = remember { Pairing.lanAddress() }
|
||||||
if (address != null) {
|
if (address != null) {
|
||||||
val qr = remember(address) {
|
val qr = remember(address) {
|
||||||
@@ -160,189 +152,12 @@ fun SettingsScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QrCard(bitmap: Bitmap) {
|
private fun SectionTitle(text: String) {
|
||||||
Column(
|
Text(text, color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
|
||||||
Modifier
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(androidx.compose.ui.graphics.Color(0xFFFBFCFD))
|
|
||||||
.padding(14.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
bitmap = bitmap.asImageBitmap(),
|
|
||||||
contentDescription = "QR-Code zum Koppeln",
|
|
||||||
modifier = Modifier.size(150.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DispatcharrAccount(
|
|
||||||
state: AppState,
|
|
||||||
fieldColors: androidx.compose.material3.TextFieldColors,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var serverUrl by remember {
|
|
||||||
mutableStateOf(state.auth.serverUrl.ifEmpty { "https://" })
|
|
||||||
}
|
|
||||||
var session by remember { mutableStateOf<DeviceAuth.DeviceSession?>(null) }
|
|
||||||
var phase by remember { mutableStateOf("") }
|
|
||||||
var loggedIn by remember { mutableStateOf(state.auth.isLoggedIn) }
|
|
||||||
val profiles by state.dispatcharr.profiles.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
Text("Dispatcharr-Konto", color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
|
|
||||||
if (!loggedIn) {
|
|
||||||
Text(
|
|
||||||
"Anmeldung per Handy: QR-Code scannen und im Browser bestätigen.",
|
|
||||||
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(14.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = serverUrl,
|
|
||||||
onValueChange = { serverUrl = it },
|
|
||||||
label = { Text("Server-URL", fontFamily = SpaceGrotesk) },
|
|
||||||
singleLine = true,
|
|
||||||
textStyle = TextStyle(fontSize = 14.sp),
|
|
||||||
colors = fieldColors,
|
|
||||||
modifier = Modifier.width(560.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
val currentSession = session
|
|
||||||
if (currentSession == null) {
|
|
||||||
Surface(
|
|
||||||
onClick = {
|
|
||||||
phase = "Verbinde…"
|
|
||||||
scope.launch {
|
|
||||||
runCatching {
|
|
||||||
state.auth.fetchServerConfig(serverUrl.trim())
|
|
||||||
session = state.auth.startDeviceFlow()
|
|
||||||
phase = ""
|
|
||||||
}.onFailure { phase = "Fehler: Server nicht erreichbar oder kein SSO" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
|
||||||
containerColor = CastarrColors.accentDim,
|
|
||||||
contentColor = CastarrColors.accent,
|
|
||||||
focusedContainerColor = CastarrColors.accent,
|
|
||||||
focusedContentColor = CastarrColors.onAccent,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Anmelden",
|
|
||||||
fontFamily = SpaceGrotesk, fontSize = 14.sp,
|
|
||||||
modifier = Modifier.padding(horizontal = 22.dp, vertical = 11.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LaunchedEffect(currentSession) {
|
|
||||||
val deadline = System.currentTimeMillis() + currentSession.expiresInSeconds * 1000L
|
|
||||||
while (System.currentTimeMillis() < deadline) {
|
|
||||||
delay(currentSession.intervalSeconds * 1000L)
|
|
||||||
when (state.auth.poll(currentSession)) {
|
|
||||||
is DeviceAuth.PollResult.Success -> {
|
|
||||||
loggedIn = true
|
|
||||||
session = null
|
|
||||||
state.setMode(AppState.SourceMode.DISPATCHARR)
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
DeviceAuth.PollResult.Denied -> {
|
|
||||||
phase = "Anmeldung abgelehnt oder abgelaufen"
|
|
||||||
session = null
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
DeviceAuth.PollResult.Pending -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
phase = "Code abgelaufen — erneut versuchen"
|
|
||||||
session = null
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
|
||||||
val qr = remember(currentSession.deviceCode) {
|
|
||||||
Qr.encode(
|
|
||||||
currentSession.verificationUriComplete,
|
|
||||||
400,
|
|
||||||
android.graphics.Color.parseColor("#101216"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
QrCard(qr)
|
|
||||||
Spacer(Modifier.width(20.dp))
|
|
||||||
Column {
|
|
||||||
Text("Code", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp)
|
|
||||||
Text(
|
|
||||||
currentSession.userCode,
|
|
||||||
color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 26.sp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Text(
|
|
||||||
currentSession.verificationUri,
|
|
||||||
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Text("Warte auf Bestätigung…", color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (phase.isNotEmpty()) {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(phase, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
"Angemeldet als ${state.auth.username} · ${state.auth.serverUrl}",
|
|
||||||
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Row {
|
|
||||||
ModeChip("Dispatcharr", state.sourceMode == AppState.SourceMode.DISPATCHARR) {
|
|
||||||
state.setMode(AppState.SourceMode.DISPATCHARR)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
ModeChip("Eigene M3U", state.sourceMode == AppState.SourceMode.GENERIC) {
|
|
||||||
state.setMode(AppState.SourceMode.GENERIC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(14.dp))
|
|
||||||
Text("Stream-Profil", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Row {
|
|
||||||
ModeChip("Standard", state.outputProfile.isEmpty()) { state.outputProfile = "" }
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
ModeChip("Passthrough", state.outputProfile == "raw") { state.outputProfile = "raw" }
|
|
||||||
profiles.forEach { name ->
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
ModeChip(name, state.outputProfile == name) { state.outputProfile = name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(14.dp))
|
|
||||||
Surface(
|
|
||||||
onClick = {
|
|
||||||
state.auth.logout()
|
|
||||||
loggedIn = false
|
|
||||||
state.setMode(AppState.SourceMode.GENERIC)
|
|
||||||
},
|
|
||||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
|
||||||
containerColor = CastarrColors.surface,
|
|
||||||
contentColor = CastarrColors.muted,
|
|
||||||
focusedContainerColor = CastarrColors.live,
|
|
||||||
focusedContentColor = CastarrColors.fg,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Abmelden",
|
|
||||||
fontFamily = SpaceGrotesk, fontSize = 13.sp,
|
|
||||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
fun Chip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
|
||||||
@@ -360,3 +175,40 @@ private fun ModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = if (danger) CastarrColors.surface else CastarrColors.accentDim,
|
||||||
|
contentColor = if (danger) CastarrColors.muted else CastarrColors.accent,
|
||||||
|
focusedContainerColor = if (danger) CastarrColors.live else CastarrColors.accent,
|
||||||
|
focusedContentColor = if (danger) CastarrColors.fg else CastarrColors.onAccent,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
fontFamily = SpaceGrotesk, fontSize = 13.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QrCard(bitmap: Bitmap) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(androidx.compose.ui.graphics.Color(0xFFFBFCFD))
|
||||||
|
.padding(14.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = bitmap.asImageBitmap(),
|
||||||
|
contentDescription = "QR-Code zum Koppeln",
|
||||||
|
modifier = Modifier.size(170.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
52
app/src/main/java/dev/castarr/tv/ui/TvTextField.kt
Normal file
52
app/src/main/java/dev/castarr/tv/ui/TvTextField.kt
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.TextFieldColors
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OutlinedTextField that doesn't trap the D-pad: vertical direction keys move
|
||||||
|
* focus instead of the text cursor (single-line fields have no use for them).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TvTextField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
label: @Composable () -> Unit,
|
||||||
|
colors: TextFieldColors,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
textStyle: TextStyle = TextStyle.Default,
|
||||||
|
) {
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
label = label,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = textStyle,
|
||||||
|
colors = colors,
|
||||||
|
modifier = modifier.onPreviewKeyEvent { event ->
|
||||||
|
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
|
when (event.key) {
|
||||||
|
Key.DirectionDown -> {
|
||||||
|
focusManager.moveFocus(FocusDirection.Down)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.DirectionUp -> {
|
||||||
|
focusManager.moveFocus(FocusDirection.Up)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
134
app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt
Normal file
134
app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import dev.castarr.tv.AppState
|
||||||
|
import dev.castarr.tv.pairing.Pairing
|
||||||
|
import dev.castarr.tv.pairing.Qr
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone-first onboarding (ADR-0007): one QR, everything else on the phone.
|
||||||
|
* No text entry and no scrolling on the TV (Ten-Foot-Regel).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WelcomeScreen(state: AppState) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val address = remember { Pairing.lanAddress() }
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize().background(CastarrColors.bg)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 60.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
"Willkommen bei Castarr",
|
||||||
|
color = CastarrColors.fg, fontFamily = SpaceGrotesk,
|
||||||
|
fontSize = 32.sp, fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
Step(1, "Code mit der Handy-Kamera scannen", state.welcomePhase == AppState.WelcomePhase.WAIT_PHONE, done = state.welcomePhase > AppState.WelcomePhase.WAIT_PHONE)
|
||||||
|
Step(2, "Auf dem Handy: Server angeben und anmelden", state.welcomePhase == AppState.WelcomePhase.WAIT_URL || state.welcomePhase == AppState.WelcomePhase.WAIT_LOGIN, done = state.welcomePhase == AppState.WelcomePhase.DONE)
|
||||||
|
Step(3, "Fertig — der Fernseher macht den Rest", state.welcomePhase == AppState.WelcomePhase.DONE, done = false)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
when (state.welcomePhase) {
|
||||||
|
AppState.WelcomePhase.WAIT_PHONE -> Unit
|
||||||
|
AppState.WelcomePhase.WAIT_URL -> Status("Handy verbunden — warte auf den Server…")
|
||||||
|
AppState.WelcomePhase.WAIT_LOGIN -> Status(
|
||||||
|
if (state.welcomeUserCode.isEmpty()) "Warte auf die Anmeldung am Handy…"
|
||||||
|
else "Anmeldung am Handy bestätigen · Code ${state.welcomeUserCode}"
|
||||||
|
)
|
||||||
|
AppState.WelcomePhase.DONE -> Status("Angemeldet! Lade Sender…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(48.dp))
|
||||||
|
|
||||||
|
if (address != null) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(Color(0xFFFBFCFD))
|
||||||
|
.padding(18.dp),
|
||||||
|
) {
|
||||||
|
val qr = remember(address) {
|
||||||
|
Qr.encode(
|
||||||
|
Pairing.pairingUrl(context, address),
|
||||||
|
520,
|
||||||
|
android.graphics.Color.parseColor("#101216"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Image(qr.asImageBitmap(), contentDescription = "Einrichtungs-QR", modifier = Modifier.size(230.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
Pairing.remoteUrl(address),
|
||||||
|
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Step(number: Int, text: String, active: Boolean, done: Boolean) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(30.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (active || done) CastarrColors.accent else CastarrColors.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (done) "✓" else "$number",
|
||||||
|
color = if (active || done) CastarrColors.onAccent else CastarrColors.faint,
|
||||||
|
fontFamily = SpaceGrotesk, fontSize = 14.sp, fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
color = if (active) CastarrColors.fg else CastarrColors.muted,
|
||||||
|
fontFamily = SpaceGrotesk, fontSize = 17.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Status(text: String) {
|
||||||
|
Text(text, color = CastarrColors.accent, fontFamily = SpaceGrotesk, fontSize = 14.sp)
|
||||||
|
}
|
||||||
103
app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt
Normal file
103
app/src/main/java/dev/castarr/tv/update/UpdateChecker.kt
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package dev.castarr.tv.update
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import dev.castarr.tv.AppState
|
||||||
|
import dev.castarr.tv.BuildConfig
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/** In-app updater over GitHub releases (#12). Explicit, never automatic. */
|
||||||
|
object UpdateChecker {
|
||||||
|
|
||||||
|
private const val TAG = "UpdateChecker"
|
||||||
|
private const val LATEST = "https://api.github.com/repos/be-nj/castarr/releases/latest"
|
||||||
|
|
||||||
|
private var apkUrl: String = ""
|
||||||
|
|
||||||
|
/** Returns the newer version tag, or null when current. Never throws. */
|
||||||
|
suspend fun check(state: AppState): String? = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val json = JSONObject(get(LATEST))
|
||||||
|
val tag = json.optString("tag_name").removePrefix("v")
|
||||||
|
val assets = json.optJSONArray("assets")
|
||||||
|
var url = ""
|
||||||
|
for (i in 0 until (assets?.length() ?: 0)) {
|
||||||
|
val asset = assets!!.getJSONObject(i)
|
||||||
|
if (asset.optString("name").endsWith(".apk")) {
|
||||||
|
url = asset.optString("browser_download_url")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) {
|
||||||
|
apkUrl = url
|
||||||
|
val version = "v$tag"
|
||||||
|
withContext(Dispatchers.Main) { state.updateAvailable = version }
|
||||||
|
version
|
||||||
|
} else {
|
||||||
|
withContext(Dispatchers.Main) { state.updateAvailable = null }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.onFailure { Log.w(TAG, "check failed: ${it.javaClass.simpleName}") }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Downloads the APK and hands it to the package installer. */
|
||||||
|
suspend fun downloadAndInstall(context: Context, state: AppState): String? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
if (apkUrl.isEmpty()) check(state)
|
||||||
|
require(apkUrl.isNotEmpty()) { "no update available" }
|
||||||
|
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||||
|
val file = File(dir, "castarr-update.apk")
|
||||||
|
(URL(apkUrl).openConnection() as HttpURLConnection).run {
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 120_000
|
||||||
|
instanceFollowRedirects = true
|
||||||
|
inputStream.use { input -> file.outputStream().use { input.copyTo(it) } }
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context, "${BuildConfig.APPLICATION_ID}.fileprovider", file,
|
||||||
|
)
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
null
|
||||||
|
}.getOrElse {
|
||||||
|
Log.w(TAG, "install failed: ${it.javaClass.simpleName}")
|
||||||
|
"Update fehlgeschlagen — später erneut versuchen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isNewer(remote: String, local: String): Boolean {
|
||||||
|
fun parts(v: String) = v.split(".").mapNotNull { it.toIntOrNull() }
|
||||||
|
val r = parts(remote)
|
||||||
|
val l = parts(local)
|
||||||
|
for (i in 0 until maxOf(r.size, l.size)) {
|
||||||
|
val a = r.getOrElse(i) { 0 }
|
||||||
|
val b = l.getOrElse(i) { 0 }
|
||||||
|
if (a != b) return a > b
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun get(url: String): String {
|
||||||
|
val connection = URL(url).openConnection() as HttpURLConnection
|
||||||
|
return try {
|
||||||
|
connection.connectTimeout = 10_000
|
||||||
|
connection.readTimeout = 15_000
|
||||||
|
connection.setRequestProperty("Accept", "application/vnd.github+json")
|
||||||
|
connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
app/src/main/res/xml/file_paths.xml
Normal file
4
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="updates" path="updates/" />
|
||||||
|
</paths>
|
||||||
20
docs/adr/0006-viewer-admin-domain-split.md
Normal file
20
docs/adr/0006-viewer-admin-domain-split.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
---
|
||||||
|
|
||||||
|
# Domain split: nodecast as family viewer, Dispatcharr as engine
|
||||||
|
|
||||||
|
The family needs comfortable SSO viewing in the browser; Dispatcharr's web UI
|
||||||
|
is an admin surface, not a viewer. We reinstated nodecast-tv as the browser
|
||||||
|
frontend on <viewer-host> (it already has an OIDC login and in-browser
|
||||||
|
transcoding) — but demoted to a pure viewer with exactly one source:
|
||||||
|
Dispatcharr's M3U/EPG output. Dispatcharr stays the engine (sources,
|
||||||
|
aggregation, failover, per-user favorites for the TV app) and moved to
|
||||||
|
<admin-host> (internal admin) plus api.<viewer-host> (external API
|
||||||
|
for Castarr sticks outside the LAN; the OIDC redirect URI is derived from the
|
||||||
|
request host, all three hosts are registered at the IdP).
|
||||||
|
|
||||||
|
Access control lives on the IdP, not in app code: an Authentik group policy
|
||||||
|
binding (`adult`) on the nodecast, Dispatcharr and Castarr applications gates
|
||||||
|
both browser login and the device flow. The fork's OIDC_REQUIRED_GROUP knob
|
||||||
|
stays unused.
|
||||||
13
docs/adr/0007-onboarding-via-remote.md
Normal file
13
docs/adr/0007-onboarding-via-remote.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
---
|
||||||
|
|
||||||
|
# Onboarding runs on the phone through the TV's own pairing server
|
||||||
|
|
||||||
|
Nobody should ever type on a TV. Instead of baking a server URL into the APK
|
||||||
|
(rejected: publishes private infrastructure in a public artifact) or on-TV
|
||||||
|
text entry (rejected: D-pad typing), first-run setup reuses the embedded
|
||||||
|
remote-control server: the TV shows one QR, the phone opens the TV-served web
|
||||||
|
app, and server URL plus IdP login (device-flow link opened directly on the
|
||||||
|
phone) happen there. Requires phone and TV on the same LAN — acceptable, since
|
||||||
|
the remote control has the same requirement by design.
|
||||||
Reference in New Issue
Block a user