@@ -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);