diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 9530b87..714f4b7 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 36 - versionCode = 105 - versionName = "0.14.42" + versionCode = 106 + versionName = "0.14.43" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt index 43d2696..b0bb0c5 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt @@ -8,13 +8,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -54,15 +52,16 @@ import androidx.compose.ui.layout.ContentScale /** * Berekende hoogte van de Home overlay-topnav (geen subtabs). * - * TopNavBar overlay-row (compact, zonder underline): - * - vertical padding: 10.dp + 10.dp - * - tab-tekst ~21.dp → veilig 56.dp + * TopNavBar overlay-row: + * - vertical padding: 14.dp + 14.dp + * - tab-inhoud: 8.dp + ~21.dp tekst + 4.dp + 3.dp underline + 8.dp ≈ 44.dp + * - totaal ≈ 72.dp; TV Surface min. interactive ≈ 48.dp → veilig 76.dp */ object HomeChrome { - val TopNavHeight = 56.dp + val TopNavHeight = 76.dp /** Lucht tussen onderkant menu en badge. */ val BadgeGap = 12.dp - val BadgeTop = TopNavHeight + BadgeGap + val BadgeTop = TopNavHeight + BadgeGap // 88.dp /** Horizontale schermmarge (links = rechts). */ val EdgeInset = 22.dp } @@ -119,7 +118,7 @@ fun TopNavBar( compact -> 0.42f else -> 1f } - val vPad = if (compact && !overlay) 6.dp else 10.dp + val vPad = if (compact && !overlay) 8.dp else 14.dp val downLink = Modifier.then( if (contentDownFocus != null) { Modifier.focusProperties { down = contentDownFocus } @@ -178,16 +177,13 @@ fun TopNavBar( color = McColors.Brand, fontSize = if (compact && !overlay) 22.sp else 28.sp, fontWeight = FontWeight.Black, - modifier = Modifier - .height(if (compact && !overlay) 32.dp else 36.dp) - .padding(end = 12.dp) - .wrapContentHeight(Alignment.CenterVertically), + modifier = Modifier.padding(end = 16.dp), ) textTabs.forEach { tab -> val active = selected == tab val color by animateColorAsState( when { - active -> McColors.Brand + active -> Color.White overlay -> Color(0xFFE8E8E8) else -> Color(0xFF808080) }, @@ -196,24 +192,21 @@ fun TopNavBar( val fr = tabFocus[tab] Surface( onClick = { onSelect(tab) }, - modifier = downLink - .height(if (compact && !overlay) 32.dp else 36.dp) - .then(if (fr != null) Modifier.focusRequester(fr) else Modifier), + modifier = downLink.then( + if (fr != null) Modifier.focusRequester(fr) else Modifier, + ), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), colors = ClickableSurfaceDefaults.colors( - containerColor = Color.Transparent, - focusedContainerColor = Color(0x33FFFFFF), + containerColor = if (active) Color(0x33FFFFFF) else Color.Transparent, + focusedContainerColor = Color(0x44FFFFFF), contentColor = color, - // Actieve tab blijft rood, ook met focus - focusedContentColor = if (active) McColors.Brand else Color.White, + focusedContentColor = Color.White, ), ) { - Box( - Modifier - .fillMaxHeight() - .padding(horizontal = 8.dp), - contentAlignment = Alignment.Center, + Column( + Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( tab.label, @@ -221,6 +214,14 @@ fun TopNavBar( fontSize = 16.sp, fontWeight = if (active) FontWeight.Bold else FontWeight.Medium, ) + Spacer(Modifier.height(4.dp)) + Box( + Modifier + .width(if (active) 28.dp else 0.dp) + .height(3.dp) + .clip(RoundedCornerShape(2.dp)) + .background(if (active) McColors.Brand else Color.Transparent), + ) } } } @@ -228,29 +229,33 @@ fun TopNavBar( val searchActive = selected == MainTab.Search Surface( onClick = onSearchClick, - modifier = downLink - .height(if (compact && !overlay) 32.dp else 36.dp) - .focusRequester(searchFocus), + modifier = downLink.focusRequester(searchFocus), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), colors = ClickableSurfaceDefaults.colors( - containerColor = Color.Transparent, - focusedContainerColor = Color(0x33FFFFFF), - contentColor = if (searchActive) McColors.Brand else Color.White, - focusedContentColor = if (searchActive) McColors.Brand else Color.White, + containerColor = if (searchActive) Color(0x33FFFFFF) else Color.Transparent, + focusedContainerColor = Color(0x44FFFFFF), + contentColor = Color.White, + focusedContentColor = Color.White, ), ) { - Box( - Modifier - .fillMaxHeight() - .padding(horizontal = 8.dp), - contentAlignment = Alignment.Center, + Column( + Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( imageVector = Icons.Filled.Search, contentDescription = "Zoeken", modifier = Modifier.size(16.dp), - tint = Color.Unspecified, + tint = Color.White, + ) + Spacer(Modifier.height(4.dp)) + Box( + Modifier + .width(if (searchActive) 18.dp else 0.dp) + .height(3.dp) + .clip(RoundedCornerShape(2.dp)) + .background(if (searchActive) McColors.Brand else Color.Transparent), ) } } @@ -259,29 +264,33 @@ fun TopNavBar( val settingsActive = selected == MainTab.Settings Surface( onClick = onSettingsClick, - modifier = downLink - .height(if (compact && !overlay) 32.dp else 36.dp) - .focusRequester(settingsFocus), + modifier = downLink.focusRequester(settingsFocus), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), colors = ClickableSurfaceDefaults.colors( - containerColor = Color.Transparent, - focusedContainerColor = Color(0x33FFFFFF), - contentColor = if (settingsActive) McColors.Brand else Color.White, - focusedContentColor = if (settingsActive) McColors.Brand else Color.White, + containerColor = if (settingsActive) Color(0x33FFFFFF) else Color.Transparent, + focusedContainerColor = Color(0x44FFFFFF), + contentColor = Color.White, + focusedContentColor = Color.White, ), ) { - Box( - Modifier - .fillMaxHeight() - .padding(horizontal = 8.dp), - contentAlignment = Alignment.Center, + Column( + Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( imageVector = Icons.Filled.Settings, contentDescription = "Instellingen", modifier = Modifier.size(16.dp), - tint = Color.Unspecified, + tint = Color.White, + ) + Spacer(Modifier.height(4.dp)) + Box( + Modifier + .width(if (settingsActive) 18.dp else 0.dp) + .height(3.dp) + .clip(RoundedCornerShape(2.dp)) + .background(if (settingsActive) McColors.Brand else Color.Transparent), ) } } diff --git a/apps/master-api/src/viewer/profiles.ts b/apps/master-api/src/viewer/profiles.ts index bfc2e71..cc84e44 100644 --- a/apps/master-api/src/viewer/profiles.ts +++ b/apps/master-api/src/viewer/profiles.ts @@ -388,14 +388,21 @@ export class ViewerProfileService { let bucket = pinAttempts.get(key); if (!bucket || bucket.resetAt < now) { bucket = { count: 0, resetAt: now + PIN_ATTEMPT_WINDOW_MS }; + pinAttempts.set(key, bucket); } if (bucket.count >= PIN_MAX_ATTEMPTS) { - throw new AppError("RATE_LIMIT", "Te veel PIN-pogingen, probeer later", 429); + throw new AppError( + "RATE_LIMITED", + "Te veel PIN-pogingen. Probeer later opnieuw.", + 429 + ); } - const ok = pin ? await verifyPassword(String(pin), profile.pinHash) : false; + + const ok = pin + ? await verifyPassword(profile.pinHash, String(pin).replace(/\D/g, "")) + : false; if (!ok) { bucket.count += 1; - pinAttempts.set(key, bucket); throw new AppError("FORBIDDEN", "Onjuiste PIN", 403); } pinAttempts.delete(key); @@ -403,7 +410,7 @@ export class ViewerProfileService { await prisma.viewerDevice.update({ where: { id: deviceId }, - data: { activeProfileId: profileId, lastSeenAt: new Date() }, + data: { activeProfileId: profile.id, lastSeenAt: new Date() }, }); return { profile: publicProfile(profile) }; }