Add in-app device approve/QR pairing and polish iOS browse.

Owners can list/revoke devices and approve TV codes in the iOS app; TV shows a vmc:// QR, and Films/Series get visible chips, titles, and a non-overlapping A–Z index.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-10 03:18:38 +02:00
parent 0b1a2daa20
commit 6ffeb50733
21 changed files with 1147 additions and 323 deletions

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster"
minSdk = 24
targetSdk = 36
versionCode = 12
versionName = "1.0.11"
versionCode = 13
versionName = "1.0.12"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -65,9 +65,10 @@ import androidx.media3.ui.PlayerView
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SubtitleHit
import nl.vonas.mediacluster.tv.playback.PlayerTrack
@ -269,46 +270,52 @@ fun PlayerScreen(
}
player.addListener(listener)
onDispose {
try {
runBlocking {
val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
val dur = runCatching { player.duration.coerceAtLeast(0) }.getOrDefault(0L)
runCatching { player.pause() }
player.removeListener(listener)
runCatching { player.release() }
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
runCatching {
api.postProgress(
mediaType = mediaType,
movieId = movieId,
episodeId = episodeId,
seriesId = seriesId,
positionMs = player.currentPosition.coerceAtLeast(0),
durationMs = player.duration.coerceAtLeast(0),
positionMs = pos,
durationMs = dur,
)
}
} catch (_: Exception) {
}
player.removeListener(listener)
player.release()
}
}
LaunchedEffect(player) {
while (true) {
while (isActive) {
delay(400)
runCatching {
positionMs = player.currentPosition.coerceAtLeast(0)
durationMs = player.duration.coerceAtLeast(0)
}
}
}
LaunchedEffect(player) {
while (true) {
while (isActive) {
delay(12_000)
if (player.isPlaying) {
try {
val playing = runCatching { player.isPlaying }.getOrDefault(false)
if (playing) {
val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
val dur = runCatching { player.duration.coerceAtLeast(0) }.getOrDefault(0L)
runCatching {
api.postProgress(
mediaType = mediaType,
movieId = movieId,
episodeId = episodeId,
seriesId = seriesId,
positionMs = player.currentPosition.coerceAtLeast(0),
durationMs = player.duration.coerceAtLeast(0),
positionMs = pos,
durationMs = dur,
)
} catch (_: Exception) {
}
}
}
@ -393,6 +400,7 @@ fun PlayerScreen(
}
},
update = { it.player = player },
onRelease = { it.player = null },
modifier = Modifier.fillMaxSize(),
)

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
targetSdk = 36
versionCode = 76
versionName = "0.14.13"
versionCode = 82
versionName = "0.14.19"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
@ -90,6 +90,7 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("com.google.zxing:core:3.5.3")
debugImplementation("androidx.compose.ui:ui-tooling")
}

View file

@ -15,9 +15,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -30,12 +30,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@ -58,7 +56,7 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
private val AzLetters = listOf("#") + ('A'..'Z').map { it.toString() }
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun BrowseScreen(
api: ApiClient,
@ -91,18 +89,16 @@ fun BrowseScreen(
var loadingMore by remember { mutableStateOf(false) }
var sort by rememberSaveable(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) }
var activeLetter by rememberSaveable(catalogId) { mutableStateOf<String?>(null) }
var loadedKey by rememberSaveable(catalogId) { mutableStateOf<String?>(null) }
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
var menuReady by remember { mutableStateOf(false) }
val menuFocus = remember { FocusRequester() }
val gridState = rememberSaveable(catalogId, sort, saver = LazyGridState.Saver) { LazyGridState() }
val gridState = rememberLazyGridState()
val scope = rememberCoroutineScope()
val showBrowseTools = !isContinue
suspend fun load(reset: Boolean, jumpLetter: String? = null) {
if (reset) {
// Bestaande items houden het grid in compositie → focus blijft bij terugkeer
if (items.isEmpty()) loading = true
loading = true
error = null
} else {
loadingMore = true
@ -137,11 +133,8 @@ fun BrowseScreen(
}
LaunchedEffect(catalogId, sort) {
val key = "$catalogId|$sort"
if (loadedKey != key || items.isEmpty()) {
load(reset = true, jumpLetter = null)
loadedKey = key
}
gridState.scrollToItem(0)
}
LaunchedEffect(gridState, items.size, total, listOffset) {
@ -185,35 +178,43 @@ fun BrowseScreen(
}
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
val scrubberPad = if (showBrowseTools && sort == "title") 40.dp else 28.dp
Column(
Modifier
.fillMaxSize()
.padding(start = 36.dp, end = if (showBrowseTools && sort == "title") 52.dp else 36.dp, top = 20.dp, bottom = 20.dp),
.padding(start = 28.dp, end = scrubberPad, top = 10.dp, bottom = 12.dp),
) {
// Eén balk: titel + telling links, sort rechts — leesbaar van de bank, weinig hoogte
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Row(
Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
title,
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
Text(
when {
total > 0 -> "$total titels"
loading -> "Laden…"
else -> "Geen titels"
total > 0 -> "$total"
loading -> "…"
else -> "0"
},
color = McColors.TextDim,
fontSize = 14.sp,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
if (onBack != null) {
McButton("Terug", onClick = onBack)
}
}
if (showBrowseTools) {
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SortChip(
label = "A–Z",
@ -231,11 +232,15 @@ fun BrowseScreen(
},
)
}
} else if (onBack != null) {
McButton("Terug", onClick = onBack)
}
}
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(10.dp))
if (error != null) {
Text(error!!, color = McColors.Danger)
Text(error!!, color = McColors.Danger, fontSize = 16.sp)
Spacer(Modifier.height(8.dp))
}
when {
loading && items.isEmpty() -> {
@ -251,21 +256,20 @@ fun BrowseScreen(
}
else -> {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 140.dp),
// ~124dp: dichter dan 140 (meer rijen), nog herkenbaar van de bank
columns = GridCells.Adaptive(minSize = 124.dp),
state = gridState,
contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxSize()
.focusRestorer(),
contentPadding = PaddingValues(start = 4.dp, top = 4.dp, end = 4.dp, bottom = 28.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxSize(),
) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard(
item = item,
fillWidth = true,
showCaption = isContinue,
modifier = Modifier.padding(6.dp),
modifier = Modifier.padding(3.dp),
onLongClick = if (isContinue) {
{ continueMenu = item }
} else {
@ -305,11 +309,10 @@ fun BrowseScreen(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.padding(vertical = 16.dp, horizontal = 4.dp),
.padding(vertical = 8.dp, horizontal = 2.dp),
onLetter = { letter ->
scope.launch {
load(reset = true, jumpLetter = letter)
loadedKey = "$catalogId|$sort"
gridState.scrollToItem(0)
}
},
@ -379,7 +382,7 @@ private fun SortChip(
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) Color.White else Color(0x33FFFFFF),
focusedContainerColor = Color.White,
@ -387,9 +390,9 @@ private fun SortChip(
) {
Text(
label,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
color = if (selected) Color.Black else Color.White,
fontSize = 14.sp,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
}
@ -403,7 +406,7 @@ private fun AzScrubber(
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.width(30.dp),
modifier = modifier.width(34.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AzLetters.forEach { letter ->
@ -414,7 +417,7 @@ private fun AzScrubber(
.weight(1f)
.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(3.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.35f),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.4f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = Color(0x55FFFFFF),
@ -426,10 +429,10 @@ private fun AzScrubber(
) {
Text(
letter,
color = if (isSelected) McColors.Brand else Color(0xCCFFFFFF),
fontSize = 10.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
lineHeight = 10.sp,
color = if (isSelected) McColors.Brand else Color(0xE6FFFFFF),
fontSize = 13.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
lineHeight = 13.sp,
)
}
}

View file

@ -9,6 +9,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -27,7 +28,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@ -45,13 +45,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Brush
@ -69,10 +67,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.foundation.Image
import androidx.compose.ui.graphics.FilterQuality
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.dialog
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.tv.material3.ClickableSurfaceDefaults
@ -326,55 +325,28 @@ fun McNav() {
},
)
}
// dialog i.p.v. composable: Home/Browse blijven gecomposeerd → scroll+focus behouden
dialog(
route = "movie/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
dialogProperties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
),
) { entry ->
val id = entry.arguments?.getString("id") ?: return@dialog
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
val id = entry.arguments?.getString("id") ?: return@composable
MovieDetailScreen(
api = api,
movieId = id,
onBack = { nav.popBackStack() },
onPlay = { mediaFileId, _, startMs ->
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs") {
popUpTo("movie/$id") { inclusive = true }
}
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs")
},
)
}
}
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)) {
composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
val id = entry.arguments?.getString("id") ?: return@composable
SeriesDetailScreen(
api = api,
seriesId = id,
onBack = { nav.popBackStack() },
onPlay = { mediaFileId, episodeId, _ ->
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") {
popUpTo("series/$id") { inclusive = true }
}
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0")
},
)
}
}
composable(
"player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}",
arguments = listOf(
@ -450,7 +422,7 @@ private fun MainHub(
val homeContentFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() }
var exitReady by remember { mutableStateOf(false) }
val homeListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() }
val homeListState = rememberLazyListState()
var prevTabName by rememberSaveable { mutableStateOf<String?>(null) }
val hubScope = rememberCoroutineScope()
val activity = LocalContext.current as? Activity
@ -818,10 +790,11 @@ private fun LinkScreen(
onLinked: () -> Unit,
) {
var code by remember { mutableStateOf<String?>(null) }
var linkUrl by remember { mutableStateOf("") }
var qrPayload by remember { mutableStateOf("") }
var status by remember { mutableStateOf("Verbinden…") }
var error by remember { mutableStateOf<String?>(null) }
var serverLabel by remember { mutableStateOf("") }
val qrImage = rememberQrImage(qrPayload)
LaunchedEffect(Unit) {
val base = store.apiBase.first()
@ -831,8 +804,10 @@ private fun LinkScreen(
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" }
val created = api.createDeviceCode(deviceName)
code = created.code
linkUrl = created.linkUrl
status = "Wachten tot je de code bevestigt…"
qrPayload = created.qrPayload.ifBlank {
"vmc://link?code=${created.code}"
}
status = "Wachten op goedkeuring in de VMC-app…"
while (true) {
delay(created.pollIntervalSeconds.coerceAtLeast(2) * 1000L)
when (val poll = api.pollDeviceCode(created.code)) {
@ -866,23 +841,60 @@ private fun LinkScreen(
.background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
Row(
horizontalArrangement = Arrangement.spacedBy(36.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.width(560.dp)
.width(820.dp)
.clip(RoundedCornerShape(24.dp))
.background(McColors.BgElevated.copy(alpha = 0.92f))
.padding(40.dp),
.padding(36.dp),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.width(280.dp),
) {
if (qrImage != null) {
Image(
bitmap = qrImage,
contentDescription = "QR-code om te scannen met de VMC-app",
modifier = Modifier
.size(240.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White)
.padding(10.dp),
filterQuality = FilterQuality.None,
)
} else {
Box(
Modifier
.size(240.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Text("QR…", color = McColors.TextDim, fontSize = 16.sp)
}
}
Text("Scan met de VMC-app", color = McColors.TextDim, fontSize = 14.sp, textAlign = TextAlign.Center)
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f),
) {
Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp)
Text("Koppel dit apparaat", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text("Open de link op je telefoon, log in en voer de code in.", color = McColors.TextDim, fontSize = 15.sp)
Text("Koppel dit apparaat", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
Text(
"Open de VMC-app op je telefoon, scan de QR of voer de code in onder Account → Apparaten.",
color = McColors.TextDim,
fontSize = 15.sp,
)
if (serverLabel.isNotBlank()) {
Text(serverLabel, color = Color(0xFF9AB0D0), fontSize = 13.sp)
}
Text(code ?: "····-····", color = McColors.Accent, fontSize = 52.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
if (linkUrl.isNotBlank()) Text(linkUrl, color = Color.White, fontSize = 18.sp)
Text(code ?: "····-····", color = McColors.Accent, fontSize = 44.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
Text(status, color = McColors.TextDim, fontSize = 14.sp)
if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp)
Spacer(Modifier.height(4.dp))
@ -890,6 +902,7 @@ private fun LinkScreen(
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
@ -910,12 +923,14 @@ private fun HomeScreen(
var loading by remember { mutableStateOf(true) }
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
var menuReady by remember { mutableStateOf(false) }
var didInitialHomeFocus by rememberSaveable { mutableStateOf(false) }
var shelfFocusReady by remember { mutableStateOf(false) }
var playFocusReady by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val menuFocus = remember { FocusRequester() }
val context = LocalContext.current
val store = remember { SessionStore(context) }
val density = LocalDensity.current
val firstShelfFocus = remember { FocusRequester() }
suspend fun reloadHome() {
val fresh = api.home()
@ -952,13 +967,15 @@ private fun HomeScreen(
}
}
// Alleen eerste keer focus op Afspelen — niet bij terugkeer uit film/serie
LaunchedEffect(home) {
if (home == null || didInitialHomeFocus) return@LaunchedEffect
delay(120)
runCatching { contentFocusRequester.requestFocus() }
// Bij elke verschijning van Home (ook na serie/film/player): focus op content, niet op top-nav.
LaunchedEffect(Unit) {
onContentFocused()
didInitialHomeFocus = true
val deadline = System.currentTimeMillis() + 900
while (!playFocusReady && System.currentTimeMillis() < deadline) {
delay(40)
}
delay(40)
runCatching { contentFocusRequester.requestFocus() }
}
val spotlight = home?.spotlight.orEmpty().ifEmpty {
@ -980,7 +997,14 @@ private fun HomeScreen(
shelfStartPx
}
// Als scroll stilvalt dicht bij de top met rest-offset: snap naar 0
fun expandHeroFully() {
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
scope.launch { listState.animateScrollToItem(0) }
}
}
// Als scroll stilvalt dicht bij de top met rest-offset: snap hero open
// (niet agressief over hele spacer — dat vecht met omlaag naar de eerste plank)
LaunchedEffect(listState) {
snapshotFlow {
Triple(
@ -989,8 +1013,8 @@ private fun HomeScreen(
listState.isScrollInProgress,
)
}.collect { (index, offset, scrolling) ->
if (!scrolling && index == 0 && offset in 1..160) {
listState.scrollToItem(0)
if (!scrolling && index == 0 && offset in 1..240) {
listState.animateScrollToItem(0)
}
}
}
@ -1021,9 +1045,18 @@ private fun HomeScreen(
}
}
val firstShelfFocus = remember { FocusRequester() }
Box(Modifier.fillMaxSize()) {
// Altijd een geldig focus-doel voor TopNav "down", ook vóór Afspelen gecomposeerd is.
if (!playFocusReady) {
Box(
Modifier
.size(1.dp)
.align(Alignment.TopStart)
.focusRequester(contentFocusRequester)
.focusable()
.onFocusChanged { if (it.isFocused) onContentFocused() },
)
}
if (loading && home == null) {
HomeSkeleton()
} else {
@ -1043,16 +1076,15 @@ private fun HomeScreen(
index = spotlightIndex,
playFocusRequester = contentFocusRequester,
navFocusRequester = navFocusRequester,
shelvesFocusRequester = firstShelfFocus,
// Alleen linken als de eerste plank écht in de tree hangt — anders crasht D-pad
// met "FocusRequester is not initialized" (Shield logcat 01:36 / 01:52).
shelvesFocusRequester = firstShelfFocus.takeIf { shelfFocusReady },
onPlayFocusReady = { playFocusReady = it },
onContentFocused = {
onContentFocused()
// 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) }
}
// Focus op hero/Afspelen → LazyColumn altijd volledig naar top
// (anders blijft hero deels onder het menu hangen tot Back).
expandHeroFully()
},
onPlay = { item ->
onContentFocused()
@ -1097,8 +1129,12 @@ private fun HomeScreen(
items = preview,
showCaptions = true,
firstItemFocusRequester = firstShelfFocus,
focusUpTo = contentFocusRequester,
onRowFocused = onContentFocused,
focusUpTo = contentFocusRequester.takeIf { playFocusReady },
onFirstFocusTargetReady = { shelfFocusReady = it },
onRowFocused = {
onContentFocused()
expandHeroFully()
},
onSeeAll = if (continueItems.size > 5) {
{ onSeeAll("continue-watching", "Verder kijken") }
} else {
@ -1139,8 +1175,16 @@ private fun HomeScreen(
title = shelf.title,
items = shelf.items,
firstItemFocusRequester = if (isFirstShelf) firstShelfFocus else null,
focusUpTo = if (isFirstShelf) contentFocusRequester else null,
onRowFocused = onContentFocused,
focusUpTo = if (isFirstShelf) contentFocusRequester.takeIf { playFocusReady } else null,
onFirstFocusTargetReady = if (isFirstShelf) {
{ shelfFocusReady = it }
} else {
null
},
onRowFocused = {
onContentFocused()
if (isFirstShelf) expandHeroFully()
},
onSeeAll = { onSeeAll(catalogId, shelf.browseTitle) },
onClick = { item ->
onContentFocused()
@ -1243,6 +1287,7 @@ private fun SpotlightHero(
playFocusRequester: FocusRequester? = null,
navFocusRequester: FocusRequester? = null,
shelvesFocusRequester: FocusRequester? = null,
onPlayFocusReady: (Boolean) -> Unit = {},
onContentFocused: () -> Unit = {},
) {
val safeItems = items.ifEmpty { listOf(MediaItem(type = "movie", id = "", title = "Welkom bij VMC")) }
@ -1339,6 +1384,10 @@ private fun SpotlightHero(
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (current.id.isNotBlank()) {
DisposableEffect(playFocusRequester) {
onPlayFocusReady(playFocusRequester != null)
onDispose { onPlayFocusReady(false) }
}
McButton(
"Afspelen",
primary = true,
@ -1374,7 +1423,6 @@ private fun SpotlightHero(
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun ShelfBlock(
title: String,
@ -1386,6 +1434,7 @@ private fun ShelfBlock(
onRowFocused: () -> Unit = {},
firstItemFocusRequester: FocusRequester? = null,
focusUpTo: FocusRequester? = null,
onFirstFocusTargetReady: ((Boolean) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
var rowFocused by remember { mutableStateOf(false) }
@ -1435,12 +1484,17 @@ private fun ShelfBlock(
}
}
LazyRow(
modifier = Modifier.focusRestorer(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
// Extra ruimte zodat focusedScale (1.10) posters niet afknipt
contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 28.dp, bottom = 28.dp),
) {
itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item ->
if (index == 0 && firstItemFocusRequester != null) {
DisposableEffect(firstItemFocusRequester) {
onFirstFocusTargetReady?.invoke(true)
onDispose { onFirstFocusTargetReady?.invoke(false) }
}
}
PosterCard(
item = item,
onClick = { onClick(item) },

View file

@ -60,9 +60,10 @@ import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SubtitleHit
import nl.vonas.mediacluster.tv.playback.PlayerTrack
@ -266,46 +267,53 @@ fun PlayerScreen(
}
player.addListener(listener)
onDispose {
try {
runBlocking {
val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
val dur = runCatching { player.duration.coerceAtLeast(0) }.getOrDefault(0L)
runCatching { player.pause() }
player.removeListener(listener)
runCatching { player.release() }
// Progress buiten de UI-thread / composition-scope (die hier gecanceld wordt)
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
runCatching {
api.postProgress(
mediaType = mediaType,
movieId = movieId,
episodeId = episodeId,
seriesId = seriesId,
positionMs = player.currentPosition.coerceAtLeast(0),
durationMs = player.duration.coerceAtLeast(0),
positionMs = pos,
durationMs = dur,
)
}
} catch (_: Exception) {
}
player.removeListener(listener)
player.release()
}
}
LaunchedEffect(player) {
while (true) {
while (isActive) {
delay(400)
runCatching {
positionMs = player.currentPosition.coerceAtLeast(0)
durationMs = player.duration.coerceAtLeast(0)
}
}
}
LaunchedEffect(player) {
while (true) {
while (isActive) {
delay(12_000)
if (player.isPlaying) {
try {
val playing = runCatching { player.isPlaying }.getOrDefault(false)
if (playing) {
val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
val dur = runCatching { player.duration.coerceAtLeast(0) }.getOrDefault(0L)
runCatching {
api.postProgress(
mediaType = mediaType,
movieId = movieId,
episodeId = episodeId,
seriesId = seriesId,
positionMs = player.currentPosition.coerceAtLeast(0),
durationMs = player.duration.coerceAtLeast(0),
positionMs = pos,
durationMs = dur,
)
} catch (_: Exception) {
}
}
}
@ -420,6 +428,7 @@ fun PlayerScreen(
}
},
update = { it.player = player },
onRelease = { it.player = null },
modifier = Modifier.fillMaxSize(),
)

View file

@ -0,0 +1,33 @@
package nl.vonas.mediacluster.tv.ui
import android.graphics.Bitmap
import android.graphics.Color as AndroidColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
fun encodeQrBitmap(content: String, sizePx: Int = 512): ImageBitmap? {
if (content.isBlank()) return null
return try {
val hints = mapOf(EncodeHintType.MARGIN to 1)
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, sizePx, sizePx, hints)
val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
for (x in 0 until sizePx) {
for (y in 0 until sizePx) {
bmp.setPixel(x, y, if (matrix[x, y]) AndroidColor.BLACK else AndroidColor.WHITE)
}
}
bmp.asImageBitmap()
} catch (_: Exception) {
null
}
}
@Composable
fun rememberQrImage(content: String, sizePx: Int = 512): ImageBitmap? {
return remember(content, sizePx) { encodeQrBitmap(content, sizePx) }
}

View file

@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx8192m -XX:MaxMetaspaceSize=1024m -XX:+UseG1GC -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx3072m -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -Dfile.encoding=UTF-8
org.gradle.parallel=false
org.gradle.workers.max=1
org.gradle.caching=true
@ -6,5 +6,5 @@ org.gradle.daemon=false
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
kotlin.compiler.execution.strategy=daemon
kotlin.compiler.execution.strategy=in-process
kotlin.incremental=true

View file

@ -28,6 +28,7 @@
668CADCCEA404B50BB19A46E /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A435950EF040DE83F30779 /* DetailScreens.swift */; };
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */; };
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */; };
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */; };
B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */; };
468E05CB91FC4A1997A2F386 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6FA95D6A03BF48EA97553486 /* Assets.xcassets */; };
DC8D7BF328134E1E8D7E3D76 /* MobileVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */; };
@ -57,6 +58,7 @@
F0A435950EF040DE83F30779 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = "<group>"; };
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvScreen.swift; sourceTree = "<group>"; };
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = "<group>"; };
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesScreen.swift; sourceTree = "<group>"; };
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = "<group>"; };
6FA95D6A03BF48EA97553486 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A22966A1CCBE44999F75FCA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@ -167,6 +169,7 @@
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */,
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */,
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */,
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */,
);
path = UI;
sourceTree = "<group>";
@ -265,6 +268,7 @@
B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */,
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */,
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */,
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -313,7 +317,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -328,7 +332,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.4.4;
MARKETING_VERSION = 0.4.6;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -346,7 +350,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -361,7 +365,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.4.4;
MARKETING_VERSION = 0.4.6;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -81,9 +81,22 @@ actor ApiClient {
return payload.results
}
func browse(catalogId: String, skip: Int = 0, take: Int = 60) async throws -> BrowsePayload {
func browse(
catalogId: String,
skip: Int = 0,
take: Int = 60,
sort: String? = nil,
letter: String? = nil
) async throws -> BrowsePayload {
let id = catalogId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? catalogId
return try await get("/api/v1/client/catalog/\(id)?skip=\(skip)&take=\(take)")
var q = "skip=\(skip)&take=\(take)"
if let sort, !sort.isEmpty {
q += "&sort=\(sort.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? sort)"
}
if let letter, !letter.isEmpty {
q += "&letter=\(letter.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? letter)"
}
return try await get("/api/v1/client/catalog/\(id)?\(q)")
}
func movie(id: String) async throws -> MovieDetail {
@ -225,6 +238,20 @@ actor ApiClient {
func clientMe() async throws -> ClientMe { try await get("/api/v1/client/me") }
func listDevices() async throws -> [LinkedDevice] {
let payload: DevicesPayload = try await get("/api/v1/client/devices")
return payload.devices
}
func revokeDevice(deviceId: String) async throws {
let encoded = deviceId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? deviceId
let _: Empty = try await post("/api/v1/client/devices/\(encoded)/revoke", json: [:])
}
func approveDeviceCode(_ code: String) async throws -> ApproveDeviceResult {
try await post("/api/v1/client/device/approve", json: ["code": code])
}
func searchSubtitles(mediaFileId: String, languages: String = "nl,en") async throws -> SubtitleSearchResult {
let id = mediaFileId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? mediaFileId
let langs = languages.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? languages

View file

@ -50,6 +50,8 @@ final class AppModel: ObservableObject {
@Published var iptvEnabled = false
@Published var canManageProfiles = false
@Published var bootError: String?
/// Code van QR/deep link om een ander apparaat goed te keuren (hoofdprofiel).
@Published var pendingApproveCode: String?
/// > 0 terwijl een speler (VOD/live) open is — verbergt de footer.
@Published private(set) var playbackDepth = 0
@ -141,9 +143,57 @@ final class AppModel: ObservableObject {
await api.configure(baseUrl: store.apiBase, token: nil)
favorites.bind(profileId: nil)
iptvEnabled = false
pendingApproveCode = nil
phase = .server
}
/// Deep link: `vmc://link?code=ABCD-EFGH` of `vmc://link/ABCD-EFGH`
func handleOpenURL(_ url: URL) {
guard let code = Self.extractLinkCode(from: url) else { return }
pendingApproveCode = code
if phase == .main {
tab = .account
}
}
func clearPendingApprove() {
pendingApproveCode = nil
}
static func extractLinkCode(from url: URL) -> String? {
let scheme = (url.scheme ?? "").lowercased()
if scheme == "vmc" {
if let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems,
let code = items.first(where: { $0.name.lowercased() == "code" })?.value,
!code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return normalizeLinkCode(code)
}
let parts = url.path.split(separator: "/").map(String.init)
if let last = parts.last, !last.isEmpty {
return normalizeLinkCode(last)
}
if let host = url.host, host.lowercased() != "link", !host.isEmpty {
return normalizeLinkCode(host)
}
}
// Web fallback: https://…/link?code=
if let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems,
let code = items.first(where: { $0.name.lowercased() == "code" })?.value {
return normalizeLinkCode(code)
}
return nil
}
static func normalizeLinkCode(_ raw: String) -> String {
let cleaned = raw.uppercased().filter { $0.isLetter || $0.isNumber || $0 == "-" }
if cleaned.contains("-") { return cleaned }
if cleaned.count == 8 {
let i = cleaned.index(cleaned.startIndex, offsetBy: 4)
return "\(cleaned[..i])-\(cleaned[i...])"
}
return cleaned
}
static var deviceName: String {
UIDevice.current.name.isEmpty ? "iPhone" : UIDevice.current.name
}

View file

@ -84,10 +84,30 @@ struct ClientMe: Decodable {
struct Iptv: Decodable { let enabled: Bool? }
let iptv: Iptv?
let profile: ViewerProfile?
let deviceId: String?
var iptvEnabled: Bool { iptv?.enabled == true }
}
struct LinkedDevice: Decodable, Identifiable, Hashable {
let id: String
let name: String
let platform: String?
let lastSeenAt: String?
let createdAt: String?
let current: Bool?
}
struct DevicesPayload: Decodable {
let devices: [LinkedDevice]
}
struct ApproveDeviceResult: Decodable {
let ok: Bool?
let deviceName: String?
let platform: String?
}
struct MediaFileInfo: Decodable, Identifiable, Hashable {
let id: String
let label: String?
@ -161,6 +181,7 @@ struct BrowsePayload: Decodable {
let title: String?
let kind: String?
let total: Int?
let offset: Int?
let items: [MediaItem]
}

View file

@ -20,6 +20,17 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>nl.vonas.mediacluster.ios.link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>vmc</string>
</array>
</dict>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>

View file

@ -1,77 +1,89 @@
import SwiftUI
/// Films / Series-bibliotheek — grid zoals Android Browse, met iOS-chips + letter-index.
struct BrowseScreen: View {
@EnvironmentObject private var app: AppModel
let kind: String // "MOVIE" or "SERIES"
let title: String
@State private var home: HomePayload?
@State private var shelfPrefs: [ShelfPref] = []
@State private var mode: BrowseMode = .planks
@State private var catalogId: String = ""
@State private var sort: SortMode = .title
@State private var activeLetter: String?
@State private var pageTitle: String = ""
@State private var items: [MediaItem] = []
@State private var total = 0
@State private var listOffset = 0
@State private var error: String?
@State private var loading = true
@State private var loadingMore = false
private enum BrowseMode: Hashable {
case planks
case catalog(String)
private enum SortMode: String {
case title
case recent
}
private var kindShelves: [ShelfRow] {
guard let home else { return [] }
return home.shelves.filter { shelf in
let k = (shelf.kind ?? "").uppercased()
if kind == "MOVIE" { return k == "MOVIE" || k.contains("MOVIE") }
return k == "SERIES" || k.contains("SERIES")
}
private var defaultCatalog: String {
kind == "MOVIE" ? "movies" : "series"
}
private var chips: [(label: String, mode: BrowseMode)] {
var list: [(String, BrowseMode)] = [("Planken", .planks)]
private var catalogChips: [(label: String, id: String)] {
var list: [(String, String)] = []
var seen = Set<String>()
func append(_ label: String, _ id: String) {
guard !seen.contains(id) else { return }
seen.insert(id)
list.append((label, id))
}
if kind == "MOVIE" {
list += [("Alles", .catalog("movies")), ("Recent", .catalog("recent-movies")), ("4K", .catalog("4k-movies"))]
append("Alles", "movies")
append("Recent", "recent-movies")
append("4K", "4k-movies")
} else {
list += [("Alles", .catalog("series")), ("Recent", .catalog("recent-series"))]
append("Alles", "series")
append("Recent", "recent-series")
}
for pref in shelfPrefs where pref.visible {
let k = (pref.kind ?? "").uppercased()
let match = kind == "MOVIE" ? (k == "MOVIE" || k.contains("MOVIE")) : (k == "SERIES" || k.contains("SERIES"))
let match = kind == "MOVIE"
? (k == "MOVIE" || k.contains("MOVIE"))
: (k == "SERIES" || k.contains("SERIES"))
if match {
let slug = pref.slug?.isEmpty == false ? pref.slug! : pref.shelfId
list.append((pref.name, .catalog(slug)))
append(pref.name, slug)
}
}
return list
}
private let cols = [GridItem(.adaptive(minimum: 104), spacing: 12)]
/// A–Z tools alleen zinvol op titel-gebaseerde collecties (niet op “Recent”-catalogi).
private var showSortTools: Bool {
switch catalogId {
case "recent-movies", "recent-series":
return false
default:
return true
}
}
private var showLetterIndex: Bool {
showSortTools && sort == .title && !items.isEmpty
}
private let letters = ["#"] + (65...90).map { String(UnicodeScalar($0)!) }
private let cols = [GridItem(.adaptive(minimum: 108), spacing: 10)]
var body: some View {
VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(chips.enumerated()), id: \.offset) { _, chip in
Button {
mode = chip.mode
Task { await activate(chip.mode) }
} label: {
Text(chip.label)
.font(McFonts.caption)
.foregroundStyle(mode == chip.mode ? .white : McColors.textDim)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(mode == chip.mode ? McColors.accent : McColors.surface, in: Capsule())
}
.buttonStyle(.plain)
catalogBar
headerBar
HStack(alignment: .top, spacing: 0) {
gridContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
if showLetterIndex {
letterIndex
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
content
}
.background(McColors.bg.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
@ -82,95 +94,197 @@ struct BrowseScreen: View {
}
.toolbarBackground(McColors.bg, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.task { await bootstrap() }
.refreshable { await bootstrap() }
.task {
if catalogId.isEmpty { catalogId = defaultCatalog }
await bootstrap()
}
.refreshable { await reload(reset: true) }
.navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) }
}
@ViewBuilder
private var content: some View {
if loading && home == nil && items.isEmpty {
ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error, home == nil && items.isEmpty {
VStack(spacing: 12) {
Text(error).foregroundStyle(McColors.danger)
Button("Opnieuw") { Task { await bootstrap() } }.buttonStyle(McPrimaryButtonStyle())
}
private var catalogBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(catalogChips, id: \.id) { chip in
let selected = catalogId == chip.id
Button {
guard catalogId != chip.id else { return }
catalogId = chip.id
activeLetter = nil
if chip.id == "recent-movies" || chip.id == "recent-series" || chip.id == "4k-movies" {
sort = .recent
} else {
switch mode {
case .planks:
ScrollView {
VStack(alignment: .leading, spacing: 22) {
if kindShelves.isEmpty {
ContentUnavailableView("Geen planken", systemImage: "rectangle.stack", description: Text("Nog geen \(title.lowercased())-planken zichtbaar."))
.frame(maxWidth: .infinity)
.padding(.top, 40)
} else {
ForEach(kindShelves) { shelf in
if !shelf.items.isEmpty {
ShelfSection(title: shelf.title, items: shelf.items)
sort = .title
}
}
}
}
.padding(.bottom, 24)
}
case .catalog:
ScrollView {
LazyVGrid(columns: cols, spacing: 14) {
ForEach(items) { item in
NavigationLink(value: item) {
PosterCard(item: item, width: 104)
Task { await reload(reset: true) }
} label: {
Text(chip.label)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(selected ? .white : McColors.text)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
selected ? McColors.accent : McColors.surfaceSoft,
in: Capsule()
)
.overlay(
Capsule()
.strokeBorder(
selected ? Color.clear : McColors.textDim.opacity(0.45),
lineWidth: 1
)
)
}
.buttonStyle(.plain)
.onAppear {
if item.id == items.last?.id { Task { await loadMore() } }
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 24)
if loadingMore { ProgressView().tint(.white).padding() }
.padding(.top, 10)
.padding(.bottom, 8)
}
.navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) }
.frame(minHeight: 52)
}
private var headerBar: some View {
HStack(spacing: 10) {
Text(pageTitle.isEmpty ? title : pageTitle)
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundStyle(McColors.text)
.lineLimit(1)
Text(total > 0 ? "\(total)" : (loading ? "…" : "0"))
.font(.system(size: 16, weight: .medium, design: .rounded))
.foregroundStyle(McColors.textDim)
Spacer(minLength: 8)
if showSortTools {
HStack(spacing: 0) {
sortChip("A–Z", selected: sort == .title) {
guard sort != .title else { return }
sort = .title
Task { await reload(reset: true) }
}
sortChip("Recent", selected: sort == .recent) {
guard sort != .recent else { return }
sort = .recent
activeLetter = nil
Task { await reload(reset: true) }
}
}
.background(McColors.surface, in: Capsule())
}
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
private func sortChip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(selected ? .black : McColors.text)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(selected ? Color.white : Color.clear, in: Capsule())
}
.buttonStyle(.plain)
}
@ViewBuilder
private var gridContent: some View {
if loading && items.isEmpty {
ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error, items.isEmpty {
VStack(spacing: 12) {
Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center)
Button("Opnieuw") { Task { await reload(reset: true) } }
.buttonStyle(McPrimaryButtonStyle())
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if !loading && items.isEmpty {
ContentUnavailableView(
"Geen titels",
systemImage: "square.stack.3d.up.slash",
description: Text("Deze collectie is leeg of nog niet gesynchroniseerd.")
)
.foregroundStyle(McColors.textDim)
} else {
ScrollView {
LazyVGrid(columns: cols, spacing: 12) {
ForEach(items) { item in
NavigationLink(value: item) {
PosterCard(item: item, width: 108, showTitle: true)
}
.buttonStyle(.plain)
.onAppear {
if item.id == items.last?.id {
Task { await loadMore() }
}
}
}
}
.padding(.horizontal, 14)
.padding(.bottom, 28)
if loadingMore {
ProgressView().tint(.white).padding(.bottom, 16)
}
}
}
}
private var letterIndex: some View {
GeometryReader { geo in
VStack(spacing: 0) {
ForEach(letters, id: \.self) { letter in
Text(letter)
.font(.system(size: 11, weight: activeLetter == letter ? .bold : .semibold, design: .rounded))
.foregroundStyle(activeLetter == letter ? McColors.accent : McColors.textDim)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(width: 22, height: geo.size.height)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let h = max(geo.size.height, 1)
let idx = Int((value.location.y / h) * CGFloat(letters.count))
let clamped = min(max(idx, 0), letters.count - 1)
let letter = letters[clamped]
if activeLetter != letter {
UISelectionFeedbackGenerator().selectionChanged()
Task { await jump(to: letter) }
}
}
)
}
.frame(width: 22)
.padding(.vertical, 4)
.padding(.trailing, 2)
}
private func bootstrap() async {
shelfPrefs = (try? await app.api.getShelfPrefs())?.sorted(by: { $0.sortOrder < $1.sortOrder }) ?? []
await reload(reset: true)
}
private func reload(reset: Bool) async {
if reset {
loading = true
error = nil
}
let id = catalogId.isEmpty ? defaultCatalog : catalogId
let sortParam: String? = showSortTools ? sort.rawValue : nil
do {
async let homeTask = app.api.home()
async let prefsTask = app.api.getShelfPrefs()
home = try await homeTask
shelfPrefs = (try? await prefsTask)?.sorted(by: { $0.sortOrder < $1.sortOrder }) ?? []
if case .catalog(let id) = mode {
await loadCatalog(id, reset: true)
}
loading = false
} catch {
self.error = error.localizedDescription
loading = false
}
}
private func activate(_ mode: BrowseMode) async {
switch mode {
case .planks:
break
case .catalog(let id):
await loadCatalog(id, reset: true)
}
}
private func loadCatalog(_ id: String, reset: Bool) async {
if reset {
items = []
loading = true
}
do {
let page = try await app.api.browse(catalogId: id, skip: 0, take: 60)
let page = try await app.api.browse(
catalogId: id,
skip: 0,
take: 60,
sort: sortParam,
letter: sort == .title ? activeLetter : nil
)
pageTitle = page.title?.isEmpty == false ? page.title! : title
total = page.total ?? page.items.count
listOffset = page.offset ?? 0
items = page.items
loading = false
} catch {
@ -179,12 +293,27 @@ struct BrowseScreen: View {
}
}
private func jump(to letter: String) async {
activeLetter = letter
sort = .title
await reload(reset: true)
}
private func loadMore() async {
guard case .catalog(let id) = mode, !loadingMore, items.count < total else { return }
guard !loadingMore, items.count + listOffset < total else { return }
loadingMore = true
let id = catalogId.isEmpty ? defaultCatalog : catalogId
let sortParam: String? = showSortTools ? sort.rawValue : nil
do {
let page = try await app.api.browse(catalogId: id, skip: items.count, take: 60)
items.append(contentsOf: page.items)
let page = try await app.api.browse(
catalogId: id,
skip: listOffset + items.count,
take: 60,
sort: sortParam,
letter: nil
)
let existing = Set(items.map(\.id))
items.append(contentsOf: page.items.filter { !existing.contains($0.id) })
loadingMore = false
} catch {
loadingMore = false

View file

@ -56,9 +56,11 @@ struct TitleLogo: View {
struct PosterCard: View {
let item: MediaItem
var width: CGFloat = 112
/// Grid-bibliotheek: geen titel onder poster (zoals Android Browse).
var showTitle: Bool = true
var body: some View {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: showTitle ? 6 : 0) {
RemoteImage(url: item.posterUrl)
.frame(width: width, height: width * 1.5)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
@ -85,6 +87,7 @@ struct PosterCard: View {
.padding(4)
}
}
if showTitle {
Text(item.title)
.font(McFonts.caption)
.foregroundStyle(McColors.text)
@ -92,7 +95,9 @@ struct PosterCard: View {
.multilineTextAlignment(.leading)
.frame(width: width, height: 36, alignment: .topLeading)
}
}
.frame(width: width, alignment: .leading)
.accessibilityLabel(item.title)
}
}

View file

@ -0,0 +1,324 @@
import SwiftUI
/// Gekoppelde apparaten + code/QR-goedkeuring (alleen hoofdprofiel).
struct DevicesScreen: View {
@EnvironmentObject private var app: AppModel
@State private var devices: [LinkedDevice] = []
@State private var loading = true
@State private var error: String?
@State private var revokeTarget: LinkedDevice?
@State private var manualCode = ""
@State private var approving = false
@State private var approveMessage: String?
@State private var approveError: String?
var body: some View {
List {
Section {
Text("Scan de QR op je TV of voer de code hieronder in om een apparaat te koppelen.")
.font(McFonts.caption)
.foregroundStyle(McColors.textDim)
.listRowBackground(McColors.bgElevated)
HStack(spacing: 10) {
TextField("ABCD-EFGH", text: $manualCode)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.font(.system(size: 18, weight: .semibold, design: .monospaced))
.foregroundStyle(McColors.text)
.onChange(of: manualCode) { _, value in
let normalized = AppModel.normalizeLinkCode(value)
if normalized != value { manualCode = normalized }
}
Button {
Task { await approve(manualCode) }
} label: {
if approving {
ProgressView().tint(.white)
} else {
Text("Koppel")
.font(McFonts.headline)
}
}
.buttonStyle(McPrimaryButtonStyle())
.disabled(approving || AppModel.normalizeLinkCode(manualCode).count < 8)
}
.listRowBackground(McColors.bgElevated)
if let approveMessage {
Text(approveMessage)
.font(McFonts.caption)
.foregroundStyle(McColors.success)
.listRowBackground(McColors.bgElevated)
}
if let approveError {
Text(approveError)
.font(McFonts.caption)
.foregroundStyle(McColors.danger)
.listRowBackground(McColors.bgElevated)
}
} header: {
Text("Nieuw apparaat")
}
Section("Gekoppelde apparaten") {
if loading && devices.isEmpty {
HStack {
Spacer()
ProgressView().tint(.white)
Spacer()
}
.listRowBackground(McColors.bgElevated)
} else if let error, devices.isEmpty {
Text(error)
.foregroundStyle(McColors.danger)
.listRowBackground(McColors.bgElevated)
} else if devices.isEmpty {
Text("Nog geen apparaten.")
.foregroundStyle(McColors.textDim)
.listRowBackground(McColors.bgElevated)
} else {
ForEach(devices) { device in
HStack(alignment: .top, spacing: 12) {
Image(systemName: platformIcon(device.platform))
.font(.system(size: 22))
.foregroundStyle(McColors.accent)
.frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(device.name)
.font(McFonts.headline)
.foregroundStyle(McColors.text)
if device.current == true {
Text("Dit apparaat")
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(McColors.accent)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(McColors.accentSoft, in: Capsule())
}
}
Text(platformLabel(device.platform))
.font(McFonts.caption)
.foregroundStyle(McColors.textDim)
if let seen = formatDate(device.lastSeenAt) {
Text("Laatst gezien: \(seen)")
.font(McFonts.caption)
.foregroundStyle(McColors.textDim)
}
}
Spacer(minLength: 8)
if device.current != true {
Button("Verwijder", role: .destructive) {
revokeTarget = device
}
.font(McFonts.caption)
}
}
.listRowBackground(McColors.bgElevated)
}
}
}
}
.scrollContentBackground(.hidden)
.background(McColors.bg.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
BrandHeader(subtitle: "APPARATEN")
}
}
.toolbarBackground(McColors.bg, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.task { await reload() }
.refreshable { await reload() }
.onChange(of: app.pendingApproveCode) { _, code in
guard let code, !code.isEmpty else { return }
manualCode = code
}
.onAppear {
if let code = app.pendingApproveCode, !code.isEmpty {
manualCode = code
}
}
.confirmationDialog(
"Apparaat verwijderen?",
isPresented: Binding(
get: { revokeTarget != nil },
set: { if !$0 { revokeTarget = nil } }
),
titleVisibility: .visible
) {
Button("Verwijderen", role: .destructive) {
if let target = revokeTarget {
Task { await revoke(target) }
}
}
Button("Annuleren", role: .cancel) { revokeTarget = nil }
} message: {
if let name = revokeTarget?.name {
Text("\(name) moet opnieuw worden gekoppeld om VMC te gebruiken.")
}
}
}
private func reload() async {
loading = true
error = nil
do {
devices = try await app.api.listDevices()
loading = false
} catch {
self.error = error.localizedDescription
loading = false
}
}
private func approve(_ raw: String) async {
let code = AppModel.normalizeLinkCode(raw)
guard code.count >= 8 else { return }
approving = true
approveError = nil
approveMessage = nil
do {
let result = try await app.api.approveDeviceCode(code)
let name = result.deviceName ?? "Apparaat"
approveMessage = "\(name) is gekoppeld."
app.clearPendingApprove()
manualCode = ""
await reload()
approving = false
} catch {
approveError = error.localizedDescription
approving = false
}
}
private func revoke(_ device: LinkedDevice) async {
revokeTarget = nil
do {
try await app.api.revokeDevice(deviceId: device.id)
await reload()
} catch {
self.error = error.localizedDescription
}
}
private func platformIcon(_ platform: String?) -> String {
switch (platform ?? "").lowercased() {
case "android_tv", "android-tv", "tv": return "tv"
case "android", "android_mobile": return "smartphone"
case "ios", "iphone", "ipad": return "iphone"
case "tizen": return "tv"
default: return "laptopcomputer"
}
}
private func platformLabel(_ platform: String?) -> String {
switch (platform ?? "").lowercased() {
case "android_tv", "android-tv": return "Android TV"
case "android", "android_mobile": return "Android"
case "ios": return "iOS"
case "tizen": return "Samsung TV"
default: return platform?.isEmpty == false ? platform! : "Apparaat"
}
}
private func formatDate(_ iso: String?) -> String? {
guard let iso, !iso.isEmpty else { return nil }
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var date = isoFormatter.date(from: iso)
if date == nil {
isoFormatter.formatOptions = [.withInternetDateTime]
date = isoFormatter.date(from: iso)
}
guard let date else { return iso }
let out = DateFormatter()
out.locale = Locale(identifier: "nl_NL")
out.dateStyle = .medium
out.timeStyle = .short
return out.string(from: date)
}
}
/// Sheet wanneer QR de app opent terwijl je al in de app zit.
struct DeviceApproveSheet: View {
@EnvironmentObject private var app: AppModel
let code: String
@State private var busy = false
@State private var error: String?
@State private var doneName: String?
var body: some View {
NavigationStack {
VStack(spacing: 20) {
Image(systemName: "tv.badge.checkmark")
.font(.system(size: 48))
.foregroundStyle(McColors.accent)
Text("Apparaat koppelen?")
.font(McFonts.title)
.foregroundStyle(McColors.text)
Text("Code \(code)\nBevestig om dit apparaat aan jouw account te koppelen.")
.font(McFonts.body)
.foregroundStyle(McColors.textDim)
.multilineTextAlignment(.center)
if let doneName {
Text("\(doneName) is gekoppeld.")
.foregroundStyle(McColors.success)
}
if let error {
Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center)
}
Spacer()
if doneName == nil {
Button {
Task { await approve() }
} label: {
if busy {
ProgressView().tint(.white)
} else {
Text("Goedkeuren")
}
}
.buttonStyle(McPrimaryButtonStyle())
.disabled(busy || !app.canManageProfiles)
if !app.canManageProfiles {
Text("Schakel naar het hoofdprofiel om apparaten te koppelen.")
.font(McFonts.caption)
.foregroundStyle(McColors.textDim)
.multilineTextAlignment(.center)
}
}
Button("Sluiten") {
app.clearPendingApprove()
}
.foregroundStyle(McColors.textDim)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(McColors.bg.ignoresSafeArea())
.toolbar {
ToolbarItem(placement: .principal) {
BrandHeader(subtitle: "KOPPELEN")
}
}
}
}
private func approve() async {
busy = true
error = nil
do {
let result = try await app.api.approveDeviceCode(code)
doneName = result.deviceName ?? "Apparaat"
busy = false
try? await Task.sleep(nanoseconds: 900_000_000)
app.clearPendingApprove()
} catch {
self.error = error.localizedDescription
busy = false
}
}
}

View file

@ -24,9 +24,31 @@ struct RootView: View {
}
}
.animation(.easeInOut(duration: 0.28), value: app.phase)
.sheet(item: approveSheetBinding) { item in
DeviceApproveSheet(code: item.code)
.environmentObject(app)
.presentationDetents([.medium, .large])
}
}
private var approveSheetBinding: Binding<ApproveCodeItem?> {
Binding(
get: {
guard let code = app.pendingApproveCode, app.phase == .main else { return nil }
return ApproveCodeItem(code: code)
},
set: { newValue in
if newValue == nil { app.clearPendingApprove() }
}
)
}
}
private struct ApproveCodeItem: Identifiable {
let code: String
var id: String { code }
}
struct SplashView: View {
@State private var pulse = false

View file

@ -57,6 +57,14 @@ struct SettingsScreen: View {
.foregroundStyle(McColors.text)
}
.listRowBackground(McColors.bgElevated)
NavigationLink {
DevicesScreen()
} label: {
Label("Apparaten", systemImage: "tv.and.hifispeaker.fill")
.foregroundStyle(McColors.text)
}
.listRowBackground(McColors.bgElevated)
}
}
@ -82,7 +90,7 @@ struct SettingsScreen: View {
Section("Info") {
LabeledContent("App", value: "VMC iOS")
LabeledContent("Versie", value: "0.4.3")
LabeledContent("Versie", value: "0.4.6")
LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
}
.listRowBackground(McColors.bgElevated)

View file

@ -9,6 +9,9 @@ struct VMCApp: App {
RootView()
.environmentObject(appModel)
.preferredColorScheme(.dark)
.onOpenURL { url in
appModel.handleOpenURL(url)
}
}
}
}

View file

@ -334,6 +334,50 @@ export function registerViewerRoutes(
}
);
/** In-app goedkeuren (ingelogde viewer / hoofdprofiel). */
app.post(
"/api/v1/client/device/approve",
{
config: {
rateLimit: { max: 30, timeWindow: "1 minute" },
},
},
async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
if (auth.profile?.isOwner !== true) {
throw new AppError("FORBIDDEN", "Alleen het hoofdprofiel kan apparaten koppelen", 403);
}
const body = (request.body || {}) as { code?: string };
if (!body.code?.trim()) throw new AppError("INVALID_REQUEST", "code verplicht", 400);
return viewers.approveDeviceCode(auth.viewerId, body.code);
}
);
app.get("/api/v1/client/devices", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
if (auth.profile?.isOwner !== true) {
throw new AppError("FORBIDDEN", "Alleen het hoofdprofiel kan apparaten beheren", 403);
}
return viewers.listMyDevices(auth.viewerId, auth.deviceId);
});
app.post(
"/api/v1/client/devices/:deviceId/revoke",
{
config: {
rateLimit: { max: 30, timeWindow: "1 minute" },
},
},
async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
if (auth.profile?.isOwner !== true) {
throw new AppError("FORBIDDEN", "Alleen het hoofdprofiel kan apparaten beheren", 403);
}
const { deviceId } = request.params as { deviceId: string };
return viewers.revokeMyDevice(auth.viewerId, deviceId, auth.deviceId);
}
);
/** Directe app-login (e-mail + wachtwoord) → refresh token. */
app.post(
"/api/v1/client/login",

View file

@ -273,6 +273,71 @@ export class ViewerService {
return { ok: true };
}
async listMyDevices(viewerId: string, currentDeviceId: string) {
const devices = await prisma.viewerDevice.findMany({
where: { viewerUserId: viewerId, revoked: false },
select: { id: true, name: true, platform: true, lastSeenAt: true, createdAt: true },
orderBy: { lastSeenAt: "desc" },
});
return {
devices: devices.map((d) => ({
id: d.id,
name: d.name,
platform: d.platform,
lastSeenAt: d.lastSeenAt?.toISOString() ?? null,
createdAt: d.createdAt.toISOString(),
current: d.id === currentDeviceId,
})),
};
}
async revokeMyDevice(viewerId: string, deviceId: string, currentDeviceId: string) {
const device = await prisma.viewerDevice.findFirst({
where: { id: deviceId, viewerUserId: viewerId, revoked: false },
});
if (!device) throw new AppError("NOT_FOUND", "Apparaat niet gevonden", 404);
if (device.id === currentDeviceId) {
throw new AppError("CONFLICT", "Je kunt dit apparaat niet intrekken terwijl je erop bent ingelogd", 409);
}
return this.revokeDevice(deviceId);
}
/**
* In-app goedkeuren: viewer is al ingelogd (Bearer) en bevestigt een TV/device-code.
*/
async approveDeviceCode(viewerId: string, codeRaw: string) {
const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId } });
if (!viewer || !viewer.enabled || !viewer.appAccess) {
throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403);
}
const row = await prisma.deviceLinkCode.findUnique({
where: { codeHash: hashLinkCode(codeRaw) },
});
if (!row) throw new AppError("NOT_FOUND", "Onbekende code", 404);
if (row.consumedAt) throw new AppError("CONFLICT", "Code al gebruikt", 409);
if (row.expiresAt.getTime() < Date.now()) {
throw new AppError("EXPIRED", "Code verlopen — start opnieuw op het apparaat", 410);
}
const claimToken = generateSecureToken(24);
await prisma.deviceLinkCode.update({
where: { id: row.id },
data: {
consumedAt: new Date(),
viewerUserId: viewer.id,
claimTokenHash: hashToken(claimToken),
},
});
return {
ok: true,
deviceName: row.deviceName,
platform: row.platform,
viewer: { id: viewer.id, email: viewer.email, name: viewer.name },
};
}
async createDeviceCode(deviceName: string, platform = "android_tv") {
const name = deviceName.trim().slice(0, 80) || "Android TV";
const code = generateLinkCode(8);
@ -287,13 +352,16 @@ export class ViewerService {
},
});
const linkUrl = `${this.publicBase()}/link`;
// Deep link voor VMC-app (in-app goedkeuren). Web /link blijft beschikbaar als fallback.
const qrPayload = `vmc://link?code=${encodeURIComponent(code)}`;
return {
code,
expiresAt: expiresAt.toISOString(),
expiresInSeconds: Math.floor(CODE_TTL_MS / 1000),
pollIntervalSeconds: POLL_INTERVAL_SEC,
linkUrl,
qrPayload: `${linkUrl}?code=${encodeURIComponent(code)}`,
qrPayload,
appLink: qrPayload,
};
}