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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-10 01:32:35 +02:00
parent 00fa9cd9c7
commit 303383ea4e
21 changed files with 1614 additions and 72 deletions

View file

@ -62,6 +62,9 @@ type Viewer = {
devices: Device[]; devices: Device[];
iptv?: ViewerIptv | null; iptv?: ViewerIptv | null;
addonTokens?: AddonTokenRow[]; addonTokens?: AddonTokenRow[];
loginFailedCount?: number;
loginLocked?: boolean;
loginLockedUntil?: string | null;
}; };
type IptvFormState = { 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) { async function deleteViewer(v: Viewer) {
const label = v.name || v.email; const label = v.name || v.email;
if ( if (
@ -708,6 +731,14 @@ export default function ViewersPage() {
<span className="pill muted-pill"> <span className="pill muted-pill">
{v.deviceCount} apparaat{v.deviceCount === 1 ? "" : "en"} {v.deviceCount} apparaat{v.deviceCount === 1 ? "" : "en"}
</span> </span>
{v.loginLocked ? (
<span className="pill bad">Login vergrendeld</span>
) : (v.loginFailedCount ?? 0) > 0 ? (
<span className="pill warn">
{v.loginFailedCount} foute login
{(v.loginFailedCount ?? 0) === 1 ? "" : "s"}
</span>
) : null}
</div> </div>
<span className="chevron" aria-hidden> <span className="chevron" aria-hidden>
{open ? "▴" : "▾"} {open ? "▴" : "▾"}
@ -760,6 +791,28 @@ export default function ViewersPage() {
<p className="muted detail-copy"> <p className="muted detail-copy">
Bepaalt of deze gebruiker kan inloggen en de app-inhoud mag gebruiken. Bepaalt of deze gebruiker kan inloggen en de app-inhoud mag gebruiken.
</p> </p>
{(v.loginLocked || (v.loginFailedCount ?? 0) > 0) && (
<div className="login-lock-box">
<p>
{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).`}
</p>
<button
type="button"
disabled={actionId === `unlock-${v.id}`}
onClick={() => void unlockLogin(v)}
>
Login ontgrendelen
</button>
</div>
)}
<div className="toggle-rows"> <div className="toggle-rows">
<div className="toggle-row"> <div className="toggle-row">
<div> <div>
@ -1281,6 +1334,22 @@ export default function ViewersPage() {
gap: 0.55rem; gap: 0.55rem;
align-content: start; 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 { .detail-block h3 {
margin: 0; margin: 0;
font-size: 0.92rem; font-size: 0.92rem;

View file

@ -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) { suspend fun home(): HomePayload = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/home").get().build() val req = authed("$baseUrl/api/v1/client/home").get().build()
parseObject(execute(req)) { o -> parseObject(execute(req)) { o ->

View file

@ -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<String?>(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() }
}
}
}

View file

@ -138,7 +138,7 @@ fun McNav() {
NavHost( NavHost(
navController = nav, navController = nav,
startDestination = when { startDestination = when {
!authed -> "link" !authed -> "server"
!hasProfile -> "profiles" !hasProfile -> "profiles"
else -> "main" else -> "main"
}, },
@ -147,11 +147,50 @@ fun McNav() {
popEnterTransition = { McTransitions.popEnter }, popEnterTransition = { McTransitions.popEnter },
popExitTransition = { McTransitions.popExit }, popExitTransition = { McTransitions.popExit },
) { ) {
composable("link") { composable("server") {
LinkScreen(api, store) { 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 authed = true
hasProfile = false hasProfile = false
nav.navigate("profiles") { popUpTo("link") { inclusive = true } } nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
},
)
}
composable("link") {
LinkScreen(
api = api,
store = store,
onChangeServer = {
nav.navigate("server") {
popUpTo("server") { inclusive = true }
launchSingleTop = true
}
},
) {
authed = true
hasProfile = false
nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
} }
} }
composable("profiles") { composable("profiles") {
@ -214,7 +253,7 @@ fun McNav() {
api.configure(store.apiBase.first(), null) api.configure(store.apiBase.first(), null)
authed = false authed = false
hasProfile = 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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @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<String?>(null) } var code by remember { mutableStateOf<String?>(null) }
var linkUrl by remember { mutableStateOf("") } var linkUrl by remember { mutableStateOf("") }
var status by remember { mutableStateOf("Verbinden…") } var status by remember { mutableStateOf("Verbinden…") }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var serverLabel by remember { mutableStateOf("") }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val base = store.apiBase.first() val base = store.apiBase.first()
serverLabel = base
api.configure(base, null) api.configure(base, null)
try { try {
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android" } 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) { } 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, color = McColors.TextDim,
fontSize = 14.sp, 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) 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) if (linkUrl.isNotBlank()) Text(linkUrl, color = Color.White, fontSize = 18.sp)
Text(status, color = McColors.TextDim, fontSize = 14.sp) Text(status, color = McColors.TextDim, fontSize = 14.sp)
if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp) if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp)
McButton("Change server", modifier = Modifier.fillMaxWidth()) { onChangeServer() }
} }
} }
} }

View file

@ -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<String?>(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)
}
}
}
}

View file

@ -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) { suspend fun home(): HomePayload = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/home").get().build() val req = authed("$baseUrl/api/v1/client/home").get().build()
parseObject(execute(req)) { o -> parseObject(execute(req)) { o ->

View file

@ -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<String?>(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()
},
)
}
}

View file

@ -15,9 +15,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells 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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -26,13 +26,16 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -55,7 +58,7 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
private val AzLetters = listOf("#") + ('A'..'Z').map { it.toString() } private val AzLetters = listOf("#") + ('A'..'Z').map { it.toString() }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
fun BrowseScreen( fun BrowseScreen(
api: ApiClient, api: ApiClient,
@ -86,18 +89,20 @@ fun BrowseScreen(
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var loadingMore by remember { mutableStateOf(false) } var loadingMore by remember { mutableStateOf(false) }
var sort by remember(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) } var sort by rememberSaveable(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) }
var activeLetter by remember(catalogId) { mutableStateOf<String?>(null) } var activeLetter by rememberSaveable(catalogId) { mutableStateOf<String?>(null) }
var loadedKey by rememberSaveable(catalogId) { mutableStateOf<String?>(null) }
var continueMenu by remember { mutableStateOf<MediaItem?>(null) } var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
var menuReady by remember { mutableStateOf(false) } var menuReady by remember { mutableStateOf(false) }
val menuFocus = remember { FocusRequester() } val menuFocus = remember { FocusRequester() }
val gridState = rememberLazyGridState() val gridState = rememberSaveable(catalogId, sort, saver = LazyGridState.Saver) { LazyGridState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val showBrowseTools = !isContinue val showBrowseTools = !isContinue
suspend fun load(reset: Boolean, jumpLetter: String? = null) { suspend fun load(reset: Boolean, jumpLetter: String? = null) {
if (reset) { if (reset) {
loading = true // Bestaande items houden het grid in compositie → focus blijft bij terugkeer
if (items.isEmpty()) loading = true
error = null error = null
} else { } else {
loadingMore = true loadingMore = true
@ -132,8 +137,11 @@ fun BrowseScreen(
} }
LaunchedEffect(catalogId, sort) { LaunchedEffect(catalogId, sort) {
val key = "$catalogId|$sort"
if (loadedKey != key || items.isEmpty()) {
load(reset = true, jumpLetter = null) load(reset = true, jumpLetter = null)
gridState.scrollToItem(0) loadedKey = key
}
} }
LaunchedEffect(gridState, items.size, total, listOffset) { 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), contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.focusRestorer(),
) { ) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item -> items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard( PosterCard(
@ -295,10 +305,11 @@ fun BrowseScreen(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.fillMaxHeight() .fillMaxHeight()
.padding(vertical = 72.dp, horizontal = 6.dp), .padding(vertical = 16.dp, horizontal = 4.dp),
onLetter = { letter -> onLetter = { letter ->
scope.launch { scope.launch {
load(reset = true, jumpLetter = letter) load(reset = true, jumpLetter = letter)
loadedKey = "$catalogId|$sort"
gridState.scrollToItem(0) gridState.scrollToItem(0)
} }
}, },
@ -392,29 +403,36 @@ private fun AzScrubber(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
modifier = modifier.width(28.dp), modifier = modifier.width(30.dp),
verticalArrangement = Arrangement.SpaceEvenly,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
AzLetters.forEach { letter -> AzLetters.forEach { letter ->
val isSelected = selected.equals(letter, ignoreCase = true) val isSelected = selected.equals(letter, ignoreCase = true)
Surface( Surface(
onClick = { onLetter(letter) }, onClick = { onLetter(letter) },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), modifier = Modifier
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.25f), .weight(1f)
.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(3.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.35f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
focusedContainerColor = Color(0x44FFFFFF), focusedContainerColor = Color(0x55FFFFFF),
), ),
) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) { ) {
Text( Text(
letter, letter,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
color = if (isSelected) McColors.Brand else Color(0xCCFFFFFF), color = if (isSelected) McColors.Brand else Color(0xCCFFFFFF),
fontSize = 11.sp, fontSize = 10.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
lineHeight = 10.sp,
) )
} }
} }
} }
}
} }

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
@ -69,6 +72,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.dialog
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
@ -171,7 +175,7 @@ fun McNav() {
NavHost( NavHost(
navController = nav, navController = nav,
startDestination = when { startDestination = when {
!authed -> "link" !authed -> "server"
!hasProfile -> "profiles" !hasProfile -> "profiles"
else -> "main" else -> "main"
}, },
@ -180,11 +184,50 @@ fun McNav() {
popEnterTransition = { McTransitions.popEnter }, popEnterTransition = { McTransitions.popEnter },
popExitTransition = { McTransitions.popExit }, popExitTransition = { McTransitions.popExit },
) { ) {
composable("link") { composable("server") {
LinkScreen(api, store) { 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 authed = true
hasProfile = false hasProfile = false
nav.navigate("profiles") { popUpTo("link") { inclusive = true } } nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
},
)
}
composable("link") {
LinkScreen(
api = api,
store = store,
onChangeServer = {
nav.navigate("server") {
popUpTo("server") { inclusive = true }
launchSingleTop = true
}
},
) {
authed = true
hasProfile = false
nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
} }
} }
composable("profiles") { composable("profiles") {
@ -255,7 +298,7 @@ fun McNav() {
api.configure(store.apiBase.first(), null) api.configure(store.apiBase.first(), null)
authed = false authed = false
hasProfile = false hasProfile = false
nav.navigate("link") { popUpTo(0) { inclusive = true } } nav.navigate("server") { popUpTo(0) { inclusive = true } }
} }
}, },
) )
@ -283,28 +326,55 @@ fun McNav() {
}, },
) )
} }
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry -> // dialog i.p.v. composable: Home/Browse blijven gecomposeerd → scroll+focus behouden
val id = entry.arguments?.getString("id") ?: return@composable 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( MovieDetailScreen(
api = api, api = api,
movieId = id, movieId = id,
onBack = { nav.popBackStack() }, onBack = { nav.popBackStack() },
onPlay = { mediaFileId, _, startMs -> onPlay = { mediaFileId, _, startMs ->
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$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 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( SeriesDetailScreen(
api = api, api = api,
seriesId = id, seriesId = id,
onBack = { nav.popBackStack() }, onBack = { nav.popBackStack() },
onPlay = { mediaFileId, episodeId, _ -> onPlay = { mediaFileId, episodeId, _ ->
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") {
popUpTo("series/$id") { inclusive = true }
}
}, },
) )
} }
}
composable( composable(
"player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}", "player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}",
arguments = listOf( arguments = listOf(
@ -380,14 +450,15 @@ private fun MainHub(
val homeContentFocus = remember { FocusRequester() } val homeContentFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() } val exitFocus = remember { FocusRequester() }
var exitReady by remember { mutableStateOf(false) } var exitReady by remember { mutableStateOf(false) }
val homeListState = rememberLazyListState() val homeListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() }
var prevTabName by rememberSaveable { mutableStateOf<String?>(null) }
val hubScope = rememberCoroutineScope() val hubScope = rememberCoroutineScope()
val activity = LocalContext.current as? Activity val activity = LocalContext.current as? Activity
var shelfPrefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) } var shelfPrefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
var movieCatalog by remember { mutableStateOf("movies") } var movieCatalog by rememberSaveable { mutableStateOf("movies") }
var seriesCatalog by remember { mutableStateOf("series") } var seriesCatalog by rememberSaveable { mutableStateOf("series") }
var movieSubId by remember { mutableStateOf("movies") } var movieSubId by rememberSaveable { mutableStateOf("movies") }
var seriesSubId by remember { mutableStateOf("series") } var seriesSubId by rememberSaveable { mutableStateOf("series") }
LaunchedEffect(exitConfirm) { LaunchedEffect(exitConfirm) {
if (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) { LaunchedEffect(tab) {
if (tab == MainTab.Home) { val name = tab.name
val switched = prevTabName != null && prevTabName != name
if (tab == MainTab.Home && switched) {
homeListState.scrollToItem(0) homeListState.scrollToItem(0)
homeChromeCompact = false homeChromeCompact = false
} }
prevTabName = name
} }
val homeOverlay = tab == MainTab.Home val homeOverlay = tab == MainTab.Home
@ -737,14 +811,21 @@ private fun Splash() {
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @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<String?>(null) } var code by remember { mutableStateOf<String?>(null) }
var linkUrl by remember { mutableStateOf("") } var linkUrl by remember { mutableStateOf("") }
var status by remember { mutableStateOf("Verbinden…") } var status by remember { mutableStateOf("Verbinden…") }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var serverLabel by remember { mutableStateOf("") }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val base = store.apiBase.first() val base = store.apiBase.first()
serverLabel = base
api.configure(base, null) api.configure(base, null)
try { try {
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" } 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) { } 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("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("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) 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) 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) if (linkUrl.isNotBlank()) Text(linkUrl, color = Color.White, fontSize = 18.sp)
Text(status, color = McColors.TextDim, fontSize = 14.sp) Text(status, color = McColors.TextDim, fontSize = 14.sp)
if (error != null) Text(error!!, color = McColors.Danger, 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 loading by remember { mutableStateOf(true) }
var continueMenu by remember { mutableStateOf<MediaItem?>(null) } var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
var menuReady by remember { mutableStateOf(false) } var menuReady by remember { mutableStateOf(false) }
var didInitialHomeFocus by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val menuFocus = remember { FocusRequester() } val menuFocus = remember { FocusRequester() }
val context = LocalContext.current 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) { LaunchedEffect(home) {
if (home == null) return@LaunchedEffect if (home == null || didInitialHomeFocus) return@LaunchedEffect
delay(120) delay(120)
runCatching { contentFocusRequester.requestFocus() } runCatching { contentFocusRequester.requestFocus() }
onContentFocused() onContentFocused()
didInitialHomeFocus = true
} }
val spotlight = home?.spotlight.orEmpty().ifEmpty { val spotlight = home?.spotlight.orEmpty().ifEmpty {
@ -958,8 +1046,13 @@ private fun HomeScreen(
shelvesFocusRequester = firstShelfFocus, shelvesFocusRequester = firstShelfFocus,
onContentFocused = { onContentFocused = {
onContentFocused() onContentFocused()
// Rest-offset na terugscrollen: hero weer volledig onder menu // 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) } scope.launch { listState.scrollToItem(0) }
}
}, },
onPlay = { item -> onPlay = { item ->
onContentFocused() onContentFocused()
@ -1281,6 +1374,7 @@ private fun SpotlightHero(
} }
} }
} }
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
private fun ShelfBlock( private fun ShelfBlock(
title: String, title: String,
@ -1341,6 +1435,7 @@ private fun ShelfBlock(
} }
} }
LazyRow( LazyRow(
modifier = Modifier.focusRestorer(),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
// Extra ruimte zodat focusedScale (1.10) posters niet afknipt // Extra ruimte zodat focusedScale (1.10) posters niet afknipt
contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 28.dp, bottom = 28.dp), contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 28.dp, bottom = 28.dp),

View file

@ -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<String?>(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)
}
}
}
}

View file

@ -20,6 +20,7 @@
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; }; D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; };
1EF110B9F82E440F999ECE37 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B20E94E6B4034B1EF282B /* Components.swift */; }; 1EF110B9F82E440F999ECE37 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B20E94E6B4034B1EF282B /* Components.swift */; };
E3C7ACAC5AC74107B90C6892 /* LinkScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19304FA5C7624099B8E97E1F /* LinkScreen.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 */; }; EEB314675D0049E6B289021D /* ProfilesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */; };
AE1CD4524BFF41A0ACB1FDDA /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5561954288524F7C9E41CA40 /* HomeScreen.swift */; }; AE1CD4524BFF41A0ACB1FDDA /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5561954288524F7C9E41CA40 /* HomeScreen.swift */; };
E2F8D0CF0F39406FB37884A5 /* BrowseScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E44909AE0F474047ADB06E27 /* BrowseScreen.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 = "<group>"; }; BF5E450343884C11B0920888 /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = "<group>"; };
314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; }; 314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
19304FA5C7624099B8E97E1F /* LinkScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkScreen.swift; sourceTree = "<group>"; }; 19304FA5C7624099B8E97E1F /* LinkScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkScreen.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5C /* ServerAuthScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAuthScreens.swift; sourceTree = "<group>"; };
EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesScreen.swift; sourceTree = "<group>"; }; EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesScreen.swift; sourceTree = "<group>"; };
5561954288524F7C9E41CA40 /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; }; 5561954288524F7C9E41CA40 /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; };
E44909AE0F474047ADB06E27 /* BrowseScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseScreen.swift; sourceTree = "<group>"; }; E44909AE0F474047ADB06E27 /* BrowseScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseScreen.swift; sourceTree = "<group>"; };
@ -156,6 +158,7 @@
BF5E450343884C11B0920888 /* MainShell.swift */, BF5E450343884C11B0920888 /* MainShell.swift */,
314B20E94E6B4034B1EF282B /* Components.swift */, 314B20E94E6B4034B1EF282B /* Components.swift */,
19304FA5C7624099B8E97E1F /* LinkScreen.swift */, 19304FA5C7624099B8E97E1F /* LinkScreen.swift */,
A1B2C3D4E5F60718293A4B5C /* ServerAuthScreens.swift */,
EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */, EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */,
5561954288524F7C9E41CA40 /* HomeScreen.swift */, 5561954288524F7C9E41CA40 /* HomeScreen.swift */,
E44909AE0F474047ADB06E27 /* BrowseScreen.swift */, E44909AE0F474047ADB06E27 /* BrowseScreen.swift */,
@ -253,6 +256,7 @@
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */, D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */,
1EF110B9F82E440F999ECE37 /* Components.swift in Sources */, 1EF110B9F82E440F999ECE37 /* Components.swift in Sources */,
E3C7ACAC5AC74107B90C6892 /* LinkScreen.swift in Sources */, E3C7ACAC5AC74107B90C6892 /* LinkScreen.swift in Sources */,
C9A1B2D3E4F5061728394A5B /* ServerAuthScreens.swift in Sources */,
EEB314675D0049E6B289021D /* ProfilesScreen.swift in Sources */, EEB314675D0049E6B289021D /* ProfilesScreen.swift in Sources */,
AE1CD4524BFF41A0ACB1FDDA /* HomeScreen.swift in Sources */, AE1CD4524BFF41A0ACB1FDDA /* HomeScreen.swift in Sources */,
E2F8D0CF0F39406FB37884A5 /* BrowseScreen.swift in Sources */, E2F8D0CF0F39406FB37884A5 /* BrowseScreen.swift in Sources */,

View file

@ -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 home() async throws -> HomePayload { try await get("/api/v1/client/home") }
func search(query: String) async throws -> [MediaItem] { func search(query: String) async throws -> [MediaItem] {

View file

@ -6,6 +6,9 @@ import Combine
final class AppModel: ObservableObject { final class AppModel: ObservableObject {
enum Phase: Equatable { enum Phase: Equatable {
case boot case boot
case server
case auth
case password
case link case link
case profiles case profiles
case main case main
@ -70,7 +73,7 @@ final class AppModel: ObservableObject {
bootError = nil bootError = nil
await api.configure(baseUrl: store.apiBase, token: store.refreshToken) await api.configure(baseUrl: store.apiBase, token: store.refreshToken)
guard store.isAuthed else { guard store.isAuthed else {
phase = .link phase = .server
return return
} }
do { do {
@ -97,7 +100,7 @@ final class AppModel: ObservableObject {
store.setToken(nil) store.setToken(nil)
favorites.bind(profileId: nil) favorites.bind(profileId: nil)
await api.configure(baseUrl: store.apiBase, token: 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) await api.configure(baseUrl: store.apiBase, token: nil)
favorites.bind(profileId: nil) favorites.bind(profileId: nil)
iptvEnabled = false iptvEnabled = false
phase = .link phase = .server
} }
static var deviceName: String { static var deviceName: String {

View file

@ -70,6 +70,11 @@ struct LinkScreen: View {
Button("Opnieuw") { Task { await startLink() } } Button("Opnieuw") { Task { await startLink() } }
.buttonStyle(McPrimaryButtonStyle()) .buttonStyle(McPrimaryButtonStyle())
} }
Button("Change server") { app.phase = .server }
.buttonStyle(McSecondaryButtonStyle())
Button("Back") { app.phase = .auth }
.buttonStyle(McSecondaryButtonStyle())
} }
.padding() .padding()
.frame(maxWidth: 420) .frame(maxWidth: 420)

View file

@ -9,6 +9,12 @@ struct RootView: View {
switch app.phase { switch app.phase {
case .boot: case .boot:
SplashView() SplashView()
case .server:
ServerChoiceScreen()
case .auth:
AuthChoiceScreen()
case .password:
PasswordLoginScreen()
case .link: case .link:
LinkScreen() LinkScreen()
case .profiles: case .profiles:

View file

@ -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
}
}

View file

@ -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 { Section {
Button(role: .destructive) { Button(role: .destructive) {
Task { await app.logout() } Task { await app.logout() }

View file

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

View file

@ -101,6 +101,16 @@ model ViewerUser {
@@map("viewer_users") @@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). */ /** Netflix-stijl kijkprofiel onder één ViewerUser (voortgang/prefs per profiel). */
model ViewerProfile { model ViewerProfile {
id String @id @default(uuid()) id String @id @default(uuid())

View file

@ -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 ——— // ——— Authenticated client ———
app.get("/api/v1/client/me", async (request) => { app.get("/api/v1/client/me", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization); const auth = await viewers.authFromBearer(request.headers.authorization);

View file

@ -28,6 +28,10 @@ const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const POLL_INTERVAL_SEC = 2; const POLL_INTERVAL_SEC = 2;
/** Max titels in Recent-/4K-catalogi (niet de hele bibliotheek). */ /** Max titels in Recent-/4K-catalogi (niet de hele bibliotheek). */
const RECENT_CATALOG_CAP = 120; 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 = { export type AuthedViewer = {
viewerId: string; 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, id: v.id,
email: v.email, email: v.email,
name: v.name, name: v.name,
@ -187,7 +202,11 @@ export class ViewerService {
createdAt: v.createdAt, createdAt: v.createdAt,
deviceCount: v._count.devices, deviceCount: v._count.devices,
devices: v.devices, devices: v.devices,
})); loginFailedCount: lock?.failedCount ?? 0,
loginLocked,
loginLockedUntil: loginLocked ? lockedUntil : null,
};
});
} }
async deleteViewer(id: string) { async deleteViewer(id: string) {
@ -332,9 +351,21 @@ export class ViewerService {
code: string; code: string;
}) { }) {
const email = opts.email.trim().toLowerCase(); const email = opts.email.trim().toLowerCase();
await this.assertLoginAllowed(email);
const viewer = await prisma.viewerUser.findUnique({ where: { email } }); const viewer = await prisma.viewerUser.findUnique({ where: { email } });
if (!viewer || !(await verifyPassword(viewer.passwordHash, opts.password))) { const passwordOk =
throw new AppError("INVALID_CREDENTIALS", "Ongeldige login", 401); !!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) { if (!viewer.enabled || !viewer.appAccess) {
throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403); throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403);
@ -358,6 +389,7 @@ export class ViewerService {
claimTokenHash: hashToken(claimToken), claimTokenHash: hashToken(claimToken),
}, },
}); });
await this.clearLoginLock(email);
return { return {
ok: true, 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<AuthedViewer> { async authFromBearer(authHeader?: string): Promise<AuthedViewer> {
if (!authHeader?.startsWith("Bearer ")) { if (!authHeader?.startsWith("Bearer ")) {
throw new AppError("UNAUTHORIZED", "Bearer token verplicht", 401); throw new AppError("UNAUTHORIZED", "Bearer token verplicht", 401);