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:
parent
00fa9cd9c7
commit
303383ea4e
21 changed files with 1614 additions and 72 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 */,
|
||||||
|
|
|
||||||
|
|
@ -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] {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
224
apps/ios/VMC/UI/ServerAuthScreens.swift
Normal file
224
apps/ios/VMC/UI/ServerAuthScreens.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
);
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue