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:
parent
0b1a2daa20
commit
6ffeb50733
21 changed files with 1147 additions and 323 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -65,9 +65,10 @@ import androidx.media3.ui.PlayerView
|
|||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.data.SubtitleHit
|
||||
import nl.vonas.mediacluster.tv.playback.PlayerTrack
|
||||
|
|
@ -269,46 +270,52 @@ fun PlayerScreen(
|
|||
}
|
||||
player.addListener(listener)
|
||||
onDispose {
|
||||
try {
|
||||
runBlocking {
|
||||
val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
|
||||
val dur = runCatching { player.duration.coerceAtLeast(0) }.getOrDefault(0L)
|
||||
runCatching { player.pause() }
|
||||
player.removeListener(listener)
|
||||
runCatching { player.release() }
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||
runCatching {
|
||||
api.postProgress(
|
||||
mediaType = mediaType,
|
||||
movieId = movieId,
|
||||
episodeId = episodeId,
|
||||
seriesId = seriesId,
|
||||
positionMs = player.currentPosition.coerceAtLeast(0),
|
||||
durationMs = player.duration.coerceAtLeast(0),
|
||||
positionMs = pos,
|
||||
durationMs = dur,
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
player.removeListener(listener)
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(player) {
|
||||
while (true) {
|
||||
while (isActive) {
|
||||
delay(400)
|
||||
runCatching {
|
||||
positionMs = player.currentPosition.coerceAtLeast(0)
|
||||
durationMs = player.duration.coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(player) {
|
||||
while (true) {
|
||||
while (isActive) {
|
||||
delay(12_000)
|
||||
if (player.isPlaying) {
|
||||
try {
|
||||
val playing = runCatching { player.isPlaying }.getOrDefault(false)
|
||||
if (playing) {
|
||||
val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
|
||||
val dur = runCatching { player.duration.coerceAtLeast(0) }.getOrDefault(0L)
|
||||
runCatching {
|
||||
api.postProgress(
|
||||
mediaType = mediaType,
|
||||
movieId = movieId,
|
||||
episodeId = episodeId,
|
||||
seriesId = seriesId,
|
||||
positionMs = player.currentPosition.coerceAtLeast(0),
|
||||
durationMs = player.duration.coerceAtLeast(0),
|
||||
positionMs = pos,
|
||||
durationMs = dur,
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -393,6 +400,7 @@ fun PlayerScreen(
|
|||
}
|
||||
},
|
||||
update = { it.player = player },
|
||||
onRelease = { it.player = null },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -30,12 +30,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -58,7 +56,7 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
|
|||
|
||||
private val AzLetters = listOf("#") + ('A'..'Z').map { it.toString() }
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun BrowseScreen(
|
||||
api: ApiClient,
|
||||
|
|
@ -91,18 +89,16 @@ fun BrowseScreen(
|
|||
var loadingMore by remember { mutableStateOf(false) }
|
||||
var sort by rememberSaveable(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) }
|
||||
var activeLetter by rememberSaveable(catalogId) { mutableStateOf<String?>(null) }
|
||||
var loadedKey by rememberSaveable(catalogId) { mutableStateOf<String?>(null) }
|
||||
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
|
||||
var menuReady by remember { mutableStateOf(false) }
|
||||
val menuFocus = remember { FocusRequester() }
|
||||
val gridState = rememberSaveable(catalogId, sort, saver = LazyGridState.Saver) { LazyGridState() }
|
||||
val gridState = rememberLazyGridState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val showBrowseTools = !isContinue
|
||||
|
||||
suspend fun load(reset: Boolean, jumpLetter: String? = null) {
|
||||
if (reset) {
|
||||
// Bestaande items houden het grid in compositie → focus blijft bij terugkeer
|
||||
if (items.isEmpty()) loading = true
|
||||
loading = true
|
||||
error = null
|
||||
} else {
|
||||
loadingMore = true
|
||||
|
|
@ -137,11 +133,8 @@ fun BrowseScreen(
|
|||
}
|
||||
|
||||
LaunchedEffect(catalogId, sort) {
|
||||
val key = "$catalogId|$sort"
|
||||
if (loadedKey != key || items.isEmpty()) {
|
||||
load(reset = true, jumpLetter = null)
|
||||
loadedKey = key
|
||||
}
|
||||
gridState.scrollToItem(0)
|
||||
}
|
||||
|
||||
LaunchedEffect(gridState, items.size, total, listOffset) {
|
||||
|
|
@ -185,35 +178,43 @@ fun BrowseScreen(
|
|||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||
val scrubberPad = if (showBrowseTools && sort == "title") 40.dp else 28.dp
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 36.dp, end = if (showBrowseTools && sort == "title") 52.dp else 36.dp, top = 20.dp, bottom = 20.dp),
|
||||
.padding(start = 28.dp, end = scrubberPad, top = 10.dp, bottom = 12.dp),
|
||||
) {
|
||||
// Eén balk: titel + telling links, sort rechts — leesbaar van de bank, weinig hoogte
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
||||
Row(
|
||||
Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
color = Color.White,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
when {
|
||||
total > 0 -> "$total titels"
|
||||
loading -> "Laden…"
|
||||
else -> "Geen titels"
|
||||
total > 0 -> "$total"
|
||||
loading -> "…"
|
||||
else -> "0"
|
||||
},
|
||||
color = McColors.TextDim,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (onBack != null) {
|
||||
McButton("Terug", onClick = onBack)
|
||||
}
|
||||
}
|
||||
|
||||
if (showBrowseTools) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SortChip(
|
||||
label = "A–Z",
|
||||
|
|
@ -231,11 +232,15 @@ fun BrowseScreen(
|
|||
},
|
||||
)
|
||||
}
|
||||
} else if (onBack != null) {
|
||||
McButton("Terug", onClick = onBack)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.height(10.dp))
|
||||
if (error != null) {
|
||||
Text(error!!, color = McColors.Danger)
|
||||
Text(error!!, color = McColors.Danger, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
when {
|
||||
loading && items.isEmpty() -> {
|
||||
|
|
@ -251,21 +256,20 @@ fun BrowseScreen(
|
|||
}
|
||||
else -> {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 140.dp),
|
||||
// ~124dp: dichter dan 140 (meer rijen), nog herkenbaar van de bank
|
||||
columns = GridCells.Adaptive(minSize = 124.dp),
|
||||
state = gridState,
|
||||
contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.focusRestorer(),
|
||||
contentPadding = PaddingValues(start = 4.dp, top = 4.dp, end = 4.dp, bottom = 28.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
||||
PosterCard(
|
||||
item = item,
|
||||
fillWidth = true,
|
||||
showCaption = isContinue,
|
||||
modifier = Modifier.padding(6.dp),
|
||||
modifier = Modifier.padding(3.dp),
|
||||
onLongClick = if (isContinue) {
|
||||
{ continueMenu = item }
|
||||
} else {
|
||||
|
|
@ -305,11 +309,10 @@ fun BrowseScreen(
|
|||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.padding(vertical = 16.dp, horizontal = 4.dp),
|
||||
.padding(vertical = 8.dp, horizontal = 2.dp),
|
||||
onLetter = { letter ->
|
||||
scope.launch {
|
||||
load(reset = true, jumpLetter = letter)
|
||||
loadedKey = "$catalogId|$sort"
|
||||
gridState.scrollToItem(0)
|
||||
}
|
||||
},
|
||||
|
|
@ -379,7 +382,7 @@ private fun SortChip(
|
|||
Surface(
|
||||
onClick = onClick,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (selected) Color.White else Color(0x33FFFFFF),
|
||||
focusedContainerColor = Color.White,
|
||||
|
|
@ -387,9 +390,9 @@ private fun SortChip(
|
|||
) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
color = if (selected) Color.Black else Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
|
|
@ -403,7 +406,7 @@ private fun AzScrubber(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.width(30.dp),
|
||||
modifier = modifier.width(34.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AzLetters.forEach { letter ->
|
||||
|
|
@ -414,7 +417,7 @@ private fun AzScrubber(
|
|||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(3.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.35f),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.4f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
focusedContainerColor = Color(0x55FFFFFF),
|
||||
|
|
@ -426,10 +429,10 @@ private fun AzScrubber(
|
|||
) {
|
||||
Text(
|
||||
letter,
|
||||
color = if (isSelected) McColors.Brand else Color(0xCCFFFFFF),
|
||||
fontSize = 10.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
||||
lineHeight = 10.sp,
|
||||
color = if (isSelected) McColors.Brand else Color(0xE6FFFFFF),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
|
||||
lineHeight = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.compose.animation.fadeIn
|
|||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -27,7 +28,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
|
@ -45,13 +45,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
|
|
@ -69,10 +67,11 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.dialog
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
|
|
@ -326,55 +325,28 @@ fun McNav() {
|
|||
},
|
||||
)
|
||||
}
|
||||
// dialog i.p.v. composable: Home/Browse blijven gecomposeerd → scroll+focus behouden
|
||||
dialog(
|
||||
route = "movie/{id}",
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType }),
|
||||
dialogProperties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) { entry ->
|
||||
val id = entry.arguments?.getString("id") ?: return@dialog
|
||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
||||
val id = entry.arguments?.getString("id") ?: return@composable
|
||||
MovieDetailScreen(
|
||||
api = api,
|
||||
movieId = id,
|
||||
onBack = { nav.popBackStack() },
|
||||
onPlay = { mediaFileId, _, startMs ->
|
||||
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs") {
|
||||
popUpTo("movie/$id") { inclusive = true }
|
||||
}
|
||||
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
dialog(
|
||||
route = "series/{id}",
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType }),
|
||||
dialogProperties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) { entry ->
|
||||
val id = entry.arguments?.getString("id") ?: return@dialog
|
||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||
composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
||||
val id = entry.arguments?.getString("id") ?: return@composable
|
||||
SeriesDetailScreen(
|
||||
api = api,
|
||||
seriesId = id,
|
||||
onBack = { nav.popBackStack() },
|
||||
onPlay = { mediaFileId, episodeId, _ ->
|
||||
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") {
|
||||
popUpTo("series/$id") { inclusive = true }
|
||||
}
|
||||
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(
|
||||
"player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}",
|
||||
arguments = listOf(
|
||||
|
|
@ -450,7 +422,7 @@ private fun MainHub(
|
|||
val homeContentFocus = remember { FocusRequester() }
|
||||
val exitFocus = remember { FocusRequester() }
|
||||
var exitReady by remember { mutableStateOf(false) }
|
||||
val homeListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() }
|
||||
val homeListState = rememberLazyListState()
|
||||
var prevTabName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val hubScope = rememberCoroutineScope()
|
||||
val activity = LocalContext.current as? Activity
|
||||
|
|
@ -818,10 +790,11 @@ private fun LinkScreen(
|
|||
onLinked: () -> Unit,
|
||||
) {
|
||||
var code by remember { mutableStateOf<String?>(null) }
|
||||
var linkUrl by remember { mutableStateOf("") }
|
||||
var qrPayload by remember { mutableStateOf("") }
|
||||
var status by remember { mutableStateOf("Verbinden…") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var serverLabel by remember { mutableStateOf("") }
|
||||
val qrImage = rememberQrImage(qrPayload)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val base = store.apiBase.first()
|
||||
|
|
@ -831,8 +804,10 @@ private fun LinkScreen(
|
|||
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" }
|
||||
val created = api.createDeviceCode(deviceName)
|
||||
code = created.code
|
||||
linkUrl = created.linkUrl
|
||||
status = "Wachten tot je de code bevestigt…"
|
||||
qrPayload = created.qrPayload.ifBlank {
|
||||
"vmc://link?code=${created.code}"
|
||||
}
|
||||
status = "Wachten op goedkeuring in de VMC-app…"
|
||||
while (true) {
|
||||
delay(created.pollIntervalSeconds.coerceAtLeast(2) * 1000L)
|
||||
when (val poll = api.pollDeviceCode(created.code)) {
|
||||
|
|
@ -866,29 +841,67 @@ private fun LinkScreen(
|
|||
.background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(36.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.width(560.dp)
|
||||
.width(820.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(McColors.BgElevated.copy(alpha = 0.92f))
|
||||
.padding(40.dp),
|
||||
.padding(36.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.width(280.dp),
|
||||
) {
|
||||
if (qrImage != null) {
|
||||
Image(
|
||||
bitmap = qrImage,
|
||||
contentDescription = "QR-code om te scannen met de VMC-app",
|
||||
modifier = Modifier
|
||||
.size(240.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.White)
|
||||
.padding(10.dp),
|
||||
filterQuality = FilterQuality.None,
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.size(240.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.White.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("QR…", color = McColors.TextDim, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
Text("Scan met de VMC-app", color = McColors.TextDim, fontSize = 14.sp, textAlign = TextAlign.Center)
|
||||
}
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp)
|
||||
Text("Koppel dit apparaat", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
||||
Text("Open de link op je telefoon, log in en voer de code in.", color = McColors.TextDim, fontSize = 15.sp)
|
||||
Text("Koppel dit apparaat", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Open de VMC-app op je telefoon, scan de QR of voer de code in onder Account → Apparaten.",
|
||||
color = McColors.TextDim,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
if (serverLabel.isNotBlank()) {
|
||||
Text(serverLabel, color = Color(0xFF9AB0D0), fontSize = 13.sp)
|
||||
}
|
||||
Text(code ?: "····-····", color = McColors.Accent, fontSize = 52.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
|
||||
if (linkUrl.isNotBlank()) Text(linkUrl, color = Color.White, fontSize = 18.sp)
|
||||
Text(code ?: "····-····", color = McColors.Accent, fontSize = 44.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
|
||||
Text(status, color = McColors.TextDim, fontSize = 14.sp)
|
||||
if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
McButton("Change server") { onChangeServer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
|
|
@ -910,12 +923,14 @@ private fun HomeScreen(
|
|||
var loading by remember { mutableStateOf(true) }
|
||||
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
|
||||
var menuReady by remember { mutableStateOf(false) }
|
||||
var didInitialHomeFocus by rememberSaveable { mutableStateOf(false) }
|
||||
var shelfFocusReady by remember { mutableStateOf(false) }
|
||||
var playFocusReady by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val menuFocus = remember { FocusRequester() }
|
||||
val context = LocalContext.current
|
||||
val store = remember { SessionStore(context) }
|
||||
val density = LocalDensity.current
|
||||
val firstShelfFocus = remember { FocusRequester() }
|
||||
|
||||
suspend fun reloadHome() {
|
||||
val fresh = api.home()
|
||||
|
|
@ -952,13 +967,15 @@ private fun HomeScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Alleen eerste keer focus op Afspelen — niet bij terugkeer uit film/serie
|
||||
LaunchedEffect(home) {
|
||||
if (home == null || didInitialHomeFocus) return@LaunchedEffect
|
||||
delay(120)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
// Bij elke verschijning van Home (ook na serie/film/player): focus op content, niet op top-nav.
|
||||
LaunchedEffect(Unit) {
|
||||
onContentFocused()
|
||||
didInitialHomeFocus = true
|
||||
val deadline = System.currentTimeMillis() + 900
|
||||
while (!playFocusReady && System.currentTimeMillis() < deadline) {
|
||||
delay(40)
|
||||
}
|
||||
delay(40)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
}
|
||||
|
||||
val spotlight = home?.spotlight.orEmpty().ifEmpty {
|
||||
|
|
@ -980,7 +997,14 @@ private fun HomeScreen(
|
|||
shelfStartPx
|
||||
}
|
||||
|
||||
// Als scroll stilvalt dicht bij de top met rest-offset: snap naar 0
|
||||
fun expandHeroFully() {
|
||||
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
|
||||
scope.launch { listState.animateScrollToItem(0) }
|
||||
}
|
||||
}
|
||||
|
||||
// Als scroll stilvalt dicht bij de top met rest-offset: snap hero open
|
||||
// (niet agressief over hele spacer — dat vecht met omlaag naar de eerste plank)
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow {
|
||||
Triple(
|
||||
|
|
@ -989,8 +1013,8 @@ private fun HomeScreen(
|
|||
listState.isScrollInProgress,
|
||||
)
|
||||
}.collect { (index, offset, scrolling) ->
|
||||
if (!scrolling && index == 0 && offset in 1..160) {
|
||||
listState.scrollToItem(0)
|
||||
if (!scrolling && index == 0 && offset in 1..240) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1021,9 +1045,18 @@ private fun HomeScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val firstShelfFocus = remember { FocusRequester() }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// Altijd een geldig focus-doel voor TopNav "down", ook vóór Afspelen gecomposeerd is.
|
||||
if (!playFocusReady) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(1.dp)
|
||||
.align(Alignment.TopStart)
|
||||
.focusRequester(contentFocusRequester)
|
||||
.focusable()
|
||||
.onFocusChanged { if (it.isFocused) onContentFocused() },
|
||||
)
|
||||
}
|
||||
if (loading && home == null) {
|
||||
HomeSkeleton()
|
||||
} else {
|
||||
|
|
@ -1043,16 +1076,15 @@ private fun HomeScreen(
|
|||
index = spotlightIndex,
|
||||
playFocusRequester = contentFocusRequester,
|
||||
navFocusRequester = navFocusRequester,
|
||||
shelvesFocusRequester = firstShelfFocus,
|
||||
// Alleen linken als de eerste plank écht in de tree hangt — anders crasht D-pad
|
||||
// met "FocusRequester is not initialized" (Shield logcat 01:36 / 01:52).
|
||||
shelvesFocusRequester = firstShelfFocus.takeIf { shelfFocusReady },
|
||||
onPlayFocusReady = { playFocusReady = it },
|
||||
onContentFocused = {
|
||||
onContentFocused()
|
||||
// Alleen terug naar top als we al bovenaan zitten met rest-offset;
|
||||
// niet forceren bij focus-events na terugkeer uit detail.
|
||||
if (listState.firstVisibleItemIndex == 0 &&
|
||||
listState.firstVisibleItemScrollOffset in 1..160
|
||||
) {
|
||||
scope.launch { listState.scrollToItem(0) }
|
||||
}
|
||||
// Focus op hero/Afspelen → LazyColumn altijd volledig naar top
|
||||
// (anders blijft hero deels onder het menu hangen tot Back).
|
||||
expandHeroFully()
|
||||
},
|
||||
onPlay = { item ->
|
||||
onContentFocused()
|
||||
|
|
@ -1097,8 +1129,12 @@ private fun HomeScreen(
|
|||
items = preview,
|
||||
showCaptions = true,
|
||||
firstItemFocusRequester = firstShelfFocus,
|
||||
focusUpTo = contentFocusRequester,
|
||||
onRowFocused = onContentFocused,
|
||||
focusUpTo = contentFocusRequester.takeIf { playFocusReady },
|
||||
onFirstFocusTargetReady = { shelfFocusReady = it },
|
||||
onRowFocused = {
|
||||
onContentFocused()
|
||||
expandHeroFully()
|
||||
},
|
||||
onSeeAll = if (continueItems.size > 5) {
|
||||
{ onSeeAll("continue-watching", "Verder kijken") }
|
||||
} else {
|
||||
|
|
@ -1139,8 +1175,16 @@ private fun HomeScreen(
|
|||
title = shelf.title,
|
||||
items = shelf.items,
|
||||
firstItemFocusRequester = if (isFirstShelf) firstShelfFocus else null,
|
||||
focusUpTo = if (isFirstShelf) contentFocusRequester else null,
|
||||
onRowFocused = onContentFocused,
|
||||
focusUpTo = if (isFirstShelf) contentFocusRequester.takeIf { playFocusReady } else null,
|
||||
onFirstFocusTargetReady = if (isFirstShelf) {
|
||||
{ shelfFocusReady = it }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onRowFocused = {
|
||||
onContentFocused()
|
||||
if (isFirstShelf) expandHeroFully()
|
||||
},
|
||||
onSeeAll = { onSeeAll(catalogId, shelf.browseTitle) },
|
||||
onClick = { item ->
|
||||
onContentFocused()
|
||||
|
|
@ -1243,6 +1287,7 @@ private fun SpotlightHero(
|
|||
playFocusRequester: FocusRequester? = null,
|
||||
navFocusRequester: FocusRequester? = null,
|
||||
shelvesFocusRequester: FocusRequester? = null,
|
||||
onPlayFocusReady: (Boolean) -> Unit = {},
|
||||
onContentFocused: () -> Unit = {},
|
||||
) {
|
||||
val safeItems = items.ifEmpty { listOf(MediaItem(type = "movie", id = "", title = "Welkom bij VMC")) }
|
||||
|
|
@ -1339,6 +1384,10 @@ private fun SpotlightHero(
|
|||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (current.id.isNotBlank()) {
|
||||
DisposableEffect(playFocusRequester) {
|
||||
onPlayFocusReady(playFocusRequester != null)
|
||||
onDispose { onPlayFocusReady(false) }
|
||||
}
|
||||
McButton(
|
||||
"Afspelen",
|
||||
primary = true,
|
||||
|
|
@ -1374,7 +1423,6 @@ private fun SpotlightHero(
|
|||
}
|
||||
}
|
||||
}
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun ShelfBlock(
|
||||
title: String,
|
||||
|
|
@ -1386,6 +1434,7 @@ private fun ShelfBlock(
|
|||
onRowFocused: () -> Unit = {},
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
focusUpTo: FocusRequester? = null,
|
||||
onFirstFocusTargetReady: ((Boolean) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var rowFocused by remember { mutableStateOf(false) }
|
||||
|
|
@ -1435,12 +1484,17 @@ private fun ShelfBlock(
|
|||
}
|
||||
}
|
||||
LazyRow(
|
||||
modifier = Modifier.focusRestorer(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
// Extra ruimte zodat focusedScale (1.10) posters niet afknipt
|
||||
contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 28.dp, bottom = 28.dp),
|
||||
) {
|
||||
itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item ->
|
||||
if (index == 0 && firstItemFocusRequester != null) {
|
||||
DisposableEffect(firstItemFocusRequester) {
|
||||
onFirstFocusTargetReady?.invoke(true)
|
||||
onDispose { onFirstFocusTargetReady?.invoke(false) }
|
||||
}
|
||||
}
|
||||
PosterCard(
|
||||
item = item,
|
||||
onClick = { onClick(item) },
|
||||
|
|
|
|||
|
|
@ -60,9 +60,10 @@ import androidx.tv.material3.ClickableSurfaceDefaults
|
|||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.data.SubtitleHit
|
||||
import nl.vonas.mediacluster.tv.playback.PlayerTrack
|
||||
|
|
@ -266,46 +267,53 @@ fun PlayerScreen(
|
|||
}
|
||||
player.addListener(listener)
|
||||
onDispose {
|
||||
try {
|
||||
runBlocking {
|
||||
val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
|
||||
val dur = runCatching { player.duration.coerceAtLeast(0) }.getOrDefault(0L)
|
||||
runCatching { player.pause() }
|
||||
player.removeListener(listener)
|
||||
runCatching { player.release() }
|
||||
// Progress buiten de UI-thread / composition-scope (die hier gecanceld wordt)
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||
runCatching {
|
||||
api.postProgress(
|
||||
mediaType = mediaType,
|
||||
movieId = movieId,
|
||||
episodeId = episodeId,
|
||||
seriesId = seriesId,
|
||||
positionMs = player.currentPosition.coerceAtLeast(0),
|
||||
durationMs = player.duration.coerceAtLeast(0),
|
||||
positionMs = pos,
|
||||
durationMs = dur,
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
player.removeListener(listener)
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(player) {
|
||||
while (true) {
|
||||
while (isActive) {
|
||||
delay(400)
|
||||
runCatching {
|
||||
positionMs = player.currentPosition.coerceAtLeast(0)
|
||||
durationMs = player.duration.coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(player) {
|
||||
while (true) {
|
||||
while (isActive) {
|
||||
delay(12_000)
|
||||
if (player.isPlaying) {
|
||||
try {
|
||||
val playing = runCatching { player.isPlaying }.getOrDefault(false)
|
||||
if (playing) {
|
||||
val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
|
||||
val dur = runCatching { player.duration.coerceAtLeast(0) }.getOrDefault(0L)
|
||||
runCatching {
|
||||
api.postProgress(
|
||||
mediaType = mediaType,
|
||||
movieId = movieId,
|
||||
episodeId = episodeId,
|
||||
seriesId = seriesId,
|
||||
positionMs = player.currentPosition.coerceAtLeast(0),
|
||||
durationMs = player.duration.coerceAtLeast(0),
|
||||
positionMs = pos,
|
||||
durationMs = dur,
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -420,6 +428,7 @@ fun PlayerScreen(
|
|||
}
|
||||
},
|
||||
update = { it.player = player },
|
||||
onRelease = { it.player = null },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
668CADCCEA404B50BB19A46E /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A435950EF040DE83F30779 /* DetailScreens.swift */; };
|
||||
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */; };
|
||||
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */; };
|
||||
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */; };
|
||||
B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */; };
|
||||
468E05CB91FC4A1997A2F386 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6FA95D6A03BF48EA97553486 /* Assets.xcassets */; };
|
||||
DC8D7BF328134E1E8D7E3D76 /* MobileVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */; };
|
||||
|
|
@ -57,6 +58,7 @@
|
|||
F0A435950EF040DE83F30779 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = "<group>"; };
|
||||
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvScreen.swift; sourceTree = "<group>"; };
|
||||
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = "<group>"; };
|
||||
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesScreen.swift; sourceTree = "<group>"; };
|
||||
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = "<group>"; };
|
||||
6FA95D6A03BF48EA97553486 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
A22966A1CCBE44999F75FCA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
|
|
@ -167,6 +169,7 @@
|
|||
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */,
|
||||
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */,
|
||||
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */,
|
||||
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */,
|
||||
);
|
||||
path = UI;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -265,6 +268,7 @@
|
|||
B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */,
|
||||
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */,
|
||||
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */,
|
||||
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -313,7 +317,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
|
@ -328,7 +332,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks"
|
||||
);
|
||||
MARKETING_VERSION = 0.4.4;
|
||||
MARKETING_VERSION = 0.4.6;
|
||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -346,7 +350,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
|
@ -361,7 +365,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks"
|
||||
);
|
||||
MARKETING_VERSION = 0.4.4;
|
||||
MARKETING_VERSION = 0.4.6;
|
||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,17 @@
|
|||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>nl.vonas.mediacluster.ios.link</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>vmc</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
|
|
|||
|
|
@ -1,77 +1,89 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Films / Series-bibliotheek — grid zoals Android Browse, met iOS-chips + letter-index.
|
||||
struct BrowseScreen: View {
|
||||
@EnvironmentObject private var app: AppModel
|
||||
let kind: String // "MOVIE" or "SERIES"
|
||||
let title: String
|
||||
|
||||
@State private var home: HomePayload?
|
||||
@State private var shelfPrefs: [ShelfPref] = []
|
||||
@State private var mode: BrowseMode = .planks
|
||||
@State private var catalogId: String = ""
|
||||
@State private var sort: SortMode = .title
|
||||
@State private var activeLetter: String?
|
||||
@State private var pageTitle: String = ""
|
||||
@State private var items: [MediaItem] = []
|
||||
@State private var total = 0
|
||||
@State private var listOffset = 0
|
||||
@State private var error: String?
|
||||
@State private var loading = true
|
||||
@State private var loadingMore = false
|
||||
|
||||
private enum BrowseMode: Hashable {
|
||||
case planks
|
||||
case catalog(String)
|
||||
private enum SortMode: String {
|
||||
case title
|
||||
case recent
|
||||
}
|
||||
|
||||
private var kindShelves: [ShelfRow] {
|
||||
guard let home else { return [] }
|
||||
return home.shelves.filter { shelf in
|
||||
let k = (shelf.kind ?? "").uppercased()
|
||||
if kind == "MOVIE" { return k == "MOVIE" || k.contains("MOVIE") }
|
||||
return k == "SERIES" || k.contains("SERIES")
|
||||
}
|
||||
private var defaultCatalog: String {
|
||||
kind == "MOVIE" ? "movies" : "series"
|
||||
}
|
||||
|
||||
private var chips: [(label: String, mode: BrowseMode)] {
|
||||
var list: [(String, BrowseMode)] = [("Planken", .planks)]
|
||||
private var catalogChips: [(label: String, id: String)] {
|
||||
var list: [(String, String)] = []
|
||||
var seen = Set<String>()
|
||||
func append(_ label: String, _ id: String) {
|
||||
guard !seen.contains(id) else { return }
|
||||
seen.insert(id)
|
||||
list.append((label, id))
|
||||
}
|
||||
if kind == "MOVIE" {
|
||||
list += [("Alles", .catalog("movies")), ("Recent", .catalog("recent-movies")), ("4K", .catalog("4k-movies"))]
|
||||
append("Alles", "movies")
|
||||
append("Recent", "recent-movies")
|
||||
append("4K", "4k-movies")
|
||||
} else {
|
||||
list += [("Alles", .catalog("series")), ("Recent", .catalog("recent-series"))]
|
||||
append("Alles", "series")
|
||||
append("Recent", "recent-series")
|
||||
}
|
||||
for pref in shelfPrefs where pref.visible {
|
||||
let k = (pref.kind ?? "").uppercased()
|
||||
let match = kind == "MOVIE" ? (k == "MOVIE" || k.contains("MOVIE")) : (k == "SERIES" || k.contains("SERIES"))
|
||||
let match = kind == "MOVIE"
|
||||
? (k == "MOVIE" || k.contains("MOVIE"))
|
||||
: (k == "SERIES" || k.contains("SERIES"))
|
||||
if match {
|
||||
let slug = pref.slug?.isEmpty == false ? pref.slug! : pref.shelfId
|
||||
list.append((pref.name, .catalog(slug)))
|
||||
append(pref.name, slug)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private let cols = [GridItem(.adaptive(minimum: 104), spacing: 12)]
|
||||
/// A–Z tools alleen zinvol op titel-gebaseerde collecties (niet op “Recent”-catalogi).
|
||||
private var showSortTools: Bool {
|
||||
switch catalogId {
|
||||
case "recent-movies", "recent-series":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private var showLetterIndex: Bool {
|
||||
showSortTools && sort == .title && !items.isEmpty
|
||||
}
|
||||
|
||||
private let letters = ["#"] + (65...90).map { String(UnicodeScalar($0)!) }
|
||||
private let cols = [GridItem(.adaptive(minimum: 108), spacing: 10)]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(chips.enumerated()), id: \.offset) { _, chip in
|
||||
Button {
|
||||
mode = chip.mode
|
||||
Task { await activate(chip.mode) }
|
||||
} label: {
|
||||
Text(chip.label)
|
||||
.font(McFonts.caption)
|
||||
.foregroundStyle(mode == chip.mode ? .white : McColors.textDim)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(mode == chip.mode ? McColors.accent : McColors.surface, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
catalogBar
|
||||
headerBar
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
gridContent
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
if showLetterIndex {
|
||||
letterIndex
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
content
|
||||
}
|
||||
.background(McColors.bg.ignoresSafeArea())
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
|
@ -82,95 +94,197 @@ struct BrowseScreen: View {
|
|||
}
|
||||
.toolbarBackground(McColors.bg, for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.task { await bootstrap() }
|
||||
.refreshable { await bootstrap() }
|
||||
.task {
|
||||
if catalogId.isEmpty { catalogId = defaultCatalog }
|
||||
await bootstrap()
|
||||
}
|
||||
.refreshable { await reload(reset: true) }
|
||||
.navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if loading && home == nil && items.isEmpty {
|
||||
ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let error, home == nil && items.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Text(error).foregroundStyle(McColors.danger)
|
||||
Button("Opnieuw") { Task { await bootstrap() } }.buttonStyle(McPrimaryButtonStyle())
|
||||
}
|
||||
private var catalogBar: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(catalogChips, id: \.id) { chip in
|
||||
let selected = catalogId == chip.id
|
||||
Button {
|
||||
guard catalogId != chip.id else { return }
|
||||
catalogId = chip.id
|
||||
activeLetter = nil
|
||||
if chip.id == "recent-movies" || chip.id == "recent-series" || chip.id == "4k-movies" {
|
||||
sort = .recent
|
||||
} else {
|
||||
switch mode {
|
||||
case .planks:
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 22) {
|
||||
if kindShelves.isEmpty {
|
||||
ContentUnavailableView("Geen planken", systemImage: "rectangle.stack", description: Text("Nog geen \(title.lowercased())-planken zichtbaar."))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 40)
|
||||
} else {
|
||||
ForEach(kindShelves) { shelf in
|
||||
if !shelf.items.isEmpty {
|
||||
ShelfSection(title: shelf.title, items: shelf.items)
|
||||
sort = .title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
case .catalog:
|
||||
ScrollView {
|
||||
LazyVGrid(columns: cols, spacing: 14) {
|
||||
ForEach(items) { item in
|
||||
NavigationLink(value: item) {
|
||||
PosterCard(item: item, width: 104)
|
||||
Task { await reload(reset: true) }
|
||||
} label: {
|
||||
Text(chip.label)
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(selected ? .white : McColors.text)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
selected ? McColors.accent : McColors.surfaceSoft,
|
||||
in: Capsule()
|
||||
)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.strokeBorder(
|
||||
selected ? Color.clear : McColors.textDim.opacity(0.45),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onAppear {
|
||||
if item.id == items.last?.id { Task { await loadMore() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 24)
|
||||
if loadingMore { ProgressView().tint(.white).padding() }
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) }
|
||||
.frame(minHeight: 52)
|
||||
}
|
||||
|
||||
private var headerBar: some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(pageTitle.isEmpty ? title : pageTitle)
|
||||
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(McColors.text)
|
||||
.lineLimit(1)
|
||||
Text(total > 0 ? "\(total)" : (loading ? "…" : "0"))
|
||||
.font(.system(size: 16, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(McColors.textDim)
|
||||
Spacer(minLength: 8)
|
||||
if showSortTools {
|
||||
HStack(spacing: 0) {
|
||||
sortChip("A–Z", selected: sort == .title) {
|
||||
guard sort != .title else { return }
|
||||
sort = .title
|
||||
Task { await reload(reset: true) }
|
||||
}
|
||||
sortChip("Recent", selected: sort == .recent) {
|
||||
guard sort != .recent else { return }
|
||||
sort = .recent
|
||||
activeLetter = nil
|
||||
Task { await reload(reset: true) }
|
||||
}
|
||||
}
|
||||
.background(McColors.surface, in: Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func sortChip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(selected ? .black : McColors.text)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(selected ? Color.white : Color.clear, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gridContent: some View {
|
||||
if loading && items.isEmpty {
|
||||
ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let error, items.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center)
|
||||
Button("Opnieuw") { Task { await reload(reset: true) } }
|
||||
.buttonStyle(McPrimaryButtonStyle())
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if !loading && items.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Geen titels",
|
||||
systemImage: "square.stack.3d.up.slash",
|
||||
description: Text("Deze collectie is leeg of nog niet gesynchroniseerd.")
|
||||
)
|
||||
.foregroundStyle(McColors.textDim)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: cols, spacing: 12) {
|
||||
ForEach(items) { item in
|
||||
NavigationLink(value: item) {
|
||||
PosterCard(item: item, width: 108, showTitle: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onAppear {
|
||||
if item.id == items.last?.id {
|
||||
Task { await loadMore() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 28)
|
||||
if loadingMore {
|
||||
ProgressView().tint(.white).padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var letterIndex: some View {
|
||||
GeometryReader { geo in
|
||||
VStack(spacing: 0) {
|
||||
ForEach(letters, id: \.self) { letter in
|
||||
Text(letter)
|
||||
.font(.system(size: 11, weight: activeLetter == letter ? .bold : .semibold, design: .rounded))
|
||||
.foregroundStyle(activeLetter == letter ? McColors.accent : McColors.textDim)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(width: 22, height: geo.size.height)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
let h = max(geo.size.height, 1)
|
||||
let idx = Int((value.location.y / h) * CGFloat(letters.count))
|
||||
let clamped = min(max(idx, 0), letters.count - 1)
|
||||
let letter = letters[clamped]
|
||||
if activeLetter != letter {
|
||||
UISelectionFeedbackGenerator().selectionChanged()
|
||||
Task { await jump(to: letter) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(width: 22)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.trailing, 2)
|
||||
}
|
||||
|
||||
private func bootstrap() async {
|
||||
shelfPrefs = (try? await app.api.getShelfPrefs())?.sorted(by: { $0.sortOrder < $1.sortOrder }) ?? []
|
||||
await reload(reset: true)
|
||||
}
|
||||
|
||||
private func reload(reset: Bool) async {
|
||||
if reset {
|
||||
loading = true
|
||||
error = nil
|
||||
}
|
||||
let id = catalogId.isEmpty ? defaultCatalog : catalogId
|
||||
let sortParam: String? = showSortTools ? sort.rawValue : nil
|
||||
do {
|
||||
async let homeTask = app.api.home()
|
||||
async let prefsTask = app.api.getShelfPrefs()
|
||||
home = try await homeTask
|
||||
shelfPrefs = (try? await prefsTask)?.sorted(by: { $0.sortOrder < $1.sortOrder }) ?? []
|
||||
if case .catalog(let id) = mode {
|
||||
await loadCatalog(id, reset: true)
|
||||
}
|
||||
loading = false
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
private func activate(_ mode: BrowseMode) async {
|
||||
switch mode {
|
||||
case .planks:
|
||||
break
|
||||
case .catalog(let id):
|
||||
await loadCatalog(id, reset: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCatalog(_ id: String, reset: Bool) async {
|
||||
if reset {
|
||||
items = []
|
||||
loading = true
|
||||
}
|
||||
do {
|
||||
let page = try await app.api.browse(catalogId: id, skip: 0, take: 60)
|
||||
let page = try await app.api.browse(
|
||||
catalogId: id,
|
||||
skip: 0,
|
||||
take: 60,
|
||||
sort: sortParam,
|
||||
letter: sort == .title ? activeLetter : nil
|
||||
)
|
||||
pageTitle = page.title?.isEmpty == false ? page.title! : title
|
||||
total = page.total ?? page.items.count
|
||||
listOffset = page.offset ?? 0
|
||||
items = page.items
|
||||
loading = false
|
||||
} catch {
|
||||
|
|
@ -179,12 +293,27 @@ struct BrowseScreen: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func jump(to letter: String) async {
|
||||
activeLetter = letter
|
||||
sort = .title
|
||||
await reload(reset: true)
|
||||
}
|
||||
|
||||
private func loadMore() async {
|
||||
guard case .catalog(let id) = mode, !loadingMore, items.count < total else { return }
|
||||
guard !loadingMore, items.count + listOffset < total else { return }
|
||||
loadingMore = true
|
||||
let id = catalogId.isEmpty ? defaultCatalog : catalogId
|
||||
let sortParam: String? = showSortTools ? sort.rawValue : nil
|
||||
do {
|
||||
let page = try await app.api.browse(catalogId: id, skip: items.count, take: 60)
|
||||
items.append(contentsOf: page.items)
|
||||
let page = try await app.api.browse(
|
||||
catalogId: id,
|
||||
skip: listOffset + items.count,
|
||||
take: 60,
|
||||
sort: sortParam,
|
||||
letter: nil
|
||||
)
|
||||
let existing = Set(items.map(\.id))
|
||||
items.append(contentsOf: page.items.filter { !existing.contains($0.id) })
|
||||
loadingMore = false
|
||||
} catch {
|
||||
loadingMore = false
|
||||
|
|
|
|||
|
|
@ -56,9 +56,11 @@ struct TitleLogo: View {
|
|||
struct PosterCard: View {
|
||||
let item: MediaItem
|
||||
var width: CGFloat = 112
|
||||
/// Grid-bibliotheek: geen titel onder poster (zoals Android Browse).
|
||||
var showTitle: Bool = true
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: showTitle ? 6 : 0) {
|
||||
RemoteImage(url: item.posterUrl)
|
||||
.frame(width: width, height: width * 1.5)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
|
|
@ -85,6 +87,7 @@ struct PosterCard: View {
|
|||
.padding(4)
|
||||
}
|
||||
}
|
||||
if showTitle {
|
||||
Text(item.title)
|
||||
.font(McFonts.caption)
|
||||
.foregroundStyle(McColors.text)
|
||||
|
|
@ -92,7 +95,9 @@ struct PosterCard: View {
|
|||
.multilineTextAlignment(.leading)
|
||||
.frame(width: width, height: 36, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
.frame(width: width, alignment: .leading)
|
||||
.accessibilityLabel(item.title)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
324
apps/ios/VMC/UI/DevicesScreen.swift
Normal file
324
apps/ios/VMC/UI/DevicesScreen.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ struct VMCApp: App {
|
|||
RootView()
|
||||
.environmentObject(appModel)
|
||||
.preferredColorScheme(.dark)
|
||||
.onOpenURL { url in
|
||||
appModel.handleOpenURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue