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" applicationId = "nl.vonas.mediacluster"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 12 versionCode = 13
versionName = "1.0.11" versionName = "1.0.12"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") 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.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text import androidx.tv.material3.Text
import java.io.File import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SubtitleHit import nl.vonas.mediacluster.tv.data.SubtitleHit
import nl.vonas.mediacluster.tv.playback.PlayerTrack import nl.vonas.mediacluster.tv.playback.PlayerTrack
@ -269,46 +270,52 @@ fun PlayerScreen(
} }
player.addListener(listener) player.addListener(listener)
onDispose { onDispose {
try { val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
runBlocking { 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( api.postProgress(
mediaType = mediaType, mediaType = mediaType,
movieId = movieId, movieId = movieId,
episodeId = episodeId, episodeId = episodeId,
seriesId = seriesId, seriesId = seriesId,
positionMs = player.currentPosition.coerceAtLeast(0), positionMs = pos,
durationMs = player.duration.coerceAtLeast(0), durationMs = dur,
) )
} }
} catch (_: Exception) {
} }
player.removeListener(listener)
player.release()
} }
} }
LaunchedEffect(player) { LaunchedEffect(player) {
while (true) { while (isActive) {
delay(400) delay(400)
runCatching {
positionMs = player.currentPosition.coerceAtLeast(0) positionMs = player.currentPosition.coerceAtLeast(0)
durationMs = player.duration.coerceAtLeast(0) durationMs = player.duration.coerceAtLeast(0)
} }
} }
}
LaunchedEffect(player) { LaunchedEffect(player) {
while (true) { while (isActive) {
delay(12_000) delay(12_000)
if (player.isPlaying) { val playing = runCatching { player.isPlaying }.getOrDefault(false)
try { if (playing) {
val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
val dur = runCatching { player.duration.coerceAtLeast(0) }.getOrDefault(0L)
runCatching {
api.postProgress( api.postProgress(
mediaType = mediaType, mediaType = mediaType,
movieId = movieId, movieId = movieId,
episodeId = episodeId, episodeId = episodeId,
seriesId = seriesId, seriesId = seriesId,
positionMs = player.currentPosition.coerceAtLeast(0), positionMs = pos,
durationMs = player.duration.coerceAtLeast(0), durationMs = dur,
) )
} catch (_: Exception) {
} }
} }
} }
@ -393,6 +400,7 @@ fun PlayerScreen(
} }
}, },
update = { it.player = player }, update = { it.player = player },
onRelease = { it.player = null },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 76 versionCode = 82
versionName = "0.14.13" versionName = "0.14.19"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")
@ -90,6 +90,7 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("androidx.datastore:datastore-preferences:1.1.1") implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("io.coil-kt:coil-compose:2.7.0") implementation("io.coil-kt:coil-compose:2.7.0")
implementation("com.google.zxing:core:3.5.3")
debugImplementation("androidx.compose.ui:ui-tooling") 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.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
@ -30,12 +30,10 @@ 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
@ -58,7 +56,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, ExperimentalComposeUiApi::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun BrowseScreen( fun BrowseScreen(
api: ApiClient, api: ApiClient,
@ -91,18 +89,16 @@ fun BrowseScreen(
var loadingMore by remember { mutableStateOf(false) } var loadingMore by remember { mutableStateOf(false) }
var sort by rememberSaveable(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) } var sort by rememberSaveable(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) }
var activeLetter by rememberSaveable(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 = rememberSaveable(catalogId, sort, saver = LazyGridState.Saver) { LazyGridState() } val gridState = rememberLazyGridState()
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) {
// Bestaande items houden het grid in compositie → focus blijft bij terugkeer loading = true
if (items.isEmpty()) loading = true
error = null error = null
} else { } else {
loadingMore = true loadingMore = true
@ -137,11 +133,8 @@ 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)
loadedKey = key gridState.scrollToItem(0)
}
} }
LaunchedEffect(gridState, items.size, total, listOffset) { LaunchedEffect(gridState, items.size, total, listOffset) {
@ -185,35 +178,43 @@ fun BrowseScreen(
} }
Box(Modifier.fillMaxSize().background(McColors.Bg)) { Box(Modifier.fillMaxSize().background(McColors.Bg)) {
val scrubberPad = if (showBrowseTools && sort == "title") 40.dp else 28.dp
Column( Column(
Modifier Modifier
.fillMaxSize() .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( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(Modifier.weight(1f)) { Row(
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold) 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( Text(
when { when {
total > 0 -> "$total titels" total > 0 -> "$total"
loading -> "Laden…" loading -> "…"
else -> "Geen titels" else -> "0"
}, },
color = McColors.TextDim, color = McColors.TextDim,
fontSize = 14.sp, fontSize = 18.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
) )
} }
if (onBack != null) {
McButton("Terug", onClick = onBack)
}
}
if (showBrowseTools) { if (showBrowseTools) {
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SortChip( SortChip(
label = "A–Z", 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) { if (error != null) {
Text(error!!, color = McColors.Danger) Text(error!!, color = McColors.Danger, fontSize = 16.sp)
Spacer(Modifier.height(8.dp))
} }
when { when {
loading && items.isEmpty() -> { loading && items.isEmpty() -> {
@ -251,21 +256,20 @@ fun BrowseScreen(
} }
else -> { else -> {
LazyVerticalGrid( 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, state = gridState,
contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp), contentPadding = PaddingValues(start = 4.dp, top = 4.dp, end = 4.dp, bottom = 28.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.focusRestorer(),
) { ) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item -> items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard( PosterCard(
item = item, item = item,
fillWidth = true, fillWidth = true,
showCaption = isContinue, showCaption = isContinue,
modifier = Modifier.padding(6.dp), modifier = Modifier.padding(3.dp),
onLongClick = if (isContinue) { onLongClick = if (isContinue) {
{ continueMenu = item } { continueMenu = item }
} else { } else {
@ -305,11 +309,10 @@ fun BrowseScreen(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.fillMaxHeight() .fillMaxHeight()
.padding(vertical = 16.dp, horizontal = 4.dp), .padding(vertical = 8.dp, horizontal = 2.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)
} }
}, },
@ -379,7 +382,7 @@ private fun SortChip(
Surface( Surface(
onClick = onClick, onClick = onClick,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) Color.White else Color(0x33FFFFFF), containerColor = if (selected) Color.White else Color(0x33FFFFFF),
focusedContainerColor = Color.White, focusedContainerColor = Color.White,
@ -387,9 +390,9 @@ private fun SortChip(
) { ) {
Text( Text(
label, 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, color = if (selected) Color.Black else Color.White,
fontSize = 14.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
} }
@ -403,7 +406,7 @@ private fun AzScrubber(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
modifier = modifier.width(30.dp), modifier = modifier.width(34.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
AzLetters.forEach { letter -> AzLetters.forEach { letter ->
@ -414,7 +417,7 @@ private fun AzScrubber(
.weight(1f) .weight(1f)
.fillMaxWidth(), .fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(3.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(3.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.35f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.4f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
focusedContainerColor = Color(0x55FFFFFF), focusedContainerColor = Color(0x55FFFFFF),
@ -426,10 +429,10 @@ private fun AzScrubber(
) { ) {
Text( Text(
letter, letter,
color = if (isSelected) McColors.Brand else Color(0xCCFFFFFF), color = if (isSelected) McColors.Brand else Color(0xE6FFFFFF),
fontSize = 10.sp, fontSize = 13.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
lineHeight = 10.sp, lineHeight = 13.sp,
) )
} }
} }

View file

@ -9,6 +9,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.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
@ -45,13 +45,11 @@ 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,10 +67,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.foundation.Image
import androidx.compose.ui.graphics.FilterQuality
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
@ -326,55 +325,28 @@ fun McNav() {
}, },
) )
} }
// dialog i.p.v. composable: Home/Browse blijven gecomposeerd → scroll+focus behouden composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
dialog( val id = entry.arguments?.getString("id") ?: return@composable
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 ->
dialog( val id = entry.arguments?.getString("id") ?: return@composable
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(
@ -450,7 +422,7 @@ 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 = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } val homeListState = rememberLazyListState()
var prevTabName by rememberSaveable { mutableStateOf<String?>(null) } 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
@ -818,10 +790,11 @@ private fun LinkScreen(
onLinked: () -> Unit, onLinked: () -> Unit,
) { ) {
var code by remember { mutableStateOf<String?>(null) } var code by remember { mutableStateOf<String?>(null) }
var linkUrl by remember { mutableStateOf("") } var qrPayload 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("") } var serverLabel by remember { mutableStateOf("") }
val qrImage = rememberQrImage(qrPayload)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val base = store.apiBase.first() val base = store.apiBase.first()
@ -831,8 +804,10 @@ private fun LinkScreen(
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" } val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" }
val created = api.createDeviceCode(deviceName) val created = api.createDeviceCode(deviceName)
code = created.code code = created.code
linkUrl = created.linkUrl qrPayload = created.qrPayload.ifBlank {
status = "Wachten tot je de code bevestigt…" "vmc://link?code=${created.code}"
}
status = "Wachten op goedkeuring in de VMC-app…"
while (true) { while (true) {
delay(created.pollIntervalSeconds.coerceAtLeast(2) * 1000L) delay(created.pollIntervalSeconds.coerceAtLeast(2) * 1000L)
when (val poll = api.pollDeviceCode(created.code)) { when (val poll = api.pollDeviceCode(created.code)) {
@ -866,23 +841,60 @@ private fun LinkScreen(
.background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)), .background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column( Row(
horizontalAlignment = Alignment.CenterHorizontally, horizontalArrangement = Arrangement.spacedBy(36.dp),
verticalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.width(560.dp) .width(820.dp)
.clip(RoundedCornerShape(24.dp)) .clip(RoundedCornerShape(24.dp))
.background(McColors.BgElevated.copy(alpha = 0.92f)) .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("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 = 30.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 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()) { if (serverLabel.isNotBlank()) {
Text(serverLabel, color = Color(0xFF9AB0D0), fontSize = 13.sp) 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 = 44.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
if (linkUrl.isNotBlank()) Text(linkUrl, color = Color.White, fontSize = 18.sp)
Text(status, color = McColors.TextDim, fontSize = 14.sp) 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)) Spacer(Modifier.height(4.dp))
@ -890,6 +902,7 @@ private fun LinkScreen(
} }
} }
} }
}
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -910,12 +923,14 @@ 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) } var shelfFocusReady by remember { mutableStateOf(false) }
var playFocusReady by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val menuFocus = remember { FocusRequester() } val menuFocus = remember { FocusRequester() }
val context = LocalContext.current val context = LocalContext.current
val store = remember { SessionStore(context) } val store = remember { SessionStore(context) }
val density = LocalDensity.current val density = LocalDensity.current
val firstShelfFocus = remember { FocusRequester() }
suspend fun reloadHome() { suspend fun reloadHome() {
val fresh = api.home() val fresh = api.home()
@ -952,13 +967,15 @@ private fun HomeScreen(
} }
} }
// Alleen eerste keer focus op Afspelen — niet bij terugkeer uit film/serie // Bij elke verschijning van Home (ook na serie/film/player): focus op content, niet op top-nav.
LaunchedEffect(home) { LaunchedEffect(Unit) {
if (home == null || didInitialHomeFocus) return@LaunchedEffect
delay(120)
runCatching { contentFocusRequester.requestFocus() }
onContentFocused() 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 { val spotlight = home?.spotlight.orEmpty().ifEmpty {
@ -980,7 +997,14 @@ private fun HomeScreen(
shelfStartPx 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) { LaunchedEffect(listState) {
snapshotFlow { snapshotFlow {
Triple( Triple(
@ -989,8 +1013,8 @@ private fun HomeScreen(
listState.isScrollInProgress, listState.isScrollInProgress,
) )
}.collect { (index, offset, scrolling) -> }.collect { (index, offset, scrolling) ->
if (!scrolling && index == 0 && offset in 1..160) { if (!scrolling && index == 0 && offset in 1..240) {
listState.scrollToItem(0) listState.animateScrollToItem(0)
} }
} }
} }
@ -1021,9 +1045,18 @@ private fun HomeScreen(
} }
} }
val firstShelfFocus = remember { FocusRequester() }
Box(Modifier.fillMaxSize()) { 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) { if (loading && home == null) {
HomeSkeleton() HomeSkeleton()
} else { } else {
@ -1043,16 +1076,15 @@ private fun HomeScreen(
index = spotlightIndex, index = spotlightIndex,
playFocusRequester = contentFocusRequester, playFocusRequester = contentFocusRequester,
navFocusRequester = navFocusRequester, 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 = {
onContentFocused() onContentFocused()
// Alleen terug naar top als we al bovenaan zitten met rest-offset; // Focus op hero/Afspelen → LazyColumn altijd volledig naar top
// niet forceren bij focus-events na terugkeer uit detail. // (anders blijft hero deels onder het menu hangen tot Back).
if (listState.firstVisibleItemIndex == 0 && expandHeroFully()
listState.firstVisibleItemScrollOffset in 1..160
) {
scope.launch { listState.scrollToItem(0) }
}
}, },
onPlay = { item -> onPlay = { item ->
onContentFocused() onContentFocused()
@ -1097,8 +1129,12 @@ private fun HomeScreen(
items = preview, items = preview,
showCaptions = true, showCaptions = true,
firstItemFocusRequester = firstShelfFocus, firstItemFocusRequester = firstShelfFocus,
focusUpTo = contentFocusRequester, focusUpTo = contentFocusRequester.takeIf { playFocusReady },
onRowFocused = onContentFocused, onFirstFocusTargetReady = { shelfFocusReady = it },
onRowFocused = {
onContentFocused()
expandHeroFully()
},
onSeeAll = if (continueItems.size > 5) { onSeeAll = if (continueItems.size > 5) {
{ onSeeAll("continue-watching", "Verder kijken") } { onSeeAll("continue-watching", "Verder kijken") }
} else { } else {
@ -1139,8 +1175,16 @@ private fun HomeScreen(
title = shelf.title, title = shelf.title,
items = shelf.items, items = shelf.items,
firstItemFocusRequester = if (isFirstShelf) firstShelfFocus else null, firstItemFocusRequester = if (isFirstShelf) firstShelfFocus else null,
focusUpTo = if (isFirstShelf) contentFocusRequester else null, focusUpTo = if (isFirstShelf) contentFocusRequester.takeIf { playFocusReady } else null,
onRowFocused = onContentFocused, onFirstFocusTargetReady = if (isFirstShelf) {
{ shelfFocusReady = it }
} else {
null
},
onRowFocused = {
onContentFocused()
if (isFirstShelf) expandHeroFully()
},
onSeeAll = { onSeeAll(catalogId, shelf.browseTitle) }, onSeeAll = { onSeeAll(catalogId, shelf.browseTitle) },
onClick = { item -> onClick = { item ->
onContentFocused() onContentFocused()
@ -1243,6 +1287,7 @@ private fun SpotlightHero(
playFocusRequester: FocusRequester? = null, playFocusRequester: FocusRequester? = null,
navFocusRequester: FocusRequester? = null, navFocusRequester: FocusRequester? = null,
shelvesFocusRequester: FocusRequester? = null, shelvesFocusRequester: FocusRequester? = null,
onPlayFocusReady: (Boolean) -> Unit = {},
onContentFocused: () -> Unit = {}, onContentFocused: () -> Unit = {},
) { ) {
val safeItems = items.ifEmpty { listOf(MediaItem(type = "movie", id = "", title = "Welkom bij VMC")) } 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)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (current.id.isNotBlank()) { if (current.id.isNotBlank()) {
DisposableEffect(playFocusRequester) {
onPlayFocusReady(playFocusRequester != null)
onDispose { onPlayFocusReady(false) }
}
McButton( McButton(
"Afspelen", "Afspelen",
primary = true, primary = true,
@ -1374,7 +1423,6 @@ private fun SpotlightHero(
} }
} }
} }
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
private fun ShelfBlock( private fun ShelfBlock(
title: String, title: String,
@ -1386,6 +1434,7 @@ private fun ShelfBlock(
onRowFocused: () -> Unit = {}, onRowFocused: () -> Unit = {},
firstItemFocusRequester: FocusRequester? = null, firstItemFocusRequester: FocusRequester? = null,
focusUpTo: FocusRequester? = null, focusUpTo: FocusRequester? = null,
onFirstFocusTargetReady: ((Boolean) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var rowFocused by remember { mutableStateOf(false) } var rowFocused by remember { mutableStateOf(false) }
@ -1435,12 +1484,17 @@ 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),
) { ) {
itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item -> 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( PosterCard(
item = item, item = item,
onClick = { onClick(item) }, onClick = { onClick(item) },

View file

@ -60,9 +60,10 @@ import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SubtitleHit import nl.vonas.mediacluster.tv.data.SubtitleHit
import nl.vonas.mediacluster.tv.playback.PlayerTrack import nl.vonas.mediacluster.tv.playback.PlayerTrack
@ -266,46 +267,53 @@ fun PlayerScreen(
} }
player.addListener(listener) player.addListener(listener)
onDispose { onDispose {
try { val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
runBlocking { 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( api.postProgress(
mediaType = mediaType, mediaType = mediaType,
movieId = movieId, movieId = movieId,
episodeId = episodeId, episodeId = episodeId,
seriesId = seriesId, seriesId = seriesId,
positionMs = player.currentPosition.coerceAtLeast(0), positionMs = pos,
durationMs = player.duration.coerceAtLeast(0), durationMs = dur,
) )
} }
} catch (_: Exception) {
} }
player.removeListener(listener)
player.release()
} }
} }
LaunchedEffect(player) { LaunchedEffect(player) {
while (true) { while (isActive) {
delay(400) delay(400)
runCatching {
positionMs = player.currentPosition.coerceAtLeast(0) positionMs = player.currentPosition.coerceAtLeast(0)
durationMs = player.duration.coerceAtLeast(0) durationMs = player.duration.coerceAtLeast(0)
} }
} }
}
LaunchedEffect(player) { LaunchedEffect(player) {
while (true) { while (isActive) {
delay(12_000) delay(12_000)
if (player.isPlaying) { val playing = runCatching { player.isPlaying }.getOrDefault(false)
try { if (playing) {
val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
val dur = runCatching { player.duration.coerceAtLeast(0) }.getOrDefault(0L)
runCatching {
api.postProgress( api.postProgress(
mediaType = mediaType, mediaType = mediaType,
movieId = movieId, movieId = movieId,
episodeId = episodeId, episodeId = episodeId,
seriesId = seriesId, seriesId = seriesId,
positionMs = player.currentPosition.coerceAtLeast(0), positionMs = pos,
durationMs = player.duration.coerceAtLeast(0), durationMs = dur,
) )
} catch (_: Exception) {
} }
} }
} }
@ -420,6 +428,7 @@ fun PlayerScreen(
} }
}, },
update = { it.player = player }, update = { it.player = player },
onRelease = { it.player = null },
modifier = Modifier.fillMaxSize(), 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.parallel=false
org.gradle.workers.max=1 org.gradle.workers.max=1
org.gradle.caching=true org.gradle.caching=true
@ -6,5 +6,5 @@ org.gradle.daemon=false
android.useAndroidX=true android.useAndroidX=true
kotlin.code.style=official kotlin.code.style=official
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
kotlin.compiler.execution.strategy=daemon kotlin.compiler.execution.strategy=in-process
kotlin.incremental=true kotlin.incremental=true

View file

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

View file

@ -81,9 +81,22 @@ actor ApiClient {
return payload.results 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 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 { 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 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 { func searchSubtitles(mediaFileId: String, languages: String = "nl,en") async throws -> SubtitleSearchResult {
let id = mediaFileId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? mediaFileId let id = mediaFileId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? mediaFileId
let langs = languages.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? languages let langs = languages.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? languages

View file

@ -50,6 +50,8 @@ final class AppModel: ObservableObject {
@Published var iptvEnabled = false @Published var iptvEnabled = false
@Published var canManageProfiles = false @Published var canManageProfiles = false
@Published var bootError: String? @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. /// > 0 terwijl een speler (VOD/live) open is — verbergt de footer.
@Published private(set) var playbackDepth = 0 @Published private(set) var playbackDepth = 0
@ -141,9 +143,57 @@ 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
pendingApproveCode = nil
phase = .server 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 { static var deviceName: String {
UIDevice.current.name.isEmpty ? "iPhone" : UIDevice.current.name UIDevice.current.name.isEmpty ? "iPhone" : UIDevice.current.name
} }

View file

@ -84,10 +84,30 @@ struct ClientMe: Decodable {
struct Iptv: Decodable { let enabled: Bool? } struct Iptv: Decodable { let enabled: Bool? }
let iptv: Iptv? let iptv: Iptv?
let profile: ViewerProfile? let profile: ViewerProfile?
let deviceId: String?
var iptvEnabled: Bool { iptv?.enabled == true } 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 { struct MediaFileInfo: Decodable, Identifiable, Hashable {
let id: String let id: String
let label: String? let label: String?
@ -161,6 +181,7 @@ struct BrowsePayload: Decodable {
let title: String? let title: String?
let kind: String? let kind: String?
let total: Int? let total: Int?
let offset: Int?
let items: [MediaItem] let items: [MediaItem]
} }

View file

@ -20,6 +20,17 @@
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <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> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>

View file

@ -1,77 +1,89 @@
import SwiftUI import SwiftUI
/// Films / Series-bibliotheek — grid zoals Android Browse, met iOS-chips + letter-index.
struct BrowseScreen: View { struct BrowseScreen: View {
@EnvironmentObject private var app: AppModel @EnvironmentObject private var app: AppModel
let kind: String // "MOVIE" or "SERIES" let kind: String // "MOVIE" or "SERIES"
let title: String let title: String
@State private var home: HomePayload?
@State private var shelfPrefs: [ShelfPref] = [] @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 items: [MediaItem] = []
@State private var total = 0 @State private var total = 0
@State private var listOffset = 0
@State private var error: String? @State private var error: String?
@State private var loading = true @State private var loading = true
@State private var loadingMore = false @State private var loadingMore = false
private enum BrowseMode: Hashable { private enum SortMode: String {
case planks case title
case catalog(String) case recent
} }
private var kindShelves: [ShelfRow] { private var defaultCatalog: String {
guard let home else { return [] } kind == "MOVIE" ? "movies" : "series"
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 chips: [(label: String, mode: BrowseMode)] { private var catalogChips: [(label: String, id: String)] {
var list: [(String, BrowseMode)] = [("Planken", .planks)] 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" { 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 { } else {
list += [("Alles", .catalog("series")), ("Recent", .catalog("recent-series"))] append("Alles", "series")
append("Recent", "recent-series")
} }
for pref in shelfPrefs where pref.visible { for pref in shelfPrefs where pref.visible {
let k = (pref.kind ?? "").uppercased() 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 { if match {
let slug = pref.slug?.isEmpty == false ? pref.slug! : pref.shelfId let slug = pref.slug?.isEmpty == false ? pref.slug! : pref.shelfId
list.append((pref.name, .catalog(slug))) append(pref.name, slug)
} }
} }
return list 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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) { catalogBar
HStack(spacing: 8) { headerBar
ForEach(Array(chips.enumerated()), id: \.offset) { _, chip in HStack(alignment: .top, spacing: 0) {
Button { gridContent
mode = chip.mode .frame(maxWidth: .infinity, maxHeight: .infinity)
Task { await activate(chip.mode) } if showLetterIndex {
} label: { letterIndex
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)
} }
} }
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
content
} }
.background(McColors.bg.ignoresSafeArea()) .background(McColors.bg.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@ -82,95 +94,197 @@ struct BrowseScreen: View {
} }
.toolbarBackground(McColors.bg, for: .navigationBar) .toolbarBackground(McColors.bg, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar)
.task { await bootstrap() } .task {
.refreshable { await bootstrap() } if catalogId.isEmpty { catalogId = defaultCatalog }
await bootstrap()
}
.refreshable { await reload(reset: true) }
.navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) }
} }
@ViewBuilder private var catalogBar: some View {
private var content: some View { ScrollView(.horizontal, showsIndicators: false) {
if loading && home == nil && items.isEmpty { HStack(spacing: 8) {
ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity) ForEach(catalogChips, id: \.id) { chip in
} else if let error, home == nil && items.isEmpty { let selected = catalogId == chip.id
VStack(spacing: 12) { Button {
Text(error).foregroundStyle(McColors.danger) guard catalogId != chip.id else { return }
Button("Opnieuw") { Task { await bootstrap() } }.buttonStyle(McPrimaryButtonStyle()) catalogId = chip.id
} activeLetter = nil
if chip.id == "recent-movies" || chip.id == "recent-series" || chip.id == "4k-movies" {
sort = .recent
} else { } else {
switch mode { sort = .title
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)
} }
} Task { await reload(reset: true) }
} } label: {
} Text(chip.label)
.padding(.bottom, 24) .font(.system(size: 13, weight: .semibold, design: .rounded))
} .foregroundStyle(selected ? .white : McColors.text)
case .catalog: .padding(.horizontal, 14)
ScrollView { .padding(.vertical, 8)
LazyVGrid(columns: cols, spacing: 14) { .background(
ForEach(items) { item in selected ? McColors.accent : McColors.surfaceSoft,
NavigationLink(value: item) { in: Capsule()
PosterCard(item: item, width: 104) )
.overlay(
Capsule()
.strokeBorder(
selected ? Color.clear : McColors.textDim.opacity(0.45),
lineWidth: 1
)
)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.onAppear {
if item.id == items.last?.id { Task { await loadMore() } }
}
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.bottom, 24) .padding(.top, 10)
if loadingMore { ProgressView().tint(.white).padding() } .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 { 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 loading = true
error = nil error = nil
}
let id = catalogId.isEmpty ? defaultCatalog : catalogId
let sortParam: String? = showSortTools ? sort.rawValue : nil
do { do {
async let homeTask = app.api.home() let page = try await app.api.browse(
async let prefsTask = app.api.getShelfPrefs() catalogId: id,
home = try await homeTask skip: 0,
shelfPrefs = (try? await prefsTask)?.sorted(by: { $0.sortOrder < $1.sortOrder }) ?? [] take: 60,
if case .catalog(let id) = mode { sort: sortParam,
await loadCatalog(id, reset: true) letter: sort == .title ? activeLetter : nil
} )
loading = false pageTitle = page.title?.isEmpty == false ? page.title! : title
} 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)
total = page.total ?? page.items.count total = page.total ?? page.items.count
listOffset = page.offset ?? 0
items = page.items items = page.items
loading = false loading = false
} catch { } 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 { 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 loadingMore = true
let id = catalogId.isEmpty ? defaultCatalog : catalogId
let sortParam: String? = showSortTools ? sort.rawValue : nil
do { do {
let page = try await app.api.browse(catalogId: id, skip: items.count, take: 60) let page = try await app.api.browse(
items.append(contentsOf: page.items) 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 loadingMore = false
} catch { } catch {
loadingMore = false loadingMore = false

View file

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

View file

@ -57,6 +57,14 @@ struct SettingsScreen: View {
.foregroundStyle(McColors.text) .foregroundStyle(McColors.text)
} }
.listRowBackground(McColors.bgElevated) .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") { Section("Info") {
LabeledContent("App", value: "VMC iOS") 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") LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
} }
.listRowBackground(McColors.bgElevated) .listRowBackground(McColors.bgElevated)

View file

@ -9,6 +9,9 @@ struct VMCApp: App {
RootView() RootView()
.environmentObject(appModel) .environmentObject(appModel)
.preferredColorScheme(.dark) .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. */ /** Directe app-login (e-mail + wachtwoord) → refresh token. */
app.post( app.post(
"/api/v1/client/login", "/api/v1/client/login",

View file

@ -273,6 +273,71 @@ export class ViewerService {
return { ok: true }; 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") { async createDeviceCode(deviceName: string, platform = "android_tv") {
const name = deviceName.trim().slice(0, 80) || "Android TV"; const name = deviceName.trim().slice(0, 80) || "Android TV";
const code = generateLinkCode(8); const code = generateLinkCode(8);
@ -287,13 +352,16 @@ export class ViewerService {
}, },
}); });
const linkUrl = `${this.publicBase()}/link`; 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 { return {
code, code,
expiresAt: expiresAt.toISOString(), expiresAt: expiresAt.toISOString(),
expiresInSeconds: Math.floor(CODE_TTL_MS / 1000), expiresInSeconds: Math.floor(CODE_TTL_MS / 1000),
pollIntervalSeconds: POLL_INTERVAL_SEC, pollIntervalSeconds: POLL_INTERVAL_SEC,
linkUrl, linkUrl,
qrPayload: `${linkUrl}?code=${encodeURIComponent(code)}`, qrPayload,
appLink: qrPayload,
}; };
} }