diff --git a/apps/android-tv/app-mobile/build.gradle.kts b/apps/android-tv/app-mobile/build.gradle.kts index 4a3f86d..7ed9a5f 100644 --- a/apps/android-tv/app-mobile/build.gradle.kts +++ b/apps/android-tv/app-mobile/build.gradle.kts @@ -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") diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index 2720311..f9c212c 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -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) - positionMs = player.currentPosition.coerceAtLeast(0) - durationMs = player.duration.coerceAtLeast(0) + 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(), ) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 07504c1..a13ddc8 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 36 - versionCode = 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") } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt index 9a4db47..cb329f4 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt @@ -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(null) } - var loadedKey by rememberSaveable(catalogId) { mutableStateOf(null) } var continueMenu by remember { mutableStateOf(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 - } + load(reset = true, jumpLetter = null) + gridState.scrollToItem(0) } LaunchedEffect(gridState, items.size, total, listOffset) { @@ -185,57 +178,69 @@ 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) { + if (showBrowseTools) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + SortChip( + label = "A–Z", + selected = sort == "title", + onClick = { if (sort != "title") sort = "title" }, + ) + SortChip( + label = "Recent", + selected = sort == "recent", + onClick = { + if (sort != "recent") { + activeLetter = null + sort = "recent" + } + }, + ) + } + } else if (onBack != null) { McButton("Terug", onClick = onBack) } } - if (showBrowseTools) { - Spacer(Modifier.height(12.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - SortChip( - label = "A–Z", - selected = sort == "title", - onClick = { if (sort != "title") sort = "title" }, - ) - SortChip( - label = "Recent", - selected = sort == "recent", - onClick = { - if (sort != "recent") { - activeLetter = null - sort = "recent" - } - }, - ) - } - } - - 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, ) } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 37375c3..fc00c7d 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -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,54 +325,27 @@ 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)) { - MovieDetailScreen( - api = api, - movieId = id, - onBack = { nav.popBackStack() }, - onPlay = { mediaFileId, _, startMs -> - nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs") { - popUpTo("movie/$id") { inclusive = true } - } - }, - ) - } + 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") + }, + ) } - dialog( - route = "series/{id}", - arguments = listOf(navArgument("id") { type = NavType.StringType }), - dialogProperties = DialogProperties( - dismissOnBackPress = true, - dismissOnClickOutside = false, - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false, - ), - ) { entry -> - val id = entry.arguments?.getString("id") ?: return@dialog - Box(Modifier.fillMaxSize().background(McColors.Bg)) { - SeriesDetailScreen( - api = api, - seriesId = id, - onBack = { nav.popBackStack() }, - onPlay = { mediaFileId, episodeId, _ -> - nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") { - popUpTo("series/$id") { inclusive = true } - } - }, - ) - } + 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") + }, + ) } composable( "player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}", @@ -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(null) } val hubScope = rememberCoroutineScope() val activity = LocalContext.current as? Activity @@ -818,10 +790,11 @@ private fun LinkScreen( onLinked: () -> Unit, ) { var code by remember { mutableStateOf(null) } - var linkUrl by remember { mutableStateOf("") } + var qrPayload by remember { mutableStateOf("") } var status by remember { mutableStateOf("Verbinden…") } var error by remember { mutableStateOf(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,27 +841,65 @@ 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), ) { - 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) - if (serverLabel.isNotBlank()) { - Text(serverLabel, color = Color(0xFF9AB0D0), fontSize = 13.sp) + 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 = 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 = 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)) + McButton("Change server") { onChangeServer() } } - 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(status, color = McColors.TextDim, fontSize = 14.sp) - if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp) - Spacer(Modifier.height(4.dp)) - McButton("Change server") { onChangeServer() } } } } @@ -910,12 +923,14 @@ private fun HomeScreen( var loading by remember { mutableStateOf(true) } var continueMenu by remember { mutableStateOf(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) }, diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index e08c80e..aa62baa 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -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) - positionMs = player.currentPosition.coerceAtLeast(0) - durationMs = player.duration.coerceAtLeast(0) + 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(), ) diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/QrBitmap.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/QrBitmap.kt new file mode 100644 index 0000000..85fb8e4 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/QrBitmap.kt @@ -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) } +} diff --git a/apps/android-tv/gradle.properties b/apps/android-tv/gradle.properties index 071f00d..03b8819 100644 --- a/apps/android-tv/gradle.properties +++ b/apps/android-tv/gradle.properties @@ -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 diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index e87c3fd..a575971 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -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 = ""; }; 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvScreen.swift; sourceTree = ""; }; 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; + C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesScreen.swift; sourceTree = ""; }; D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = ""; }; 6FA95D6A03BF48EA97553486 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A22966A1CCBE44999F75FCA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -167,6 +169,7 @@ D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */, 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */, 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */, + C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */, ); path = UI; sourceTree = ""; @@ -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)"; diff --git a/apps/ios/VMC/Data/ApiClient.swift b/apps/ios/VMC/Data/ApiClient.swift index cff0b1d..71d5124 100644 --- a/apps/ios/VMC/Data/ApiClient.swift +++ b/apps/ios/VMC/Data/ApiClient.swift @@ -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 diff --git a/apps/ios/VMC/Data/AppModel.swift b/apps/ios/VMC/Data/AppModel.swift index f3f72d6..a298c05 100644 --- a/apps/ios/VMC/Data/AppModel.swift +++ b/apps/ios/VMC/Data/AppModel.swift @@ -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 } diff --git a/apps/ios/VMC/Data/Models.swift b/apps/ios/VMC/Data/Models.swift index 0d7eecf..09ac50b 100644 --- a/apps/ios/VMC/Data/Models.swift +++ b/apps/ios/VMC/Data/Models.swift @@ -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] } diff --git a/apps/ios/VMC/Info.plist b/apps/ios/VMC/Info.plist index c40d399..4cd50a4 100644 --- a/apps/ios/VMC/Info.plist +++ b/apps/ios/VMC/Info.plist @@ -20,6 +20,17 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + CFBundleURLTypes + + + CFBundleURLName + nl.vonas.mediacluster.ios.link + CFBundleURLSchemes + + vmc + + + LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/apps/ios/VMC/UI/BrowseScreen.swift b/apps/ios/VMC/UI/BrowseScreen.swift index 999e2c1..d86bede 100644 --- a/apps/ios/VMC/UI/BrowseScreen.swift +++ b/apps/ios/VMC/UI/BrowseScreen.swift @@ -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 catalogChips: [(label: String, id: String)] { + var list: [(String, String)] = [] + var seen = Set() + func append(_ label: String, _ id: String) { + guard !seen.contains(id) else { return } + seen.insert(id) + list.append((label, id)) } - } - - private var chips: [(label: String, mode: BrowseMode)] { - var list: [(String, BrowseMode)] = [("Planken", .planks)] 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) } + } + + 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 { + sort = .title + } + 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) + } + } + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 8) + } + .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 content: some View { - if loading && home == nil && items.isEmpty { + private var gridContent: some View { + if loading && items.isEmpty { ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error, home == nil && items.isEmpty { + } else if let error, items.isEmpty { VStack(spacing: 12) { - Text(error).foregroundStyle(McColors.danger) - Button("Opnieuw") { Task { await bootstrap() } }.buttonStyle(McPrimaryButtonStyle()) + 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 { - 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) - } + 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(.bottom, 24) } - case .catalog: - ScrollView { - LazyVGrid(columns: cols, spacing: 14) { - ForEach(items) { item in - NavigationLink(value: item) { - PosterCard(item: item, width: 104) - } - .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(.horizontal, 14) + .padding(.bottom, 28) + if loadingMore { + ProgressView().tint(.white).padding(.bottom, 16) } - .navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) } } } } + 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 { - loading = true - error = 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 - } + shelfPrefs = (try? await app.api.getShelfPrefs())?.sorted(by: { $0.sortOrder < $1.sortOrder }) ?? [] + await reload(reset: true) } - 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 { + private func reload(reset: Bool) async { if reset { - items = [] loading = true + error = nil } + let id = catalogId.isEmpty ? defaultCatalog : catalogId + let sortParam: String? = showSortTools ? sort.rawValue : nil 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 diff --git a/apps/ios/VMC/UI/Components.swift b/apps/ios/VMC/UI/Components.swift index 665290b..a2919df 100644 --- a/apps/ios/VMC/UI/Components.swift +++ b/apps/ios/VMC/UI/Components.swift @@ -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,14 +87,17 @@ struct PosterCard: View { .padding(4) } } - Text(item.title) - .font(McFonts.caption) - .foregroundStyle(McColors.text) - .lineLimit(2) - .multilineTextAlignment(.leading) - .frame(width: width, height: 36, alignment: .topLeading) + if showTitle { + Text(item.title) + .font(McFonts.caption) + .foregroundStyle(McColors.text) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(width: width, height: 36, alignment: .topLeading) + } } .frame(width: width, alignment: .leading) + .accessibilityLabel(item.title) } } diff --git a/apps/ios/VMC/UI/DevicesScreen.swift b/apps/ios/VMC/UI/DevicesScreen.swift new file mode 100644 index 0000000..eddc7ae --- /dev/null +++ b/apps/ios/VMC/UI/DevicesScreen.swift @@ -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 + } + } +} diff --git a/apps/ios/VMC/UI/RootView.swift b/apps/ios/VMC/UI/RootView.swift index 77167cc..5804ce0 100644 --- a/apps/ios/VMC/UI/RootView.swift +++ b/apps/ios/VMC/UI/RootView.swift @@ -24,7 +24,29 @@ 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 { + 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 { diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index ccf9eb8..9cd9592 100644 --- a/apps/ios/VMC/UI/SettingsScreen.swift +++ b/apps/ios/VMC/UI/SettingsScreen.swift @@ -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) diff --git a/apps/ios/VMC/VMCApp.swift b/apps/ios/VMC/VMCApp.swift index e3ae844..fa24adf 100644 --- a/apps/ios/VMC/VMCApp.swift +++ b/apps/ios/VMC/VMCApp.swift @@ -9,6 +9,9 @@ struct VMCApp: App { RootView() .environmentObject(appModel) .preferredColorScheme(.dark) + .onOpenURL { url in + appModel.handleOpenURL(url) + } } } } diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 25598c2..2572cea 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -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", diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index ad9bf01..e35b03d 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -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, }; }