From 303383ea4e95bcd82b7804981db7b65afee4704f Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Thu, 10 Sep 2026 01:32:35 +0200 Subject: [PATCH] Add server URL choice, email login, and login lock controls. Clients can use VMC or a custom server with email/password or device code; API locks after 3 failed logins with admin unlock. Co-authored-by: Cursor --- apps/admin-ui/src/app/viewers/page.tsx | 69 +++++ .../vonas/mediacluster/tv/data/ApiClient.kt | 27 ++ .../vonas/mediacluster/tv/ui/AuthScreens.kt | 176 +++++++++++++ .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 62 ++++- .../mediacluster/tv/ui/ServerChoiceScreen.kt | 178 +++++++++++++ .../vonas/mediacluster/tv/data/ApiClient.kt | 27 ++ .../vonas/mediacluster/tv/ui/AuthScreens.kt | 237 ++++++++++++++++++ .../vonas/mediacluster/tv/ui/BrowseScreen.kt | 62 +++-- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 169 ++++++++++--- .../mediacluster/tv/ui/ServerChoiceScreen.kt | 214 ++++++++++++++++ apps/ios/VMC.xcodeproj/project.pbxproj | 4 + apps/ios/VMC/Data/ApiClient.swift | 19 ++ apps/ios/VMC/Data/AppModel.swift | 9 +- apps/ios/VMC/UI/LinkScreen.swift | 5 + apps/ios/VMC/UI/RootView.swift | 6 + apps/ios/VMC/UI/ServerAuthScreens.swift | 224 +++++++++++++++++ apps/ios/VMC/UI/SettingsScreen.swift | 11 + .../migration.sql | 9 + apps/master-api/prisma/schema.prisma | 10 + apps/master-api/src/viewer/routes.ts | 36 +++ apps/master-api/src/viewer/service.ts | 132 +++++++++- 21 files changed, 1614 insertions(+), 72 deletions(-) create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/AuthScreens.kt create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/ServerChoiceScreen.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/AuthScreens.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ServerChoiceScreen.kt create mode 100644 apps/ios/VMC/UI/ServerAuthScreens.swift create mode 100644 apps/master-api/prisma/migrations/20260910010000_viewer_login_locks/migration.sql diff --git a/apps/admin-ui/src/app/viewers/page.tsx b/apps/admin-ui/src/app/viewers/page.tsx index 1b66845..fb10493 100644 --- a/apps/admin-ui/src/app/viewers/page.tsx +++ b/apps/admin-ui/src/app/viewers/page.tsx @@ -62,6 +62,9 @@ type Viewer = { devices: Device[]; iptv?: ViewerIptv | null; addonTokens?: AddonTokenRow[]; + loginFailedCount?: number; + loginLocked?: boolean; + loginLockedUntil?: string | null; }; type IptvFormState = { @@ -516,6 +519,26 @@ export default function ViewersPage() { } } + async function unlockLogin(v: Viewer) { + setActionId(`unlock-${v.id}`); + setErr(null); + try { + const res = await fetch(`/api/v1/admin/viewers/${v.id}/unlock-login`, { + method: "POST", + credentials: "include", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setErr(data.error?.message ?? "Ontgrendelen mislukt"); + return; + } + setMsg(`Login ontgrendeld voor ${v.email}`); + load(); + } finally { + setActionId(null); + } + } + async function deleteViewer(v: Viewer) { const label = v.name || v.email; if ( @@ -708,6 +731,14 @@ export default function ViewersPage() { {v.deviceCount} apparaat{v.deviceCount === 1 ? "" : "en"} + {v.loginLocked ? ( + Login vergrendeld + ) : (v.loginFailedCount ?? 0) > 0 ? ( + + {v.loginFailedCount} foute login + {(v.loginFailedCount ?? 0) === 1 ? "" : "s"} + + ) : null} {open ? "▴" : "▾"} @@ -760,6 +791,28 @@ export default function ViewersPage() {

Bepaalt of deze gebruiker kan inloggen en de app-inhoud mag gebruiken.

+ {(v.loginLocked || (v.loginFailedCount ?? 0) > 0) && ( +
+

+ {v.loginLocked + ? `Login vergrendeld tot ${ + v.loginLockedUntil + ? new Date(v.loginLockedUntil).toLocaleString("nl-NL") + : "…" + } (3 foute pogingen).` + : `${v.loginFailedCount} foute wachtwoordpoging${ + (v.loginFailedCount ?? 0) === 1 ? "" : "en" + } (lock na 3).`} +

+ +
+ )}
@@ -1281,6 +1334,22 @@ export default function ViewersPage() { gap: 0.55rem; align-content: start; } + .login-lock-box { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0.9rem; + border-radius: 10px; + border: 1px solid rgba(248, 113, 113, 0.35); + background: rgba(248, 113, 113, 0.08); + } + .login-lock-box p { + margin: 0; + flex: 1; + min-width: 200px; + font-size: 0.88rem; + } .detail-block h3 { margin: 0; font-size: 0.92rem; diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 7645f23..50837d1 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -70,6 +70,33 @@ class ApiClient( } } + suspend fun login( + email: String, + password: String, + deviceName: String, + platform: String = "android", + ): PollResult.Linked = withContext(Dispatchers.IO) { + val body = JSONObject() + .put("email", email.trim()) + .put("password", password) + .put("deviceName", deviceName) + .put("platform", platform) + .toString() + .toRequestBody(jsonMedia) + val req = Request.Builder() + .url("$baseUrl/api/v1/client/login") + .post(body) + .build() + parseObject(execute(req)) { o -> + PollResult.Linked( + refreshToken = o.getString("refreshToken"), + viewerName = o.optJSONObject("viewer")?.optString("name") + ?: o.optJSONObject("viewer")?.optString("email") + ?: "Kijker", + ) + } + } + suspend fun home(): HomePayload = withContext(Dispatchers.IO) { val req = authed("$baseUrl/api/v1/client/home").get().build() parseObject(execute(req)) { o -> diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/AuthScreens.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/AuthScreens.kt new file mode 100644 index 0000000..0b1a5b1 --- /dev/null +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/AuthScreens.kt @@ -0,0 +1,176 @@ +package nl.vonas.mediacluster.tv.ui + +import android.os.Build +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Text +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.SessionStore +import nl.vonas.mediacluster.tv.ui.components.McButton +import nl.vonas.mediacluster.tv.ui.theme.McColors + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun AuthChoiceScreen( + onEmailLogin: () -> Unit, + onDeviceCode: () -> Unit, + onChangeServer: () -> Unit, +) { + Box( + Modifier + .fillMaxSize() + .background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .widthIn(max = 420.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(McColors.BgElevated.copy(alpha = 0.95f)) + .padding(28.dp), + ) { + Text("VMC", color = McColors.Accent, fontSize = 16.sp, fontWeight = FontWeight.Black, letterSpacing = 2.sp) + Text("Sign in", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold) + Text( + "Use your account email, or link this device with a code", + color = McColors.TextDim, + fontSize = 14.sp, + ) + Spacer(Modifier.height(6.dp)) + McButton("Sign in with email", primary = true, modifier = Modifier.fillMaxWidth()) { onEmailLogin() } + McButton("Link with code", modifier = Modifier.fillMaxWidth()) { onDeviceCode() } + McButton("Change server", modifier = Modifier.fillMaxWidth()) { onChangeServer() } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun PasswordLoginScreen( + api: ApiClient, + store: SessionStore, + platform: String = "android", + onBack: () -> Unit, + onLoggedIn: () -> Unit, +) { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val fieldColors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = McColors.Accent, + unfocusedBorderColor = Color(0x44FFFFFF), + focusedLabelColor = McColors.Accent, + unfocusedLabelColor = McColors.TextDim, + cursorColor = Color.White, + ) + + fun submit() { + if (busy) return + if (email.isBlank() || password.isEmpty()) { + error = "E-mail and password required" + return + } + busy = true + error = null + scope.launch { + try { + val base = store.apiBase.first() + api.configure(base, null) + val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android" } + val linked = api.login(email.trim(), password, deviceName, platform) + store.setToken(linked.refreshToken) + api.configure(base, linked.refreshToken) + onLoggedIn() + } catch (e: Exception) { + error = e.message ?: "Login failed" + } finally { + busy = false + } + } + } + + Box( + Modifier + .fillMaxSize() + .background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .widthIn(max = 420.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(McColors.BgElevated.copy(alpha = 0.95f)) + .padding(28.dp), + ) { + Text("Sign in with email", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold) + OutlinedTextField( + value = email, + onValueChange = { email = it; error = null }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { androidx.compose.material3.Text("Email") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), + colors = fieldColors, + ) + OutlinedTextField( + value = password, + onValueChange = { password = it; error = null }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { androidx.compose.material3.Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { submit() }), + colors = fieldColors, + ) + if (error != null) Text(error!!, color = McColors.Danger, fontSize = 13.sp) + McButton(if (busy) "Signing in…" else "Sign in", primary = true, modifier = Modifier.fillMaxWidth()) { submit() } + McButton("Back", modifier = Modifier.fillMaxWidth()) { onBack() } + } + } +} diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index fc2df94..5561880 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -138,7 +138,7 @@ fun McNav() { NavHost( navController = nav, startDestination = when { - !authed -> "link" + !authed -> "server" !hasProfile -> "profiles" else -> "main" }, @@ -147,11 +147,50 @@ fun McNav() { popEnterTransition = { McTransitions.popEnter }, popExitTransition = { McTransitions.popExit }, ) { + composable("server") { + ServerChoiceScreen(api, store) { + nav.navigate("auth") { popUpTo("server") { inclusive = false } } + } + } + composable("auth") { + AuthChoiceScreen( + onEmailLogin = { nav.navigate("password") }, + onDeviceCode = { nav.navigate("link") }, + onChangeServer = { + nav.navigate("server") { + popUpTo("server") { inclusive = true } + launchSingleTop = true + } + }, + ) + } + composable("password") { + PasswordLoginScreen( + api = api, + store = store, + platform = "android", + onBack = { nav.popBackStack() }, + onLoggedIn = { + authed = true + hasProfile = false + nav.navigate("profiles") { popUpTo("server") { inclusive = true } } + }, + ) + } composable("link") { - LinkScreen(api, store) { + LinkScreen( + api = api, + store = store, + onChangeServer = { + nav.navigate("server") { + popUpTo("server") { inclusive = true } + launchSingleTop = true + } + }, + ) { authed = true hasProfile = false - nav.navigate("profiles") { popUpTo("link") { inclusive = true } } + nav.navigate("profiles") { popUpTo("server") { inclusive = true } } } } composable("profiles") { @@ -214,7 +253,7 @@ fun McNav() { api.configure(store.apiBase.first(), null) authed = false hasProfile = false - nav.navigate("link") { popUpTo(0) { inclusive = true } } + nav.navigate("server") { popUpTo(0) { inclusive = true } } } }, ) @@ -614,14 +653,21 @@ private fun Splash() { @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit) { +private fun LinkScreen( + api: ApiClient, + store: SessionStore, + onChangeServer: () -> Unit = {}, + onLinked: () -> Unit, +) { var code by remember { mutableStateOf(null) } var linkUrl by remember { mutableStateOf("") } var status by remember { mutableStateOf("Verbinden…") } var error by remember { mutableStateOf(null) } + var serverLabel by remember { mutableStateOf("") } LaunchedEffect(Unit) { val base = store.apiBase.first() + serverLabel = base api.configure(base, null) try { val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android" } @@ -652,7 +698,7 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit } } } catch (e: Exception) { - error = e.message ?: "Kon master niet bereiken" + error = e.message ?: "Kon server niet bereiken" } } @@ -679,10 +725,14 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit color = McColors.TextDim, fontSize = 14.sp, ) + if (serverLabel.isNotBlank()) { + Text(serverLabel, color = Color(0xFF9AB0D0), fontSize = 12.sp) + } Text(code ?: "····-····", color = McColors.Accent, fontSize = 40.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp) if (linkUrl.isNotBlank()) Text(linkUrl, color = Color.White, fontSize = 18.sp) Text(status, color = McColors.TextDim, fontSize = 14.sp) if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp) + McButton("Change server", modifier = Modifier.fillMaxWidth()) { onChangeServer() } } } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/ServerChoiceScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/ServerChoiceScreen.kt new file mode 100644 index 0000000..536556c --- /dev/null +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/ServerChoiceScreen.kt @@ -0,0 +1,178 @@ +package nl.vonas.mediacluster.tv.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Text +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import nl.vonas.mediacluster.BuildConfig +import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.SessionStore +import nl.vonas.mediacluster.tv.ui.components.McButton +import nl.vonas.mediacluster.tv.ui.theme.McColors + +fun normalizeServerUrl(raw: String, fallback: String = BuildConfig.DEFAULT_API_BASE): String { + var s = raw.trim().trimEnd('/') + if (s.isBlank()) return fallback.trimEnd('/') + if (!s.startsWith("http://", ignoreCase = true) && !s.startsWith("https://", ignoreCase = true)) { + s = "https://$s" + } + return s.trimEnd('/') +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun ServerChoiceScreen( + api: ApiClient, + store: SessionStore, + onServerReady: () -> Unit, +) { + var customMode by remember { mutableStateOf(false) } + var urlInput by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + val current = store.apiBase.first() + if (current != BuildConfig.DEFAULT_API_BASE) { + urlInput = current + } + } + + fun useVmc() { + if (busy) return + busy = true + error = null + scope.launch { + val base = BuildConfig.DEFAULT_API_BASE.trimEnd('/') + store.setApiBase(base) + api.configure(base, null) + busy = false + onServerReady() + } + } + + fun useCustom() { + if (busy) return + if (urlInput.isBlank()) { + error = "Enter a server URL" + return + } + busy = true + error = null + scope.launch { + val base = normalizeServerUrl(urlInput) + store.setApiBase(base) + api.configure(base, null) + busy = false + onServerReady() + } + } + + Box( + Modifier + .fillMaxSize() + .background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .widthIn(max = 420.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(McColors.BgElevated.copy(alpha = 0.95f)) + .padding(28.dp), + ) { + Text("VMC", color = McColors.Accent, fontSize = 16.sp, fontWeight = FontWeight.Black, letterSpacing = 2.sp) + Text("Connect to your media", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold) + Text( + "VMC account or your own self-hosted server", + color = McColors.TextDim, + fontSize = 14.sp, + ) + Spacer(Modifier.height(6.dp)) + + if (!customMode) { + McButton("Continue with VMC", primary = true, modifier = Modifier.fillMaxWidth()) { useVmc() } + McButton("Connect to your server", modifier = Modifier.fillMaxWidth()) { + customMode = true + error = null + } + } else { + OutlinedTextField( + value = urlInput, + onValueChange = { + urlInput = it + error = null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { androidx.compose.material3.Text("Server URL") }, + placeholder = { androidx.compose.material3.Text("https://media.example.com") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { useCustom() }), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = McColors.Accent, + unfocusedBorderColor = Color(0x44FFFFFF), + focusedLabelColor = McColors.Accent, + unfocusedLabelColor = McColors.TextDim, + cursorColor = Color.White, + ), + ) + McButton( + if (busy) "Connecting…" else "Continue", + primary = true, + modifier = Modifier.fillMaxWidth(), + ) { useCustom() } + McButton("Back", modifier = Modifier.fillMaxWidth()) { + customMode = false + error = null + } + } + + if (error != null) { + Text(error!!, color = McColors.Danger, fontSize = 13.sp) + } + } + } +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 965f1f3..14fca2f 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -70,6 +70,33 @@ class ApiClient( } } + suspend fun login( + email: String, + password: String, + deviceName: String, + platform: String = "android_tv", + ): PollResult.Linked = withContext(Dispatchers.IO) { + val body = JSONObject() + .put("email", email.trim()) + .put("password", password) + .put("deviceName", deviceName) + .put("platform", platform) + .toString() + .toRequestBody(jsonMedia) + val req = Request.Builder() + .url("$baseUrl/api/v1/client/login") + .post(body) + .build() + parseObject(execute(req)) { o -> + PollResult.Linked( + refreshToken = o.getString("refreshToken"), + viewerName = o.optJSONObject("viewer")?.optString("name") + ?: o.optJSONObject("viewer")?.optString("email") + ?: "Kijker", + ) + } + } + suspend fun home(): HomePayload = withContext(Dispatchers.IO) { val req = authed("$baseUrl/api/v1/client/home").get().build() parseObject(execute(req)) { o -> diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/AuthScreens.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/AuthScreens.kt new file mode 100644 index 0000000..256c0d5 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/AuthScreens.kt @@ -0,0 +1,237 @@ +package nl.vonas.mediacluster.tv.ui + +import android.os.Build +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.SessionStore +import nl.vonas.mediacluster.tv.ui.components.McButton +import nl.vonas.mediacluster.tv.ui.theme.McColors + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun AuthChoiceScreen( + onEmailLogin: () -> Unit, + onDeviceCode: () -> Unit, + onChangeServer: () -> Unit, +) { + val primaryFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { + delay(80) + runCatching { primaryFocus.requestFocus() } + } + + Box( + Modifier + .fillMaxSize() + .background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier + .width(560.dp) + .clip(RoundedCornerShape(24.dp)) + .background(McColors.BgElevated.copy(alpha = 0.92f)) + .padding(40.dp), + ) { + Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp) + Text("Sign in", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold) + Text( + "Use your account email, or link this device with a code", + color = McColors.TextDim, + fontSize = 15.sp, + ) + Spacer(Modifier.height(8.dp)) + McButton( + "Sign in with email", + primary = true, + focusRequester = primaryFocus, + modifier = Modifier.fillMaxWidth(), + ) { onEmailLogin() } + McButton( + "Link with code", + modifier = Modifier.fillMaxWidth(), + ) { onDeviceCode() } + McButton("Change server", modifier = Modifier.fillMaxWidth()) { onChangeServer() } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun PasswordLoginScreen( + api: ApiClient, + store: SessionStore, + platform: String = "android_tv", + onBack: () -> Unit, + onLoggedIn: () -> Unit, +) { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val emailFocus = remember { FocusRequester() } + + LaunchedEffect(Unit) { + delay(80) + runCatching { emailFocus.requestFocus() } + } + + fun submit() { + if (busy) return + if (email.isBlank() || password.isEmpty()) { + error = "E-mail and password required" + return + } + busy = true + error = null + scope.launch { + try { + val base = store.apiBase.first() + api.configure(base, null) + val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" } + val linked = api.login(email.trim(), password, deviceName, platform) + store.setToken(linked.refreshToken) + api.configure(base, linked.refreshToken) + onLoggedIn() + } catch (e: Exception) { + error = e.message ?: "Login failed" + } finally { + busy = false + } + } + } + + Box( + Modifier + .fillMaxSize() + .background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .widthIn(max = 560.dp) + .width(520.dp) + .clip(RoundedCornerShape(24.dp)) + .background(McColors.BgElevated.copy(alpha = 0.92f)) + .padding(36.dp), + ) { + Text("Sign in with email", color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Bold) + Text("Your VMC account credentials", color = McColors.TextDim, fontSize = 14.sp) + + TvField( + value = email, + onValueChange = { email = it; error = null }, + placeholder = "email@example.com", + keyboardType = KeyboardType.Email, + modifier = Modifier.focusRequester(emailFocus), + imeAction = ImeAction.Next, + ) + TvField( + value = password, + onValueChange = { password = it; error = null }, + placeholder = "Password", + keyboardType = KeyboardType.Password, + password = true, + imeAction = ImeAction.Done, + onDone = { submit() }, + ) + + if (error != null) { + Text(error!!, color = McColors.Danger, fontSize = 14.sp) + } + + McButton( + if (busy) "Signing in…" else "Sign in", + primary = true, + modifier = Modifier.fillMaxWidth(), + ) { submit() } + McButton("Back", modifier = Modifier.fillMaxWidth()) { onBack() } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun TvField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + keyboardType: KeyboardType, + modifier: Modifier = Modifier, + password: Boolean = false, + imeAction: ImeAction = ImeAction.Next, + onDone: (() -> Unit)? = null, +) { + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0x22FFFFFF)) + .padding(horizontal = 16.dp, vertical = 14.dp) + .then(modifier), + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + cursorBrush = SolidColor(Color.White), + textStyle = TextStyle(color = Color.White, fontSize = 16.sp), + visualTransformation = if (password) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction), + keyboardActions = KeyboardActions(onDone = { onDone?.invoke() }), + modifier = Modifier.fillMaxWidth(), + decorationBox = { inner -> + if (value.isEmpty()) { + Text(placeholder, color = Color(0x66FFFFFF), fontSize = 16.sp) + } + inner() + }, + ) + } +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt index 766a9a9..9a4db47 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt @@ -15,9 +15,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -26,13 +26,16 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -55,7 +58,7 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors private val AzLetters = listOf("#") + ('A'..'Z').map { it.toString() } -@OptIn(ExperimentalTvMaterial3Api::class) +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun BrowseScreen( api: ApiClient, @@ -86,18 +89,20 @@ fun BrowseScreen( var loading by remember { mutableStateOf(true) } var error by remember { mutableStateOf(null) } var loadingMore by remember { mutableStateOf(false) } - var sort by remember(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) } - var activeLetter by remember(catalogId) { mutableStateOf(null) } + var sort by rememberSaveable(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) } + var activeLetter by rememberSaveable(catalogId) { mutableStateOf(null) } + var loadedKey by rememberSaveable(catalogId) { mutableStateOf(null) } var continueMenu by remember { mutableStateOf(null) } var menuReady by remember { mutableStateOf(false) } val menuFocus = remember { FocusRequester() } - val gridState = rememberLazyGridState() + val gridState = rememberSaveable(catalogId, sort, saver = LazyGridState.Saver) { LazyGridState() } val scope = rememberCoroutineScope() val showBrowseTools = !isContinue suspend fun load(reset: Boolean, jumpLetter: String? = null) { if (reset) { - loading = true + // Bestaande items houden het grid in compositie → focus blijft bij terugkeer + if (items.isEmpty()) loading = true error = null } else { loadingMore = true @@ -132,8 +137,11 @@ fun BrowseScreen( } LaunchedEffect(catalogId, sort) { - load(reset = true, jumpLetter = null) - gridState.scrollToItem(0) + val key = "$catalogId|$sort" + if (loadedKey != key || items.isEmpty()) { + load(reset = true, jumpLetter = null) + loadedKey = key + } } LaunchedEffect(gridState, items.size, total, listOffset) { @@ -248,7 +256,9 @@ fun BrowseScreen( contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .focusRestorer(), ) { items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item -> PosterCard( @@ -295,10 +305,11 @@ fun BrowseScreen( modifier = Modifier .align(Alignment.CenterEnd) .fillMaxHeight() - .padding(vertical = 72.dp, horizontal = 6.dp), + .padding(vertical = 16.dp, horizontal = 4.dp), onLetter = { letter -> scope.launch { load(reset = true, jumpLetter = letter) + loadedKey = "$catalogId|$sort" gridState.scrollToItem(0) } }, @@ -392,28 +403,35 @@ private fun AzScrubber( modifier: Modifier = Modifier, ) { Column( - modifier = modifier.width(28.dp), - verticalArrangement = Arrangement.SpaceEvenly, + modifier = modifier.width(30.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { AzLetters.forEach { letter -> val isSelected = selected.equals(letter, ignoreCase = true) Surface( onClick = { onLetter(letter) }, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.25f), + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(3.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.35f), colors = ClickableSurfaceDefaults.colors( containerColor = Color.Transparent, - focusedContainerColor = Color(0x44FFFFFF), + focusedContainerColor = Color(0x55FFFFFF), ), ) { - Text( - letter, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), - color = if (isSelected) McColors.Brand else Color(0xCCFFFFFF), - fontSize = 11.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, - ) + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + letter, + color = if (isSelected) McColors.Brand else Color(0xCCFFFFFF), + fontSize = 10.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + lineHeight = 10.sp, + ) + } } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 8454acd..37375c3 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -44,11 +45,13 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Brush @@ -69,6 +72,7 @@ import androidx.compose.ui.window.DialogProperties import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.dialog import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import androidx.tv.material3.ClickableSurfaceDefaults @@ -171,7 +175,7 @@ fun McNav() { NavHost( navController = nav, startDestination = when { - !authed -> "link" + !authed -> "server" !hasProfile -> "profiles" else -> "main" }, @@ -180,11 +184,50 @@ fun McNav() { popEnterTransition = { McTransitions.popEnter }, popExitTransition = { McTransitions.popExit }, ) { + composable("server") { + ServerChoiceScreen(api, store) { + nav.navigate("auth") { popUpTo("server") { inclusive = false } } + } + } + composable("auth") { + AuthChoiceScreen( + onEmailLogin = { nav.navigate("password") }, + onDeviceCode = { nav.navigate("link") }, + onChangeServer = { + nav.navigate("server") { + popUpTo("server") { inclusive = true } + launchSingleTop = true + } + }, + ) + } + composable("password") { + PasswordLoginScreen( + api = api, + store = store, + platform = "android_tv", + onBack = { nav.popBackStack() }, + onLoggedIn = { + authed = true + hasProfile = false + nav.navigate("profiles") { popUpTo("server") { inclusive = true } } + }, + ) + } composable("link") { - LinkScreen(api, store) { + LinkScreen( + api = api, + store = store, + onChangeServer = { + nav.navigate("server") { + popUpTo("server") { inclusive = true } + launchSingleTop = true + } + }, + ) { authed = true hasProfile = false - nav.navigate("profiles") { popUpTo("link") { inclusive = true } } + nav.navigate("profiles") { popUpTo("server") { inclusive = true } } } } composable("profiles") { @@ -255,7 +298,7 @@ fun McNav() { api.configure(store.apiBase.first(), null) authed = false hasProfile = false - nav.navigate("link") { popUpTo(0) { inclusive = true } } + nav.navigate("server") { popUpTo(0) { inclusive = true } } } }, ) @@ -283,27 +326,54 @@ fun McNav() { }, ) } - composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry -> - val id = entry.arguments?.getString("id") ?: return@composable - MovieDetailScreen( - api = api, - movieId = id, - onBack = { nav.popBackStack() }, - onPlay = { mediaFileId, _, startMs -> - nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs") - }, - ) + // dialog i.p.v. composable: Home/Browse blijven gecomposeerd → scroll+focus behouden + dialog( + route = "movie/{id}", + arguments = listOf(navArgument("id") { type = NavType.StringType }), + dialogProperties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { entry -> + val id = entry.arguments?.getString("id") ?: return@dialog + Box(Modifier.fillMaxSize().background(McColors.Bg)) { + MovieDetailScreen( + api = api, + movieId = id, + onBack = { nav.popBackStack() }, + onPlay = { mediaFileId, _, startMs -> + nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs") { + popUpTo("movie/$id") { inclusive = true } + } + }, + ) + } } - composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry -> - val id = entry.arguments?.getString("id") ?: return@composable - SeriesDetailScreen( - api = api, - seriesId = id, - onBack = { nav.popBackStack() }, - onPlay = { mediaFileId, episodeId, _ -> - nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") - }, - ) + dialog( + route = "series/{id}", + arguments = listOf(navArgument("id") { type = NavType.StringType }), + dialogProperties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { entry -> + val id = entry.arguments?.getString("id") ?: return@dialog + Box(Modifier.fillMaxSize().background(McColors.Bg)) { + SeriesDetailScreen( + api = api, + seriesId = id, + onBack = { nav.popBackStack() }, + onPlay = { mediaFileId, episodeId, _ -> + nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") { + popUpTo("series/$id") { inclusive = true } + } + }, + ) + } } composable( "player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}", @@ -380,14 +450,15 @@ private fun MainHub( val homeContentFocus = remember { FocusRequester() } val exitFocus = remember { FocusRequester() } var exitReady by remember { mutableStateOf(false) } - val homeListState = rememberLazyListState() + val homeListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } + var prevTabName by rememberSaveable { mutableStateOf(null) } val hubScope = rememberCoroutineScope() val activity = LocalContext.current as? Activity var shelfPrefs by remember { mutableStateOf>(emptyList()) } - var movieCatalog by remember { mutableStateOf("movies") } - var seriesCatalog by remember { mutableStateOf("series") } - var movieSubId by remember { mutableStateOf("movies") } - var seriesSubId by remember { mutableStateOf("series") } + var movieCatalog by rememberSaveable { mutableStateOf("movies") } + var seriesCatalog by rememberSaveable { mutableStateOf("series") } + var movieSubId by rememberSaveable { mutableStateOf("movies") } + var seriesSubId by rememberSaveable { mutableStateOf("series") } LaunchedEffect(exitConfirm) { if (exitConfirm) { @@ -501,12 +572,15 @@ private fun MainHub( } } - // Reset scroll wanneer je terugkomt op Home (tab-wissel) + // Reset scroll alleen bij echte tab-wissel naar Home (niet bij terugkeer uit detail/player) LaunchedEffect(tab) { - if (tab == MainTab.Home) { + val name = tab.name + val switched = prevTabName != null && prevTabName != name + if (tab == MainTab.Home && switched) { homeListState.scrollToItem(0) homeChromeCompact = false } + prevTabName = name } val homeOverlay = tab == MainTab.Home @@ -737,14 +811,21 @@ private fun Splash() { @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit) { +private fun LinkScreen( + api: ApiClient, + store: SessionStore, + onChangeServer: () -> Unit = {}, + onLinked: () -> Unit, +) { var code by remember { mutableStateOf(null) } var linkUrl by remember { mutableStateOf("") } var status by remember { mutableStateOf("Verbinden…") } var error by remember { mutableStateOf(null) } + var serverLabel by remember { mutableStateOf("") } LaunchedEffect(Unit) { val base = store.apiBase.first() + serverLabel = base api.configure(base, null) try { val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" } @@ -775,7 +856,7 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit } } } catch (e: Exception) { - error = e.message ?: "Kon master niet bereiken" + error = e.message ?: "Kon server niet bereiken" } } @@ -797,10 +878,15 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp) Text("Koppel dit apparaat", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold) Text("Open de link op je telefoon, log in en voer de code in.", color = McColors.TextDim, fontSize = 15.sp) + if (serverLabel.isNotBlank()) { + Text(serverLabel, color = Color(0xFF9AB0D0), fontSize = 13.sp) + } Text(code ?: "····-····", color = McColors.Accent, fontSize = 52.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp) if (linkUrl.isNotBlank()) Text(linkUrl, color = Color.White, fontSize = 18.sp) Text(status, color = McColors.TextDim, fontSize = 14.sp) if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp) + Spacer(Modifier.height(4.dp)) + McButton("Change server") { onChangeServer() } } } } @@ -824,6 +910,7 @@ private fun HomeScreen( var loading by remember { mutableStateOf(true) } var continueMenu by remember { mutableStateOf(null) } var menuReady by remember { mutableStateOf(false) } + var didInitialHomeFocus by rememberSaveable { mutableStateOf(false) } val scope = rememberCoroutineScope() val menuFocus = remember { FocusRequester() } val context = LocalContext.current @@ -865,12 +952,13 @@ private fun HomeScreen( } } - // Focus op Afspelen zit BUITEN de LazyColumn → geen bring-into-view scroll-jump + // Alleen eerste keer focus op Afspelen — niet bij terugkeer uit film/serie LaunchedEffect(home) { - if (home == null) return@LaunchedEffect + if (home == null || didInitialHomeFocus) return@LaunchedEffect delay(120) runCatching { contentFocusRequester.requestFocus() } onContentFocused() + didInitialHomeFocus = true } val spotlight = home?.spotlight.orEmpty().ifEmpty { @@ -958,8 +1046,13 @@ private fun HomeScreen( shelvesFocusRequester = firstShelfFocus, onContentFocused = { onContentFocused() - // Rest-offset na terugscrollen: hero weer volledig onder menu - scope.launch { listState.scrollToItem(0) } + // Alleen terug naar top als we al bovenaan zitten met rest-offset; + // niet forceren bij focus-events na terugkeer uit detail. + if (listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset in 1..160 + ) { + scope.launch { listState.scrollToItem(0) } + } }, onPlay = { item -> onContentFocused() @@ -1281,6 +1374,7 @@ private fun SpotlightHero( } } } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun ShelfBlock( title: String, @@ -1341,6 +1435,7 @@ private fun ShelfBlock( } } LazyRow( + modifier = Modifier.focusRestorer(), horizontalArrangement = Arrangement.spacedBy(12.dp), // Extra ruimte zodat focusedScale (1.10) posters niet afknipt contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 28.dp, bottom = 28.dp), diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ServerChoiceScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ServerChoiceScreen.kt new file mode 100644 index 0000000..af6d8e1 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ServerChoiceScreen.kt @@ -0,0 +1,214 @@ +package nl.vonas.mediacluster.tv.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import nl.vonas.mediacluster.tv.BuildConfig +import nl.vonas.mediacluster.tv.data.ApiClient +import nl.vonas.mediacluster.tv.data.SessionStore +import nl.vonas.mediacluster.tv.ui.components.McButton +import nl.vonas.mediacluster.tv.ui.theme.McColors + +/** Normaliseer server-URL: trim, strip trailing slash, voeg https:// toe indien nodig. */ +fun normalizeServerUrl(raw: String, fallback: String = BuildConfig.DEFAULT_API_BASE): String { + var s = raw.trim().trimEnd('/') + if (s.isBlank()) return fallback.trimEnd('/') + if (!s.startsWith("http://", ignoreCase = true) && !s.startsWith("https://", ignoreCase = true)) { + s = "https://$s" + } + return s.trimEnd('/') +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun ServerChoiceScreen( + api: ApiClient, + store: SessionStore, + onServerReady: () -> Unit, +) { + var customMode by remember { mutableStateOf(false) } + var urlInput by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val primaryFocus = remember { FocusRequester() } + val urlFocus = remember { FocusRequester() } + + LaunchedEffect(Unit) { + val current = store.apiBase.first() + if (current != BuildConfig.DEFAULT_API_BASE) { + urlInput = current + } + delay(80) + runCatching { primaryFocus.requestFocus() } + } + + LaunchedEffect(customMode) { + if (customMode) { + delay(80) + runCatching { urlFocus.requestFocus() } + } + } + + fun useVmc() { + if (busy) return + busy = true + error = null + scope.launch { + val base = BuildConfig.DEFAULT_API_BASE.trimEnd('/') + store.setApiBase(base) + api.configure(base, null) + busy = false + onServerReady() + } + } + + fun useCustom() { + if (busy) return + val base = normalizeServerUrl(urlInput) + if (urlInput.isBlank()) { + error = "Voer een server-URL in" + return + } + busy = true + error = null + scope.launch { + store.setApiBase(base) + api.configure(base, null) + busy = false + onServerReady() + } + } + + Box( + Modifier + .fillMaxSize() + .background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier + .widthIn(max = 640.dp) + .width(560.dp) + .clip(RoundedCornerShape(24.dp)) + .background(McColors.BgElevated.copy(alpha = 0.92f)) + .padding(40.dp), + ) { + Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp) + Text("Connect to your media", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold) + Text( + "VMC account or your own self-hosted server", + color = McColors.TextDim, + fontSize = 15.sp, + ) + Spacer(Modifier.height(8.dp)) + + if (!customMode) { + McButton( + "Continue with VMC", + primary = true, + focusRequester = primaryFocus, + modifier = Modifier.fillMaxWidth(), + ) { useVmc() } + McButton( + "Connect to your server", + modifier = Modifier.fillMaxWidth(), + ) { + customMode = true + error = null + } + } else { + Text("Server URL", color = McColors.TextDim, fontSize = 13.sp) + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0x22FFFFFF)) + .padding(horizontal = 16.dp, vertical = 14.dp), + ) { + BasicTextField( + value = urlInput, + onValueChange = { + urlInput = it + error = null + }, + singleLine = true, + cursorBrush = SolidColor(Color.White), + textStyle = TextStyle(color = Color.White, fontSize = 16.sp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { useCustom() }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(urlFocus), + decorationBox = { inner -> + if (urlInput.isEmpty()) { + Text("https://media.example.com", color = Color(0x66FFFFFF), fontSize = 16.sp) + } + inner() + }, + ) + } + McButton( + if (busy) "Connecting…" else "Continue", + primary = true, + focusRequester = primaryFocus, + modifier = Modifier.fillMaxWidth(), + ) { useCustom() } + McButton( + "Back", + modifier = Modifier.fillMaxWidth(), + ) { + customMode = false + error = null + } + } + + if (error != null) { + Text(error!!, color = McColors.Danger, fontSize = 14.sp) + } + } + } +} diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index 9e14bf6..e87c3fd 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; }; 1EF110B9F82E440F999ECE37 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B20E94E6B4034B1EF282B /* Components.swift */; }; E3C7ACAC5AC74107B90C6892 /* LinkScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19304FA5C7624099B8E97E1F /* LinkScreen.swift */; }; + C9A1B2D3E4F5061728394A5B /* ServerAuthScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* ServerAuthScreens.swift */; }; EEB314675D0049E6B289021D /* ProfilesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */; }; AE1CD4524BFF41A0ACB1FDDA /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5561954288524F7C9E41CA40 /* HomeScreen.swift */; }; E2F8D0CF0F39406FB37884A5 /* BrowseScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E44909AE0F474047ADB06E27 /* BrowseScreen.swift */; }; @@ -48,6 +49,7 @@ BF5E450343884C11B0920888 /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = ""; }; 314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; 19304FA5C7624099B8E97E1F /* LinkScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkScreen.swift; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B5C /* ServerAuthScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAuthScreens.swift; sourceTree = ""; }; EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesScreen.swift; sourceTree = ""; }; 5561954288524F7C9E41CA40 /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; E44909AE0F474047ADB06E27 /* BrowseScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseScreen.swift; sourceTree = ""; }; @@ -156,6 +158,7 @@ BF5E450343884C11B0920888 /* MainShell.swift */, 314B20E94E6B4034B1EF282B /* Components.swift */, 19304FA5C7624099B8E97E1F /* LinkScreen.swift */, + A1B2C3D4E5F60718293A4B5C /* ServerAuthScreens.swift */, EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */, 5561954288524F7C9E41CA40 /* HomeScreen.swift */, E44909AE0F474047ADB06E27 /* BrowseScreen.swift */, @@ -253,6 +256,7 @@ D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */, 1EF110B9F82E440F999ECE37 /* Components.swift in Sources */, E3C7ACAC5AC74107B90C6892 /* LinkScreen.swift in Sources */, + C9A1B2D3E4F5061728394A5B /* ServerAuthScreens.swift in Sources */, EEB314675D0049E6B289021D /* ProfilesScreen.swift in Sources */, AE1CD4524BFF41A0ACB1FDDA /* HomeScreen.swift in Sources */, E2F8D0CF0F39406FB37884A5 /* BrowseScreen.swift in Sources */, diff --git a/apps/ios/VMC/Data/ApiClient.swift b/apps/ios/VMC/Data/ApiClient.swift index 326d375..cff0b1d 100644 --- a/apps/ios/VMC/Data/ApiClient.swift +++ b/apps/ios/VMC/Data/ApiClient.swift @@ -54,6 +54,25 @@ actor ApiClient { } } + func login(email: String, password: String, deviceName: String) async throws -> (refreshToken: String, viewerName: String) { + struct LoginResponse: Decodable { + let refreshToken: String + let viewer: ViewerLite? + struct ViewerLite: Decodable { + let name: String? + let email: String? + } + } + let res: LoginResponse = try await post("/api/v1/client/login", json: [ + "email": email, + "password": password, + "deviceName": deviceName, + "platform": "ios", + ], authed: false) + let name = res.viewer?.name ?? res.viewer?.email ?? "Kijker" + return (res.refreshToken, name) + } + func home() async throws -> HomePayload { try await get("/api/v1/client/home") } func search(query: String) async throws -> [MediaItem] { diff --git a/apps/ios/VMC/Data/AppModel.swift b/apps/ios/VMC/Data/AppModel.swift index 84e61eb..f3f72d6 100644 --- a/apps/ios/VMC/Data/AppModel.swift +++ b/apps/ios/VMC/Data/AppModel.swift @@ -6,6 +6,9 @@ import Combine final class AppModel: ObservableObject { enum Phase: Equatable { case boot + case server + case auth + case password case link case profiles case main @@ -70,7 +73,7 @@ final class AppModel: ObservableObject { bootError = nil await api.configure(baseUrl: store.apiBase, token: store.refreshToken) guard store.isAuthed else { - phase = .link + phase = .server return } do { @@ -97,7 +100,7 @@ final class AppModel: ObservableObject { store.setToken(nil) favorites.bind(profileId: nil) await api.configure(baseUrl: store.apiBase, token: nil) - phase = .link + phase = .server } } @@ -138,7 +141,7 @@ final class AppModel: ObservableObject { await api.configure(baseUrl: store.apiBase, token: nil) favorites.bind(profileId: nil) iptvEnabled = false - phase = .link + phase = .server } static var deviceName: String { diff --git a/apps/ios/VMC/UI/LinkScreen.swift b/apps/ios/VMC/UI/LinkScreen.swift index ccd7d6d..91aae40 100644 --- a/apps/ios/VMC/UI/LinkScreen.swift +++ b/apps/ios/VMC/UI/LinkScreen.swift @@ -70,6 +70,11 @@ struct LinkScreen: View { Button("Opnieuw") { Task { await startLink() } } .buttonStyle(McPrimaryButtonStyle()) } + + Button("Change server") { app.phase = .server } + .buttonStyle(McSecondaryButtonStyle()) + Button("Back") { app.phase = .auth } + .buttonStyle(McSecondaryButtonStyle()) } .padding() .frame(maxWidth: 420) diff --git a/apps/ios/VMC/UI/RootView.swift b/apps/ios/VMC/UI/RootView.swift index bc8d10d..77167cc 100644 --- a/apps/ios/VMC/UI/RootView.swift +++ b/apps/ios/VMC/UI/RootView.swift @@ -9,6 +9,12 @@ struct RootView: View { switch app.phase { case .boot: SplashView() + case .server: + ServerChoiceScreen() + case .auth: + AuthChoiceScreen() + case .password: + PasswordLoginScreen() case .link: LinkScreen() case .profiles: diff --git a/apps/ios/VMC/UI/ServerAuthScreens.swift b/apps/ios/VMC/UI/ServerAuthScreens.swift new file mode 100644 index 0000000..ee34c93 --- /dev/null +++ b/apps/ios/VMC/UI/ServerAuthScreens.swift @@ -0,0 +1,224 @@ +import SwiftUI + +enum ServerUrl { + static func normalize(_ raw: String, fallback: String = SessionStore.defaultApiBase) -> String { + var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if s.isEmpty { return fallback.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } + if !s.lowercased().hasPrefix("http://") && !s.lowercased().hasPrefix("https://") { + s = "https://\(s)" + } + return s.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } +} + +private struct AuthBackground: View { + var body: some View { + LinearGradient( + colors: [ + Color(red: 0.12, green: 0.04, blue: 0.06), + McColors.bg, + Color(red: 0.04, green: 0.05, blue: 0.1), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + } +} + +struct ServerChoiceScreen: View { + @EnvironmentObject private var app: AppModel + @State private var customMode = false + @State private var urlInput = "" + @State private var error: String? + @State private var busy = false + + var body: some View { + ZStack { + AuthBackground() + VStack(spacing: 16) { + Text("VMC") + .font(McFonts.brand) + .foregroundStyle(McColors.accent) + + Text("Connect to your media") + .font(McFonts.title) + .foregroundStyle(McColors.text) + + Text("VMC account or your own self-hosted server") + .font(McFonts.body) + .foregroundStyle(McColors.textDim) + .multilineTextAlignment(.center) + + if !customMode { + Button("Continue with VMC") { useVmc() } + .buttonStyle(McPrimaryButtonStyle()) + .disabled(busy) + Button("Connect to your server") { + customMode = true + error = nil + if app.store.apiBase != SessionStore.defaultApiBase { + urlInput = app.store.apiBase + } + } + .buttonStyle(McSecondaryButtonStyle()) + } else { + TextField("https://media.example.com", text: $urlInput) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + .autocorrectionDisabled() + .padding(14) + .background(McColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .foregroundStyle(McColors.text) + + Button(busy ? "Connecting…" : "Continue") { useCustom() } + .buttonStyle(McPrimaryButtonStyle()) + .disabled(busy) + Button("Back") { + customMode = false + error = nil + } + .buttonStyle(McSecondaryButtonStyle()) + } + + if let error { + Text(error).font(McFonts.body).foregroundStyle(McColors.danger) + } + } + .padding(28) + .frame(maxWidth: 420) + .background(McColors.bgElevated.opacity(0.92), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .padding() + } + } + + private func useVmc() { + busy = true + error = nil + app.store.setApiBase(SessionStore.defaultApiBase) + Task { + await app.api.configure(baseUrl: app.store.apiBase, token: nil) + busy = false + app.phase = .auth + } + } + + private func useCustom() { + guard !urlInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + error = "Enter a server URL" + return + } + busy = true + error = nil + app.store.setApiBase(ServerUrl.normalize(urlInput)) + Task { + await app.api.configure(baseUrl: app.store.apiBase, token: nil) + busy = false + app.phase = .auth + } + } +} + +struct AuthChoiceScreen: View { + @EnvironmentObject private var app: AppModel + + var body: some View { + ZStack { + AuthBackground() + VStack(spacing: 16) { + Text("VMC") + .font(McFonts.brand) + .foregroundStyle(McColors.accent) + Text("Sign in") + .font(McFonts.title) + .foregroundStyle(McColors.text) + Text("Use your account email, or link this device with a code") + .font(McFonts.body) + .foregroundStyle(McColors.textDim) + .multilineTextAlignment(.center) + + Button("Sign in with email") { app.phase = .password } + .buttonStyle(McPrimaryButtonStyle()) + Button("Link with code") { app.phase = .link } + .buttonStyle(McSecondaryButtonStyle()) + Button("Change server") { app.phase = .server } + .buttonStyle(McSecondaryButtonStyle()) + } + .padding(28) + .frame(maxWidth: 420) + .background(McColors.bgElevated.opacity(0.92), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .padding() + } + } +} + +struct PasswordLoginScreen: View { + @EnvironmentObject private var app: AppModel + @State private var email = "" + @State private var password = "" + @State private var error: String? + @State private var busy = false + + var body: some View { + ZStack { + AuthBackground() + VStack(spacing: 14) { + Text("Sign in with email") + .font(McFonts.title) + .foregroundStyle(McColors.text) + Text("Your VMC account credentials") + .font(McFonts.caption) + .foregroundStyle(McColors.textDim) + + TextField("Email", text: $email) + .textInputAutocapitalization(.never) + .keyboardType(.emailAddress) + .autocorrectionDisabled() + .padding(14) + .background(McColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .foregroundStyle(McColors.text) + + SecureField("Password", text: $password) + .padding(14) + .background(McColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .foregroundStyle(McColors.text) + + if let error { + Text(error).font(McFonts.body).foregroundStyle(McColors.danger).multilineTextAlignment(.center) + } + + Button(busy ? "Signing in…" : "Sign in") { Task { await submit() } } + .buttonStyle(McPrimaryButtonStyle()) + .disabled(busy) + Button("Back") { app.phase = .auth } + .buttonStyle(McSecondaryButtonStyle()) + } + .padding(28) + .frame(maxWidth: 420) + .background(McColors.bgElevated.opacity(0.92), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .padding() + } + } + + private func submit() async { + guard !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !password.isEmpty else { + error = "E-mail and password required" + return + } + busy = true + error = nil + await app.api.configure(baseUrl: app.store.apiBase, token: nil) + do { + let result = try await app.api.login( + email: email.trimmingCharacters(in: .whitespacesAndNewlines), + password: password, + deviceName: AppModel.deviceName + ) + await app.didLink(token: result.refreshToken) + } catch { + self.error = error.localizedDescription + } + busy = false + } +} diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index f4e6438..ccf9eb8 100644 --- a/apps/ios/VMC/UI/SettingsScreen.swift +++ b/apps/ios/VMC/UI/SettingsScreen.swift @@ -60,6 +60,17 @@ struct SettingsScreen: View { } } + Section("Server") { + LabeledContent("URL", value: app.store.apiBase) + Button { + Task { await app.logout() } + } label: { + Label("Server / account wisselen", systemImage: "server.rack") + .foregroundStyle(McColors.text) + } + .listRowBackground(McColors.bgElevated) + } + Section { Button(role: .destructive) { Task { await app.logout() } diff --git a/apps/master-api/prisma/migrations/20260910010000_viewer_login_locks/migration.sql b/apps/master-api/prisma/migrations/20260910010000_viewer_login_locks/migration.sql new file mode 100644 index 0000000..0599800 --- /dev/null +++ b/apps/master-api/prisma/migrations/20260910010000_viewer_login_locks/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE "viewer_login_locks" ( + "email" TEXT NOT NULL, + "failed_count" INTEGER NOT NULL DEFAULT 0, + "locked_until" TIMESTAMP(3), + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "viewer_login_locks_pkey" PRIMARY KEY ("email") +); diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index d70b617..1604ab5 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -101,6 +101,16 @@ model ViewerUser { @@map("viewer_users") } +/** Bruteforce-bescherming op e-mail/wachtwoord (claim + direct login). */ +model ViewerLoginLock { + email String @id + failedCount Int @default(0) @map("failed_count") + lockedUntil DateTime? @map("locked_until") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("viewer_login_locks") +} + /** Netflix-stijl kijkprofiel onder één ViewerUser (voortgang/prefs per profiel). */ model ViewerProfile { id String @id @default(uuid()) diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 406b52d..25598c2 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -334,6 +334,42 @@ export function registerViewerRoutes( } ); + /** Directe app-login (e-mail + wachtwoord) → refresh token. */ + app.post( + "/api/v1/client/login", + { + config: { + rateLimit: { max: 20, timeWindow: "1 minute" }, + }, + }, + async (request) => { + const body = (request.body || {}) as { + email?: string; + password?: string; + deviceName?: string; + platform?: string; + }; + if (!body.email?.trim() || !body.password) { + throw new AppError("INVALID_REQUEST", "E-mail en wachtwoord verplicht", 400); + } + return viewers.loginWithPassword({ + email: body.email, + password: body.password, + deviceName: body.deviceName, + platform: body.platform, + }); + } + ); + + app.post( + "/api/v1/admin/viewers/:id/unlock-login", + { preHandler: requireAdmin }, + async (request) => { + const { id } = request.params as { id: string }; + return viewers.unlockViewerLogin(id); + } + ); + // ——— Authenticated client ——— app.get("/api/v1/client/me", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index f12007c..ad9bf01 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -28,6 +28,10 @@ const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const POLL_INTERVAL_SEC = 2; /** Max titels in Recent-/4K-catalogi (niet de hele bibliotheek). */ const RECENT_CATALOG_CAP = 120; +/** Foute wachtwoordpogingen vóór tijdelijke lock. */ +const LOGIN_MAX_FAILURES = 3; +/** Lock-duur na te veel foute logins. */ +const LOGIN_LOCK_MS = 30 * 60 * 1000; export type AuthedViewer = { viewerId: string; @@ -147,7 +151,18 @@ export class ViewerService { }, }, }); - return rows.map((v) => ({ + const emails = rows.map((v) => v.email.toLowerCase()); + const locks = emails.length + ? await prisma.viewerLoginLock.findMany({ where: { email: { in: emails } } }) + : []; + const lockByEmail = new Map(locks.map((l) => [l.email, l])); + const now = Date.now(); + + return rows.map((v) => { + const lock = lockByEmail.get(v.email.toLowerCase()); + const lockedUntil = lock?.lockedUntil ?? null; + const loginLocked = !!(lockedUntil && lockedUntil.getTime() > now); + return { id: v.id, email: v.email, name: v.name, @@ -187,7 +202,11 @@ export class ViewerService { createdAt: v.createdAt, deviceCount: v._count.devices, devices: v.devices, - })); + loginFailedCount: lock?.failedCount ?? 0, + loginLocked, + loginLockedUntil: loginLocked ? lockedUntil : null, + }; + }); } async deleteViewer(id: string) { @@ -332,9 +351,21 @@ export class ViewerService { code: string; }) { const email = opts.email.trim().toLowerCase(); + await this.assertLoginAllowed(email); + const viewer = await prisma.viewerUser.findUnique({ where: { email } }); - if (!viewer || !(await verifyPassword(viewer.passwordHash, opts.password))) { - throw new AppError("INVALID_CREDENTIALS", "Ongeldige login", 401); + const passwordOk = + !!viewer && (await verifyPassword(viewer.passwordHash, opts.password)); + if (!viewer || !passwordOk) { + const lock = await this.recordFailedLogin(email); + const remaining = Math.max(0, LOGIN_MAX_FAILURES - lock.failedCount); + throw new AppError( + "INVALID_CREDENTIALS", + remaining > 0 + ? `Ongeldige login (${remaining} poging${remaining === 1 ? "" : "en"} over)` + : "Account tijdelijk geblokkeerd na te veel foute pogingen", + 401 + ); } if (!viewer.enabled || !viewer.appAccess) { throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403); @@ -358,6 +389,7 @@ export class ViewerService { claimTokenHash: hashToken(claimToken), }, }); + await this.clearLoginLock(email); return { ok: true, @@ -366,6 +398,98 @@ export class ViewerService { }; } + /** + * Directe app-login met e-mail/wachtwoord (zonder TV-code). + * Maakt een ViewerDevice + refresh token aan. + */ + async loginWithPassword(opts: { + email: string; + password: string; + deviceName?: string; + platform?: string; + }) { + const email = opts.email.trim().toLowerCase(); + await this.assertLoginAllowed(email); + + const viewer = await prisma.viewerUser.findUnique({ where: { email } }); + const passwordOk = + !!viewer && (await verifyPassword(viewer.passwordHash, opts.password)); + if (!viewer || !passwordOk) { + const lock = await this.recordFailedLogin(email); + const remaining = Math.max(0, LOGIN_MAX_FAILURES - lock.failedCount); + throw new AppError( + "INVALID_CREDENTIALS", + remaining > 0 + ? `Ongeldige login (${remaining} poging${remaining === 1 ? "" : "en"} over)` + : "Account tijdelijk geblokkeerd na te veel foute pogingen", + 401 + ); + } + if (!viewer.enabled || !viewer.appAccess) { + throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403); + } + + const refreshToken = generateSecureToken(32); + const device = await prisma.viewerDevice.create({ + data: { + viewerUserId: viewer.id, + name: (opts.deviceName || "App").trim().slice(0, 80) || "App", + platform: (opts.platform || "unknown").trim().slice(0, 40) || "unknown", + refreshTokenHash: hashToken(refreshToken), + lastSeenAt: new Date(), + }, + }); + await this.clearLoginLock(email); + await ensureOwnerProfile(viewer.id, viewer.name); + + return { + refreshToken, + device: { id: device.id, name: device.name, platform: device.platform }, + viewer: { id: viewer.id, email: viewer.email, name: viewer.name }, + }; + } + + async unlockViewerLogin(viewerId: string) { + const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId } }); + if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404); + await this.clearLoginLock(viewer.email.toLowerCase()); + return { ok: true, email: viewer.email }; + } + + private async assertLoginAllowed(email: string) { + const lock = await prisma.viewerLoginLock.findUnique({ where: { email } }); + if (lock?.lockedUntil && lock.lockedUntil.getTime() > Date.now()) { + const mins = Math.ceil((lock.lockedUntil.getTime() - Date.now()) / 60_000); + throw new AppError( + "LOGIN_LOCKED", + `Te veel foute pogingen. Probeer over ${mins} minuut${mins === 1 ? "" : "en"} opnieuw, of vraag de beheerder om te ontgrendelen.`, + 429 + ); + } + } + + private async recordFailedLogin(email: string) { + const existing = await prisma.viewerLoginLock.findUnique({ where: { email } }); + const now = Date.now(); + // Verlopen lock → teller opnieuw beginnen + const baseCount = + existing?.lockedUntil && existing.lockedUntil.getTime() <= now + ? 0 + : existing?.failedCount ?? 0; + const failedCount = baseCount + 1; + const lockedUntil = + failedCount >= LOGIN_MAX_FAILURES ? new Date(now + LOGIN_LOCK_MS) : null; + return prisma.viewerLoginLock.upsert({ + where: { email }, + create: { email, failedCount, lockedUntil }, + update: { failedCount, lockedUntil }, + }); + } + + private async clearLoginLock(email: string) { + await prisma.viewerLoginLock.deleteMany({ where: { email } }); + } + async authFromBearer(authHeader?: string): Promise { if (!authHeader?.startsWith("Bearer ")) { throw new AppError("UNAUTHORIZED", "Bearer token verplicht", 401);