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"
|
applicationId = "nl.vonas.mediacluster"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 12
|
versionCode = 13
|
||||||
versionName = "1.0.11"
|
versionName = "1.0.12"
|
||||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,10 @@ import androidx.media3.ui.PlayerView
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
import nl.vonas.mediacluster.tv.data.SubtitleHit
|
import nl.vonas.mediacluster.tv.data.SubtitleHit
|
||||||
import nl.vonas.mediacluster.tv.playback.PlayerTrack
|
import nl.vonas.mediacluster.tv.playback.PlayerTrack
|
||||||
|
|
@ -269,46 +270,52 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
player.addListener(listener)
|
player.addListener(listener)
|
||||||
onDispose {
|
onDispose {
|
||||||
try {
|
val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
|
||||||
runBlocking {
|
val dur = runCatching { player.duration.coerceAtLeast(0) }.getOrDefault(0L)
|
||||||
|
runCatching { player.pause() }
|
||||||
|
player.removeListener(listener)
|
||||||
|
runCatching { player.release() }
|
||||||
|
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
runCatching {
|
||||||
api.postProgress(
|
api.postProgress(
|
||||||
mediaType = mediaType,
|
mediaType = mediaType,
|
||||||
movieId = movieId,
|
movieId = movieId,
|
||||||
episodeId = episodeId,
|
episodeId = episodeId,
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
positionMs = player.currentPosition.coerceAtLeast(0),
|
positionMs = pos,
|
||||||
durationMs = player.duration.coerceAtLeast(0),
|
durationMs = dur,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
}
|
||||||
player.removeListener(listener)
|
|
||||||
player.release()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(player) {
|
LaunchedEffect(player) {
|
||||||
while (true) {
|
while (isActive) {
|
||||||
delay(400)
|
delay(400)
|
||||||
positionMs = player.currentPosition.coerceAtLeast(0)
|
runCatching {
|
||||||
durationMs = player.duration.coerceAtLeast(0)
|
positionMs = player.currentPosition.coerceAtLeast(0)
|
||||||
|
durationMs = player.duration.coerceAtLeast(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(player) {
|
LaunchedEffect(player) {
|
||||||
while (true) {
|
while (isActive) {
|
||||||
delay(12_000)
|
delay(12_000)
|
||||||
if (player.isPlaying) {
|
val playing = runCatching { player.isPlaying }.getOrDefault(false)
|
||||||
try {
|
if (playing) {
|
||||||
|
val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
|
||||||
|
val dur = runCatching { player.duration.coerceAtLeast(0) }.getOrDefault(0L)
|
||||||
|
runCatching {
|
||||||
api.postProgress(
|
api.postProgress(
|
||||||
mediaType = mediaType,
|
mediaType = mediaType,
|
||||||
movieId = movieId,
|
movieId = movieId,
|
||||||
episodeId = episodeId,
|
episodeId = episodeId,
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
positionMs = player.currentPosition.coerceAtLeast(0),
|
positionMs = pos,
|
||||||
durationMs = player.duration.coerceAtLeast(0),
|
durationMs = dur,
|
||||||
)
|
)
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -393,6 +400,7 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { it.player = player },
|
update = { it.player = player },
|
||||||
|
onRelease = { it.player = null },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ android {
|
||||||
applicationId = "nl.vonas.mediacluster.tv"
|
applicationId = "nl.vonas.mediacluster.tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 76
|
versionCode = 82
|
||||||
versionName = "0.14.13"
|
versionName = "0.14.19"
|
||||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
@ -90,6 +90,7 @@ dependencies {
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
|
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -30,12 +30,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -58,7 +56,7 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
|
|
||||||
private val AzLetters = listOf("#") + ('A'..'Z').map { it.toString() }
|
private val AzLetters = listOf("#") + ('A'..'Z').map { it.toString() }
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun BrowseScreen(
|
fun BrowseScreen(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
|
|
@ -91,18 +89,16 @@ fun BrowseScreen(
|
||||||
var loadingMore by remember { mutableStateOf(false) }
|
var loadingMore by remember { mutableStateOf(false) }
|
||||||
var sort by rememberSaveable(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) }
|
var sort by rememberSaveable(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) }
|
||||||
var activeLetter by rememberSaveable(catalogId) { mutableStateOf<String?>(null) }
|
var activeLetter by rememberSaveable(catalogId) { mutableStateOf<String?>(null) }
|
||||||
var loadedKey by rememberSaveable(catalogId) { mutableStateOf<String?>(null) }
|
|
||||||
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
|
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
|
||||||
var menuReady by remember { mutableStateOf(false) }
|
var menuReady by remember { mutableStateOf(false) }
|
||||||
val menuFocus = remember { FocusRequester() }
|
val menuFocus = remember { FocusRequester() }
|
||||||
val gridState = rememberSaveable(catalogId, sort, saver = LazyGridState.Saver) { LazyGridState() }
|
val gridState = rememberLazyGridState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val showBrowseTools = !isContinue
|
val showBrowseTools = !isContinue
|
||||||
|
|
||||||
suspend fun load(reset: Boolean, jumpLetter: String? = null) {
|
suspend fun load(reset: Boolean, jumpLetter: String? = null) {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
// Bestaande items houden het grid in compositie → focus blijft bij terugkeer
|
loading = true
|
||||||
if (items.isEmpty()) loading = true
|
|
||||||
error = null
|
error = null
|
||||||
} else {
|
} else {
|
||||||
loadingMore = true
|
loadingMore = true
|
||||||
|
|
@ -137,11 +133,8 @@ fun BrowseScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(catalogId, sort) {
|
LaunchedEffect(catalogId, sort) {
|
||||||
val key = "$catalogId|$sort"
|
load(reset = true, jumpLetter = null)
|
||||||
if (loadedKey != key || items.isEmpty()) {
|
gridState.scrollToItem(0)
|
||||||
load(reset = true, jumpLetter = null)
|
|
||||||
loadedKey = key
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(gridState, items.size, total, listOffset) {
|
LaunchedEffect(gridState, items.size, total, listOffset) {
|
||||||
|
|
@ -185,57 +178,69 @@ fun BrowseScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||||
|
val scrubberPad = if (showBrowseTools && sort == "title") 40.dp else 28.dp
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(start = 36.dp, end = if (showBrowseTools && sort == "title") 52.dp else 36.dp, top = 20.dp, bottom = 20.dp),
|
.padding(start = 28.dp, end = scrubberPad, top = 10.dp, bottom = 12.dp),
|
||||||
) {
|
) {
|
||||||
|
// Eén balk: titel + telling links, sort rechts — leesbaar van de bank, weinig hoogte
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f)) {
|
Row(
|
||||||
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
Modifier.weight(1f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
when {
|
when {
|
||||||
total > 0 -> "$total titels"
|
total > 0 -> "$total"
|
||||||
loading -> "Laden…"
|
loading -> "…"
|
||||||
else -> "Geen titels"
|
else -> "0"
|
||||||
},
|
},
|
||||||
color = McColors.TextDim,
|
color = McColors.TextDim,
|
||||||
fontSize = 14.sp,
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (onBack != null) {
|
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)
|
McButton("Terug", onClick = onBack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showBrowseTools) {
|
Spacer(Modifier.height(10.dp))
|
||||||
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))
|
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
Text(error!!, color = McColors.Danger)
|
Text(error!!, color = McColors.Danger, fontSize = 16.sp)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
loading && items.isEmpty() -> {
|
loading && items.isEmpty() -> {
|
||||||
|
|
@ -251,21 +256,20 @@ fun BrowseScreen(
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 140.dp),
|
// ~124dp: dichter dan 140 (meer rijen), nog herkenbaar van de bank
|
||||||
|
columns = GridCells.Adaptive(minSize = 124.dp),
|
||||||
state = gridState,
|
state = gridState,
|
||||||
contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp),
|
contentPadding = PaddingValues(start = 4.dp, top = 4.dp, end = 4.dp, bottom = 28.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
|
||||||
.focusRestorer(),
|
|
||||||
) {
|
) {
|
||||||
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
||||||
PosterCard(
|
PosterCard(
|
||||||
item = item,
|
item = item,
|
||||||
fillWidth = true,
|
fillWidth = true,
|
||||||
showCaption = isContinue,
|
showCaption = isContinue,
|
||||||
modifier = Modifier.padding(6.dp),
|
modifier = Modifier.padding(3.dp),
|
||||||
onLongClick = if (isContinue) {
|
onLongClick = if (isContinue) {
|
||||||
{ continueMenu = item }
|
{ continueMenu = item }
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -305,11 +309,10 @@ fun BrowseScreen(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.CenterEnd)
|
.align(Alignment.CenterEnd)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.padding(vertical = 16.dp, horizontal = 4.dp),
|
.padding(vertical = 8.dp, horizontal = 2.dp),
|
||||||
onLetter = { letter ->
|
onLetter = { letter ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
load(reset = true, jumpLetter = letter)
|
load(reset = true, jumpLetter = letter)
|
||||||
loadedKey = "$catalogId|$sort"
|
|
||||||
gridState.scrollToItem(0)
|
gridState.scrollToItem(0)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -379,7 +382,7 @@ private fun SortChip(
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = if (selected) Color.White else Color(0x33FFFFFF),
|
containerColor = if (selected) Color.White else Color(0x33FFFFFF),
|
||||||
focusedContainerColor = Color.White,
|
focusedContainerColor = Color.White,
|
||||||
|
|
@ -387,9 +390,9 @@ private fun SortChip(
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||||
color = if (selected) Color.Black else Color.White,
|
color = if (selected) Color.Black else Color.White,
|
||||||
fontSize = 14.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -403,7 +406,7 @@ private fun AzScrubber(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.width(30.dp),
|
modifier = modifier.width(34.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
AzLetters.forEach { letter ->
|
AzLetters.forEach { letter ->
|
||||||
|
|
@ -414,7 +417,7 @@ private fun AzScrubber(
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(3.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(3.dp)),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.35f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.4f),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
focusedContainerColor = Color(0x55FFFFFF),
|
focusedContainerColor = Color(0x55FFFFFF),
|
||||||
|
|
@ -426,10 +429,10 @@ private fun AzScrubber(
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
letter,
|
letter,
|
||||||
color = if (isSelected) McColors.Brand else Color(0xCCFFFFFF),
|
color = if (isSelected) McColors.Brand else Color(0xE6FFFFFF),
|
||||||
fontSize = 10.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.SemiBold,
|
||||||
lineHeight = 10.sp,
|
lineHeight = 13.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -27,7 +28,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
@ -45,13 +45,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
|
@ -69,10 +67,11 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.ui.graphics.FilterQuality
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.dialog
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
|
|
@ -326,54 +325,27 @@ fun McNav() {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// dialog i.p.v. composable: Home/Browse blijven gecomposeerd → scroll+focus behouden
|
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
||||||
dialog(
|
val id = entry.arguments?.getString("id") ?: return@composable
|
||||||
route = "movie/{id}",
|
MovieDetailScreen(
|
||||||
arguments = listOf(navArgument("id") { type = NavType.StringType }),
|
api = api,
|
||||||
dialogProperties = DialogProperties(
|
movieId = id,
|
||||||
dismissOnBackPress = true,
|
onBack = { nav.popBackStack() },
|
||||||
dismissOnClickOutside = false,
|
onPlay = { mediaFileId, _, startMs ->
|
||||||
usePlatformDefaultWidth = false,
|
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs")
|
||||||
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 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
dialog(
|
composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
||||||
route = "series/{id}",
|
val id = entry.arguments?.getString("id") ?: return@composable
|
||||||
arguments = listOf(navArgument("id") { type = NavType.StringType }),
|
SeriesDetailScreen(
|
||||||
dialogProperties = DialogProperties(
|
api = api,
|
||||||
dismissOnBackPress = true,
|
seriesId = id,
|
||||||
dismissOnClickOutside = false,
|
onBack = { nav.popBackStack() },
|
||||||
usePlatformDefaultWidth = false,
|
onPlay = { mediaFileId, episodeId, _ ->
|
||||||
decorFitsSystemWindows = false,
|
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0")
|
||||||
),
|
},
|
||||||
) { 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(
|
composable(
|
||||||
"player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}",
|
"player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}",
|
||||||
|
|
@ -450,7 +422,7 @@ private fun MainHub(
|
||||||
val homeContentFocus = remember { FocusRequester() }
|
val homeContentFocus = remember { FocusRequester() }
|
||||||
val exitFocus = remember { FocusRequester() }
|
val exitFocus = remember { FocusRequester() }
|
||||||
var exitReady by remember { mutableStateOf(false) }
|
var exitReady by remember { mutableStateOf(false) }
|
||||||
val homeListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() }
|
val homeListState = rememberLazyListState()
|
||||||
var prevTabName by rememberSaveable { mutableStateOf<String?>(null) }
|
var prevTabName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val hubScope = rememberCoroutineScope()
|
val hubScope = rememberCoroutineScope()
|
||||||
val activity = LocalContext.current as? Activity
|
val activity = LocalContext.current as? Activity
|
||||||
|
|
@ -818,10 +790,11 @@ private fun LinkScreen(
|
||||||
onLinked: () -> Unit,
|
onLinked: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var code by remember { mutableStateOf<String?>(null) }
|
var code by remember { mutableStateOf<String?>(null) }
|
||||||
var linkUrl by remember { mutableStateOf("") }
|
var qrPayload by remember { mutableStateOf("") }
|
||||||
var status by remember { mutableStateOf("Verbinden…") }
|
var status by remember { mutableStateOf("Verbinden…") }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var serverLabel by remember { mutableStateOf("") }
|
var serverLabel by remember { mutableStateOf("") }
|
||||||
|
val qrImage = rememberQrImage(qrPayload)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val base = store.apiBase.first()
|
val base = store.apiBase.first()
|
||||||
|
|
@ -831,8 +804,10 @@ private fun LinkScreen(
|
||||||
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" }
|
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" }
|
||||||
val created = api.createDeviceCode(deviceName)
|
val created = api.createDeviceCode(deviceName)
|
||||||
code = created.code
|
code = created.code
|
||||||
linkUrl = created.linkUrl
|
qrPayload = created.qrPayload.ifBlank {
|
||||||
status = "Wachten tot je de code bevestigt…"
|
"vmc://link?code=${created.code}"
|
||||||
|
}
|
||||||
|
status = "Wachten op goedkeuring in de VMC-app…"
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(created.pollIntervalSeconds.coerceAtLeast(2) * 1000L)
|
delay(created.pollIntervalSeconds.coerceAtLeast(2) * 1000L)
|
||||||
when (val poll = api.pollDeviceCode(created.code)) {
|
when (val poll = api.pollDeviceCode(created.code)) {
|
||||||
|
|
@ -866,27 +841,65 @@ private fun LinkScreen(
|
||||||
.background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)),
|
.background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(
|
Row(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalArrangement = Arrangement.spacedBy(36.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(560.dp)
|
.width(820.dp)
|
||||||
.clip(RoundedCornerShape(24.dp))
|
.clip(RoundedCornerShape(24.dp))
|
||||||
.background(McColors.BgElevated.copy(alpha = 0.92f))
|
.background(McColors.BgElevated.copy(alpha = 0.92f))
|
||||||
.padding(40.dp),
|
.padding(36.dp),
|
||||||
) {
|
) {
|
||||||
Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp)
|
Column(
|
||||||
Text("Koppel dit apparaat", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
Text("Open de link op je telefoon, log in en voer de code in.", color = McColors.TextDim, fontSize = 15.sp)
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
if (serverLabel.isNotBlank()) {
|
modifier = Modifier.width(280.dp),
|
||||||
Text(serverLabel, color = Color(0xFF9AB0D0), fontSize = 13.sp)
|
) {
|
||||||
|
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 loading by remember { mutableStateOf(true) }
|
||||||
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
|
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
|
||||||
var menuReady by remember { mutableStateOf(false) }
|
var menuReady by remember { mutableStateOf(false) }
|
||||||
var didInitialHomeFocus by rememberSaveable { mutableStateOf(false) }
|
var shelfFocusReady by remember { mutableStateOf(false) }
|
||||||
|
var playFocusReady by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val menuFocus = remember { FocusRequester() }
|
val menuFocus = remember { FocusRequester() }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val store = remember { SessionStore(context) }
|
val store = remember { SessionStore(context) }
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val firstShelfFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
suspend fun reloadHome() {
|
suspend fun reloadHome() {
|
||||||
val fresh = api.home()
|
val fresh = api.home()
|
||||||
|
|
@ -952,13 +967,15 @@ private fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alleen eerste keer focus op Afspelen — niet bij terugkeer uit film/serie
|
// Bij elke verschijning van Home (ook na serie/film/player): focus op content, niet op top-nav.
|
||||||
LaunchedEffect(home) {
|
LaunchedEffect(Unit) {
|
||||||
if (home == null || didInitialHomeFocus) return@LaunchedEffect
|
|
||||||
delay(120)
|
|
||||||
runCatching { contentFocusRequester.requestFocus() }
|
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
didInitialHomeFocus = true
|
val deadline = System.currentTimeMillis() + 900
|
||||||
|
while (!playFocusReady && System.currentTimeMillis() < deadline) {
|
||||||
|
delay(40)
|
||||||
|
}
|
||||||
|
delay(40)
|
||||||
|
runCatching { contentFocusRequester.requestFocus() }
|
||||||
}
|
}
|
||||||
|
|
||||||
val spotlight = home?.spotlight.orEmpty().ifEmpty {
|
val spotlight = home?.spotlight.orEmpty().ifEmpty {
|
||||||
|
|
@ -980,7 +997,14 @@ private fun HomeScreen(
|
||||||
shelfStartPx
|
shelfStartPx
|
||||||
}
|
}
|
||||||
|
|
||||||
// Als scroll stilvalt dicht bij de top met rest-offset: snap naar 0
|
fun expandHeroFully() {
|
||||||
|
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
|
||||||
|
scope.launch { listState.animateScrollToItem(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Als scroll stilvalt dicht bij de top met rest-offset: snap hero open
|
||||||
|
// (niet agressief over hele spacer — dat vecht met omlaag naar de eerste plank)
|
||||||
LaunchedEffect(listState) {
|
LaunchedEffect(listState) {
|
||||||
snapshotFlow {
|
snapshotFlow {
|
||||||
Triple(
|
Triple(
|
||||||
|
|
@ -989,8 +1013,8 @@ private fun HomeScreen(
|
||||||
listState.isScrollInProgress,
|
listState.isScrollInProgress,
|
||||||
)
|
)
|
||||||
}.collect { (index, offset, scrolling) ->
|
}.collect { (index, offset, scrolling) ->
|
||||||
if (!scrolling && index == 0 && offset in 1..160) {
|
if (!scrolling && index == 0 && offset in 1..240) {
|
||||||
listState.scrollToItem(0)
|
listState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1021,9 +1045,18 @@ private fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val firstShelfFocus = remember { FocusRequester() }
|
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
// Altijd een geldig focus-doel voor TopNav "down", ook vóór Afspelen gecomposeerd is.
|
||||||
|
if (!playFocusReady) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(1.dp)
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.focusRequester(contentFocusRequester)
|
||||||
|
.focusable()
|
||||||
|
.onFocusChanged { if (it.isFocused) onContentFocused() },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (loading && home == null) {
|
if (loading && home == null) {
|
||||||
HomeSkeleton()
|
HomeSkeleton()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1043,16 +1076,15 @@ private fun HomeScreen(
|
||||||
index = spotlightIndex,
|
index = spotlightIndex,
|
||||||
playFocusRequester = contentFocusRequester,
|
playFocusRequester = contentFocusRequester,
|
||||||
navFocusRequester = navFocusRequester,
|
navFocusRequester = navFocusRequester,
|
||||||
shelvesFocusRequester = firstShelfFocus,
|
// Alleen linken als de eerste plank écht in de tree hangt — anders crasht D-pad
|
||||||
|
// met "FocusRequester is not initialized" (Shield logcat 01:36 / 01:52).
|
||||||
|
shelvesFocusRequester = firstShelfFocus.takeIf { shelfFocusReady },
|
||||||
|
onPlayFocusReady = { playFocusReady = it },
|
||||||
onContentFocused = {
|
onContentFocused = {
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
// Alleen terug naar top als we al bovenaan zitten met rest-offset;
|
// Focus op hero/Afspelen → LazyColumn altijd volledig naar top
|
||||||
// niet forceren bij focus-events na terugkeer uit detail.
|
// (anders blijft hero deels onder het menu hangen tot Back).
|
||||||
if (listState.firstVisibleItemIndex == 0 &&
|
expandHeroFully()
|
||||||
listState.firstVisibleItemScrollOffset in 1..160
|
|
||||||
) {
|
|
||||||
scope.launch { listState.scrollToItem(0) }
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onPlay = { item ->
|
onPlay = { item ->
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
|
|
@ -1097,8 +1129,12 @@ private fun HomeScreen(
|
||||||
items = preview,
|
items = preview,
|
||||||
showCaptions = true,
|
showCaptions = true,
|
||||||
firstItemFocusRequester = firstShelfFocus,
|
firstItemFocusRequester = firstShelfFocus,
|
||||||
focusUpTo = contentFocusRequester,
|
focusUpTo = contentFocusRequester.takeIf { playFocusReady },
|
||||||
onRowFocused = onContentFocused,
|
onFirstFocusTargetReady = { shelfFocusReady = it },
|
||||||
|
onRowFocused = {
|
||||||
|
onContentFocused()
|
||||||
|
expandHeroFully()
|
||||||
|
},
|
||||||
onSeeAll = if (continueItems.size > 5) {
|
onSeeAll = if (continueItems.size > 5) {
|
||||||
{ onSeeAll("continue-watching", "Verder kijken") }
|
{ onSeeAll("continue-watching", "Verder kijken") }
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1139,8 +1175,16 @@ private fun HomeScreen(
|
||||||
title = shelf.title,
|
title = shelf.title,
|
||||||
items = shelf.items,
|
items = shelf.items,
|
||||||
firstItemFocusRequester = if (isFirstShelf) firstShelfFocus else null,
|
firstItemFocusRequester = if (isFirstShelf) firstShelfFocus else null,
|
||||||
focusUpTo = if (isFirstShelf) contentFocusRequester else null,
|
focusUpTo = if (isFirstShelf) contentFocusRequester.takeIf { playFocusReady } else null,
|
||||||
onRowFocused = onContentFocused,
|
onFirstFocusTargetReady = if (isFirstShelf) {
|
||||||
|
{ shelfFocusReady = it }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onRowFocused = {
|
||||||
|
onContentFocused()
|
||||||
|
if (isFirstShelf) expandHeroFully()
|
||||||
|
},
|
||||||
onSeeAll = { onSeeAll(catalogId, shelf.browseTitle) },
|
onSeeAll = { onSeeAll(catalogId, shelf.browseTitle) },
|
||||||
onClick = { item ->
|
onClick = { item ->
|
||||||
onContentFocused()
|
onContentFocused()
|
||||||
|
|
@ -1243,6 +1287,7 @@ private fun SpotlightHero(
|
||||||
playFocusRequester: FocusRequester? = null,
|
playFocusRequester: FocusRequester? = null,
|
||||||
navFocusRequester: FocusRequester? = null,
|
navFocusRequester: FocusRequester? = null,
|
||||||
shelvesFocusRequester: FocusRequester? = null,
|
shelvesFocusRequester: FocusRequester? = null,
|
||||||
|
onPlayFocusReady: (Boolean) -> Unit = {},
|
||||||
onContentFocused: () -> Unit = {},
|
onContentFocused: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val safeItems = items.ifEmpty { listOf(MediaItem(type = "movie", id = "", title = "Welkom bij VMC")) }
|
val safeItems = items.ifEmpty { listOf(MediaItem(type = "movie", id = "", title = "Welkom bij VMC")) }
|
||||||
|
|
@ -1339,6 +1384,10 @@ private fun SpotlightHero(
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
if (current.id.isNotBlank()) {
|
if (current.id.isNotBlank()) {
|
||||||
|
DisposableEffect(playFocusRequester) {
|
||||||
|
onPlayFocusReady(playFocusRequester != null)
|
||||||
|
onDispose { onPlayFocusReady(false) }
|
||||||
|
}
|
||||||
McButton(
|
McButton(
|
||||||
"Afspelen",
|
"Afspelen",
|
||||||
primary = true,
|
primary = true,
|
||||||
|
|
@ -1374,7 +1423,6 @@ private fun SpotlightHero(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ShelfBlock(
|
private fun ShelfBlock(
|
||||||
title: String,
|
title: String,
|
||||||
|
|
@ -1386,6 +1434,7 @@ private fun ShelfBlock(
|
||||||
onRowFocused: () -> Unit = {},
|
onRowFocused: () -> Unit = {},
|
||||||
firstItemFocusRequester: FocusRequester? = null,
|
firstItemFocusRequester: FocusRequester? = null,
|
||||||
focusUpTo: FocusRequester? = null,
|
focusUpTo: FocusRequester? = null,
|
||||||
|
onFirstFocusTargetReady: ((Boolean) -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var rowFocused by remember { mutableStateOf(false) }
|
var rowFocused by remember { mutableStateOf(false) }
|
||||||
|
|
@ -1435,12 +1484,17 @@ private fun ShelfBlock(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LazyRow(
|
LazyRow(
|
||||||
modifier = Modifier.focusRestorer(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
// Extra ruimte zodat focusedScale (1.10) posters niet afknipt
|
// Extra ruimte zodat focusedScale (1.10) posters niet afknipt
|
||||||
contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 28.dp, bottom = 28.dp),
|
contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 28.dp, bottom = 28.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item ->
|
itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item ->
|
||||||
|
if (index == 0 && firstItemFocusRequester != null) {
|
||||||
|
DisposableEffect(firstItemFocusRequester) {
|
||||||
|
onFirstFocusTargetReady?.invoke(true)
|
||||||
|
onDispose { onFirstFocusTargetReady?.invoke(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
PosterCard(
|
PosterCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = { onClick(item) },
|
onClick = { onClick(item) },
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,10 @@ import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
import nl.vonas.mediacluster.tv.data.SubtitleHit
|
import nl.vonas.mediacluster.tv.data.SubtitleHit
|
||||||
import nl.vonas.mediacluster.tv.playback.PlayerTrack
|
import nl.vonas.mediacluster.tv.playback.PlayerTrack
|
||||||
|
|
@ -266,46 +267,53 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
player.addListener(listener)
|
player.addListener(listener)
|
||||||
onDispose {
|
onDispose {
|
||||||
try {
|
val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
|
||||||
runBlocking {
|
val dur = runCatching { player.duration.coerceAtLeast(0) }.getOrDefault(0L)
|
||||||
|
runCatching { player.pause() }
|
||||||
|
player.removeListener(listener)
|
||||||
|
runCatching { player.release() }
|
||||||
|
// Progress buiten de UI-thread / composition-scope (die hier gecanceld wordt)
|
||||||
|
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
runCatching {
|
||||||
api.postProgress(
|
api.postProgress(
|
||||||
mediaType = mediaType,
|
mediaType = mediaType,
|
||||||
movieId = movieId,
|
movieId = movieId,
|
||||||
episodeId = episodeId,
|
episodeId = episodeId,
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
positionMs = player.currentPosition.coerceAtLeast(0),
|
positionMs = pos,
|
||||||
durationMs = player.duration.coerceAtLeast(0),
|
durationMs = dur,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
}
|
||||||
player.removeListener(listener)
|
|
||||||
player.release()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(player) {
|
LaunchedEffect(player) {
|
||||||
while (true) {
|
while (isActive) {
|
||||||
delay(400)
|
delay(400)
|
||||||
positionMs = player.currentPosition.coerceAtLeast(0)
|
runCatching {
|
||||||
durationMs = player.duration.coerceAtLeast(0)
|
positionMs = player.currentPosition.coerceAtLeast(0)
|
||||||
|
durationMs = player.duration.coerceAtLeast(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(player) {
|
LaunchedEffect(player) {
|
||||||
while (true) {
|
while (isActive) {
|
||||||
delay(12_000)
|
delay(12_000)
|
||||||
if (player.isPlaying) {
|
val playing = runCatching { player.isPlaying }.getOrDefault(false)
|
||||||
try {
|
if (playing) {
|
||||||
|
val pos = runCatching { player.currentPosition.coerceAtLeast(0) }.getOrDefault(0L)
|
||||||
|
val dur = runCatching { player.duration.coerceAtLeast(0) }.getOrDefault(0L)
|
||||||
|
runCatching {
|
||||||
api.postProgress(
|
api.postProgress(
|
||||||
mediaType = mediaType,
|
mediaType = mediaType,
|
||||||
movieId = movieId,
|
movieId = movieId,
|
||||||
episodeId = episodeId,
|
episodeId = episodeId,
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
positionMs = player.currentPosition.coerceAtLeast(0),
|
positionMs = pos,
|
||||||
durationMs = player.duration.coerceAtLeast(0),
|
durationMs = dur,
|
||||||
)
|
)
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -420,6 +428,7 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { it.player = player },
|
update = { it.player = player },
|
||||||
|
onRelease = { it.player = null },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.parallel=false
|
||||||
org.gradle.workers.max=1
|
org.gradle.workers.max=1
|
||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
|
|
@ -6,5 +6,5 @@ org.gradle.daemon=false
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
kotlin.compiler.execution.strategy=daemon
|
kotlin.compiler.execution.strategy=in-process
|
||||||
kotlin.incremental=true
|
kotlin.incremental=true
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
668CADCCEA404B50BB19A46E /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A435950EF040DE83F30779 /* DetailScreens.swift */; };
|
668CADCCEA404B50BB19A46E /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A435950EF040DE83F30779 /* DetailScreens.swift */; };
|
||||||
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */; };
|
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */; };
|
||||||
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */; };
|
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */; };
|
||||||
|
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */; };
|
||||||
B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */; };
|
B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */; };
|
||||||
468E05CB91FC4A1997A2F386 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6FA95D6A03BF48EA97553486 /* Assets.xcassets */; };
|
468E05CB91FC4A1997A2F386 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6FA95D6A03BF48EA97553486 /* Assets.xcassets */; };
|
||||||
DC8D7BF328134E1E8D7E3D76 /* MobileVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */; };
|
DC8D7BF328134E1E8D7E3D76 /* MobileVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE257D7C209E4803903206A8 /* MobileVLCKit.xcframework */; };
|
||||||
|
|
@ -57,6 +58,7 @@
|
||||||
F0A435950EF040DE83F30779 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = "<group>"; };
|
F0A435950EF040DE83F30779 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = "<group>"; };
|
||||||
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvScreen.swift; sourceTree = "<group>"; };
|
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTvScreen.swift; sourceTree = "<group>"; };
|
||||||
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = "<group>"; };
|
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = "<group>"; };
|
||||||
|
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesScreen.swift; sourceTree = "<group>"; };
|
||||||
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = "<group>"; };
|
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityBadges.swift; sourceTree = "<group>"; };
|
||||||
6FA95D6A03BF48EA97553486 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
6FA95D6A03BF48EA97553486 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
A22966A1CCBE44999F75FCA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
A22966A1CCBE44999F75FCA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
|
@ -167,6 +169,7 @@
|
||||||
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */,
|
D4A1F82E6B9C4E7A8F1D3C50 /* QualityBadges.swift */,
|
||||||
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */,
|
98E3A2A592E64C998AB1B72A /* LiveTvScreen.swift */,
|
||||||
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */,
|
470B4DF416DE4B3FB7A60DDB /* SettingsScreen.swift */,
|
||||||
|
C1D2E3F4A5B647C8D9E0F1A3 /* DevicesScreen.swift */,
|
||||||
);
|
);
|
||||||
path = UI;
|
path = UI;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -265,6 +268,7 @@
|
||||||
B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */,
|
B7C2E91A4F8D4A1E9C3B6D20 /* QualityBadges.swift in Sources */,
|
||||||
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */,
|
A72DE8139DAE48EC9975E0CF /* LiveTvScreen.swift in Sources */,
|
||||||
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */,
|
A1CFC814F3894773A5BEA652 /* SettingsScreen.swift in Sources */,
|
||||||
|
B8D4E1F2A3C54B6D9E0F1A2B /* DevicesScreen.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
@ -313,7 +317,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 8;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -328,7 +332,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.4.4;
|
MARKETING_VERSION = 0.4.6;
|
||||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|
@ -346,7 +350,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 8;
|
CURRENT_PROJECT_VERSION = 10;
|
||||||
DEVELOPMENT_TEAM = X25NYX3K49;
|
DEVELOPMENT_TEAM = X25NYX3K49;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -361,7 +365,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks"
|
"@executable_path/Frameworks"
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.4.4;
|
MARKETING_VERSION = 0.4.6;
|
||||||
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,22 @@ actor ApiClient {
|
||||||
return payload.results
|
return payload.results
|
||||||
}
|
}
|
||||||
|
|
||||||
func browse(catalogId: String, skip: Int = 0, take: Int = 60) async throws -> BrowsePayload {
|
func browse(
|
||||||
|
catalogId: String,
|
||||||
|
skip: Int = 0,
|
||||||
|
take: Int = 60,
|
||||||
|
sort: String? = nil,
|
||||||
|
letter: String? = nil
|
||||||
|
) async throws -> BrowsePayload {
|
||||||
let id = catalogId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? catalogId
|
let id = catalogId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? catalogId
|
||||||
return try await get("/api/v1/client/catalog/\(id)?skip=\(skip)&take=\(take)")
|
var q = "skip=\(skip)&take=\(take)"
|
||||||
|
if let sort, !sort.isEmpty {
|
||||||
|
q += "&sort=\(sort.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? sort)"
|
||||||
|
}
|
||||||
|
if let letter, !letter.isEmpty {
|
||||||
|
q += "&letter=\(letter.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? letter)"
|
||||||
|
}
|
||||||
|
return try await get("/api/v1/client/catalog/\(id)?\(q)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func movie(id: String) async throws -> MovieDetail {
|
func movie(id: String) async throws -> MovieDetail {
|
||||||
|
|
@ -225,6 +238,20 @@ actor ApiClient {
|
||||||
|
|
||||||
func clientMe() async throws -> ClientMe { try await get("/api/v1/client/me") }
|
func clientMe() async throws -> ClientMe { try await get("/api/v1/client/me") }
|
||||||
|
|
||||||
|
func listDevices() async throws -> [LinkedDevice] {
|
||||||
|
let payload: DevicesPayload = try await get("/api/v1/client/devices")
|
||||||
|
return payload.devices
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeDevice(deviceId: String) async throws {
|
||||||
|
let encoded = deviceId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? deviceId
|
||||||
|
let _: Empty = try await post("/api/v1/client/devices/\(encoded)/revoke", json: [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func approveDeviceCode(_ code: String) async throws -> ApproveDeviceResult {
|
||||||
|
try await post("/api/v1/client/device/approve", json: ["code": code])
|
||||||
|
}
|
||||||
|
|
||||||
func searchSubtitles(mediaFileId: String, languages: String = "nl,en") async throws -> SubtitleSearchResult {
|
func searchSubtitles(mediaFileId: String, languages: String = "nl,en") async throws -> SubtitleSearchResult {
|
||||||
let id = mediaFileId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? mediaFileId
|
let id = mediaFileId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? mediaFileId
|
||||||
let langs = languages.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? languages
|
let langs = languages.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? languages
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ final class AppModel: ObservableObject {
|
||||||
@Published var iptvEnabled = false
|
@Published var iptvEnabled = false
|
||||||
@Published var canManageProfiles = false
|
@Published var canManageProfiles = false
|
||||||
@Published var bootError: String?
|
@Published var bootError: String?
|
||||||
|
/// Code van QR/deep link om een ander apparaat goed te keuren (hoofdprofiel).
|
||||||
|
@Published var pendingApproveCode: String?
|
||||||
/// > 0 terwijl een speler (VOD/live) open is — verbergt de footer.
|
/// > 0 terwijl een speler (VOD/live) open is — verbergt de footer.
|
||||||
@Published private(set) var playbackDepth = 0
|
@Published private(set) var playbackDepth = 0
|
||||||
|
|
||||||
|
|
@ -141,9 +143,57 @@ final class AppModel: ObservableObject {
|
||||||
await api.configure(baseUrl: store.apiBase, token: nil)
|
await api.configure(baseUrl: store.apiBase, token: nil)
|
||||||
favorites.bind(profileId: nil)
|
favorites.bind(profileId: nil)
|
||||||
iptvEnabled = false
|
iptvEnabled = false
|
||||||
|
pendingApproveCode = nil
|
||||||
phase = .server
|
phase = .server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deep link: `vmc://link?code=ABCD-EFGH` of `vmc://link/ABCD-EFGH`
|
||||||
|
func handleOpenURL(_ url: URL) {
|
||||||
|
guard let code = Self.extractLinkCode(from: url) else { return }
|
||||||
|
pendingApproveCode = code
|
||||||
|
if phase == .main {
|
||||||
|
tab = .account
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearPendingApprove() {
|
||||||
|
pendingApproveCode = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func extractLinkCode(from url: URL) -> String? {
|
||||||
|
let scheme = (url.scheme ?? "").lowercased()
|
||||||
|
if scheme == "vmc" {
|
||||||
|
if let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems,
|
||||||
|
let code = items.first(where: { $0.name.lowercased() == "code" })?.value,
|
||||||
|
!code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return normalizeLinkCode(code)
|
||||||
|
}
|
||||||
|
let parts = url.path.split(separator: "/").map(String.init)
|
||||||
|
if let last = parts.last, !last.isEmpty {
|
||||||
|
return normalizeLinkCode(last)
|
||||||
|
}
|
||||||
|
if let host = url.host, host.lowercased() != "link", !host.isEmpty {
|
||||||
|
return normalizeLinkCode(host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Web fallback: https://…/link?code=
|
||||||
|
if let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems,
|
||||||
|
let code = items.first(where: { $0.name.lowercased() == "code" })?.value {
|
||||||
|
return normalizeLinkCode(code)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func normalizeLinkCode(_ raw: String) -> String {
|
||||||
|
let cleaned = raw.uppercased().filter { $0.isLetter || $0.isNumber || $0 == "-" }
|
||||||
|
if cleaned.contains("-") { return cleaned }
|
||||||
|
if cleaned.count == 8 {
|
||||||
|
let i = cleaned.index(cleaned.startIndex, offsetBy: 4)
|
||||||
|
return "\(cleaned[..i])-\(cleaned[i...])"
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
static var deviceName: String {
|
static var deviceName: String {
|
||||||
UIDevice.current.name.isEmpty ? "iPhone" : UIDevice.current.name
|
UIDevice.current.name.isEmpty ? "iPhone" : UIDevice.current.name
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,30 @@ struct ClientMe: Decodable {
|
||||||
struct Iptv: Decodable { let enabled: Bool? }
|
struct Iptv: Decodable { let enabled: Bool? }
|
||||||
let iptv: Iptv?
|
let iptv: Iptv?
|
||||||
let profile: ViewerProfile?
|
let profile: ViewerProfile?
|
||||||
|
let deviceId: String?
|
||||||
|
|
||||||
var iptvEnabled: Bool { iptv?.enabled == true }
|
var iptvEnabled: Bool { iptv?.enabled == true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct LinkedDevice: Decodable, Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let platform: String?
|
||||||
|
let lastSeenAt: String?
|
||||||
|
let createdAt: String?
|
||||||
|
let current: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DevicesPayload: Decodable {
|
||||||
|
let devices: [LinkedDevice]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ApproveDeviceResult: Decodable {
|
||||||
|
let ok: Bool?
|
||||||
|
let deviceName: String?
|
||||||
|
let platform: String?
|
||||||
|
}
|
||||||
|
|
||||||
struct MediaFileInfo: Decodable, Identifiable, Hashable {
|
struct MediaFileInfo: Decodable, Identifiable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
let label: String?
|
let label: String?
|
||||||
|
|
@ -161,6 +181,7 @@ struct BrowsePayload: Decodable {
|
||||||
let title: String?
|
let title: String?
|
||||||
let kind: String?
|
let kind: String?
|
||||||
let total: Int?
|
let total: Int?
|
||||||
|
let offset: Int?
|
||||||
let items: [MediaItem]
|
let items: [MediaItem]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,17 @@
|
||||||
<string>$(MARKETING_VERSION)</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>nl.vonas.mediacluster.ios.link</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>vmc</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,89 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Films / Series-bibliotheek — grid zoals Android Browse, met iOS-chips + letter-index.
|
||||||
struct BrowseScreen: View {
|
struct BrowseScreen: View {
|
||||||
@EnvironmentObject private var app: AppModel
|
@EnvironmentObject private var app: AppModel
|
||||||
let kind: String // "MOVIE" or "SERIES"
|
let kind: String // "MOVIE" or "SERIES"
|
||||||
let title: String
|
let title: String
|
||||||
|
|
||||||
@State private var home: HomePayload?
|
|
||||||
@State private var shelfPrefs: [ShelfPref] = []
|
@State private var shelfPrefs: [ShelfPref] = []
|
||||||
@State private var mode: BrowseMode = .planks
|
@State private var catalogId: String = ""
|
||||||
|
@State private var sort: SortMode = .title
|
||||||
|
@State private var activeLetter: String?
|
||||||
|
@State private var pageTitle: String = ""
|
||||||
@State private var items: [MediaItem] = []
|
@State private var items: [MediaItem] = []
|
||||||
@State private var total = 0
|
@State private var total = 0
|
||||||
|
@State private var listOffset = 0
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var loading = true
|
@State private var loading = true
|
||||||
@State private var loadingMore = false
|
@State private var loadingMore = false
|
||||||
|
|
||||||
private enum BrowseMode: Hashable {
|
private enum SortMode: String {
|
||||||
case planks
|
case title
|
||||||
case catalog(String)
|
case recent
|
||||||
}
|
}
|
||||||
|
|
||||||
private var kindShelves: [ShelfRow] {
|
private var defaultCatalog: String {
|
||||||
guard let home else { return [] }
|
kind == "MOVIE" ? "movies" : "series"
|
||||||
return home.shelves.filter { shelf in
|
}
|
||||||
let k = (shelf.kind ?? "").uppercased()
|
|
||||||
if kind == "MOVIE" { return k == "MOVIE" || k.contains("MOVIE") }
|
private var catalogChips: [(label: String, id: String)] {
|
||||||
return k == "SERIES" || k.contains("SERIES")
|
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))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private var chips: [(label: String, mode: BrowseMode)] {
|
|
||||||
var list: [(String, BrowseMode)] = [("Planken", .planks)]
|
|
||||||
if kind == "MOVIE" {
|
if kind == "MOVIE" {
|
||||||
list += [("Alles", .catalog("movies")), ("Recent", .catalog("recent-movies")), ("4K", .catalog("4k-movies"))]
|
append("Alles", "movies")
|
||||||
|
append("Recent", "recent-movies")
|
||||||
|
append("4K", "4k-movies")
|
||||||
} else {
|
} else {
|
||||||
list += [("Alles", .catalog("series")), ("Recent", .catalog("recent-series"))]
|
append("Alles", "series")
|
||||||
|
append("Recent", "recent-series")
|
||||||
}
|
}
|
||||||
for pref in shelfPrefs where pref.visible {
|
for pref in shelfPrefs where pref.visible {
|
||||||
let k = (pref.kind ?? "").uppercased()
|
let k = (pref.kind ?? "").uppercased()
|
||||||
let match = kind == "MOVIE" ? (k == "MOVIE" || k.contains("MOVIE")) : (k == "SERIES" || k.contains("SERIES"))
|
let match = kind == "MOVIE"
|
||||||
|
? (k == "MOVIE" || k.contains("MOVIE"))
|
||||||
|
: (k == "SERIES" || k.contains("SERIES"))
|
||||||
if match {
|
if match {
|
||||||
let slug = pref.slug?.isEmpty == false ? pref.slug! : pref.shelfId
|
let slug = pref.slug?.isEmpty == false ? pref.slug! : pref.shelfId
|
||||||
list.append((pref.name, .catalog(slug)))
|
append(pref.name, slug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
private let cols = [GridItem(.adaptive(minimum: 104), spacing: 12)]
|
/// A–Z tools alleen zinvol op titel-gebaseerde collecties (niet op “Recent”-catalogi).
|
||||||
|
private var showSortTools: Bool {
|
||||||
|
switch catalogId {
|
||||||
|
case "recent-movies", "recent-series":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var showLetterIndex: Bool {
|
||||||
|
showSortTools && sort == .title && !items.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private let letters = ["#"] + (65...90).map { String(UnicodeScalar($0)!) }
|
||||||
|
private let cols = [GridItem(.adaptive(minimum: 108), spacing: 10)]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
catalogBar
|
||||||
HStack(spacing: 8) {
|
headerBar
|
||||||
ForEach(Array(chips.enumerated()), id: \.offset) { _, chip in
|
HStack(alignment: .top, spacing: 0) {
|
||||||
Button {
|
gridContent
|
||||||
mode = chip.mode
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
Task { await activate(chip.mode) }
|
if showLetterIndex {
|
||||||
} label: {
|
letterIndex
|
||||||
Text(chip.label)
|
|
||||||
.font(McFonts.caption)
|
|
||||||
.foregroundStyle(mode == chip.mode ? .white : McColors.textDim)
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(mode == chip.mode ? McColors.accent : McColors.surface, in: Capsule())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
content
|
|
||||||
}
|
}
|
||||||
.background(McColors.bg.ignoresSafeArea())
|
.background(McColors.bg.ignoresSafeArea())
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
|
@ -82,95 +94,197 @@ struct BrowseScreen: View {
|
||||||
}
|
}
|
||||||
.toolbarBackground(McColors.bg, for: .navigationBar)
|
.toolbarBackground(McColors.bg, for: .navigationBar)
|
||||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
.task { await bootstrap() }
|
.task {
|
||||||
.refreshable { await bootstrap() }
|
if catalogId.isEmpty { catalogId = defaultCatalog }
|
||||||
|
await bootstrap()
|
||||||
|
}
|
||||||
|
.refreshable { await reload(reset: true) }
|
||||||
|
.navigationDestination(for: MediaItem.self) { MediaRouter(item: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var gridContent: some View {
|
||||||
if loading && home == nil && items.isEmpty {
|
if loading && items.isEmpty {
|
||||||
ProgressView().tint(.white).frame(maxWidth: .infinity, maxHeight: .infinity)
|
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) {
|
VStack(spacing: 12) {
|
||||||
Text(error).foregroundStyle(McColors.danger)
|
Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center)
|
||||||
Button("Opnieuw") { Task { await bootstrap() } }.buttonStyle(McPrimaryButtonStyle())
|
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 {
|
} else {
|
||||||
switch mode {
|
ScrollView {
|
||||||
case .planks:
|
LazyVGrid(columns: cols, spacing: 12) {
|
||||||
ScrollView {
|
ForEach(items) { item in
|
||||||
VStack(alignment: .leading, spacing: 22) {
|
NavigationLink(value: item) {
|
||||||
if kindShelves.isEmpty {
|
PosterCard(item: item, width: 108, showTitle: true)
|
||||||
ContentUnavailableView("Geen planken", systemImage: "rectangle.stack", description: Text("Nog geen \(title.lowercased())-planken zichtbaar."))
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.buttonStyle(.plain)
|
||||||
.padding(.top, 40)
|
.onAppear {
|
||||||
} else {
|
if item.id == items.last?.id {
|
||||||
ForEach(kindShelves) { shelf in
|
Task { await loadMore() }
|
||||||
if !shelf.items.isEmpty {
|
|
||||||
ShelfSection(title: shelf.title, items: shelf.items)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, 24)
|
|
||||||
}
|
}
|
||||||
case .catalog:
|
.padding(.horizontal, 14)
|
||||||
ScrollView {
|
.padding(.bottom, 28)
|
||||||
LazyVGrid(columns: cols, spacing: 14) {
|
if loadingMore {
|
||||||
ForEach(items) { item in
|
ProgressView().tint(.white).padding(.bottom, 16)
|
||||||
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() }
|
|
||||||
}
|
}
|
||||||
.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 {
|
private func bootstrap() async {
|
||||||
loading = true
|
shelfPrefs = (try? await app.api.getShelfPrefs())?.sorted(by: { $0.sortOrder < $1.sortOrder }) ?? []
|
||||||
error = nil
|
await reload(reset: true)
|
||||||
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 {
|
private func reload(reset: Bool) 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 {
|
if reset {
|
||||||
items = []
|
|
||||||
loading = true
|
loading = true
|
||||||
|
error = nil
|
||||||
}
|
}
|
||||||
|
let id = catalogId.isEmpty ? defaultCatalog : catalogId
|
||||||
|
let sortParam: String? = showSortTools ? sort.rawValue : nil
|
||||||
do {
|
do {
|
||||||
let page = try await app.api.browse(catalogId: id, skip: 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
|
total = page.total ?? page.items.count
|
||||||
|
listOffset = page.offset ?? 0
|
||||||
items = page.items
|
items = page.items
|
||||||
loading = false
|
loading = false
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -179,12 +293,27 @@ struct BrowseScreen: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func jump(to letter: String) async {
|
||||||
|
activeLetter = letter
|
||||||
|
sort = .title
|
||||||
|
await reload(reset: true)
|
||||||
|
}
|
||||||
|
|
||||||
private func loadMore() async {
|
private func loadMore() async {
|
||||||
guard case .catalog(let id) = mode, !loadingMore, items.count < total else { return }
|
guard !loadingMore, items.count + listOffset < total else { return }
|
||||||
loadingMore = true
|
loadingMore = true
|
||||||
|
let id = catalogId.isEmpty ? defaultCatalog : catalogId
|
||||||
|
let sortParam: String? = showSortTools ? sort.rawValue : nil
|
||||||
do {
|
do {
|
||||||
let page = try await app.api.browse(catalogId: id, skip: items.count, take: 60)
|
let page = try await app.api.browse(
|
||||||
items.append(contentsOf: page.items)
|
catalogId: id,
|
||||||
|
skip: listOffset + items.count,
|
||||||
|
take: 60,
|
||||||
|
sort: sortParam,
|
||||||
|
letter: nil
|
||||||
|
)
|
||||||
|
let existing = Set(items.map(\.id))
|
||||||
|
items.append(contentsOf: page.items.filter { !existing.contains($0.id) })
|
||||||
loadingMore = false
|
loadingMore = false
|
||||||
} catch {
|
} catch {
|
||||||
loadingMore = false
|
loadingMore = false
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,11 @@ struct TitleLogo: View {
|
||||||
struct PosterCard: View {
|
struct PosterCard: View {
|
||||||
let item: MediaItem
|
let item: MediaItem
|
||||||
var width: CGFloat = 112
|
var width: CGFloat = 112
|
||||||
|
/// Grid-bibliotheek: geen titel onder poster (zoals Android Browse).
|
||||||
|
var showTitle: Bool = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: showTitle ? 6 : 0) {
|
||||||
RemoteImage(url: item.posterUrl)
|
RemoteImage(url: item.posterUrl)
|
||||||
.frame(width: width, height: width * 1.5)
|
.frame(width: width, height: width * 1.5)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
|
@ -85,14 +87,17 @@ struct PosterCard: View {
|
||||||
.padding(4)
|
.padding(4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(item.title)
|
if showTitle {
|
||||||
.font(McFonts.caption)
|
Text(item.title)
|
||||||
.foregroundStyle(McColors.text)
|
.font(McFonts.caption)
|
||||||
.lineLimit(2)
|
.foregroundStyle(McColors.text)
|
||||||
.multilineTextAlignment(.leading)
|
.lineLimit(2)
|
||||||
.frame(width: width, height: 36, alignment: .topLeading)
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(width: width, height: 36, alignment: .topLeading)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(width: width, alignment: .leading)
|
.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)
|
.animation(.easeInOut(duration: 0.28), value: app.phase)
|
||||||
|
.sheet(item: approveSheetBinding) { item in
|
||||||
|
DeviceApproveSheet(code: item.code)
|
||||||
|
.environmentObject(app)
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var approveSheetBinding: Binding<ApproveCodeItem?> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
guard let code = app.pendingApproveCode, app.phase == .main else { return nil }
|
||||||
|
return ApproveCodeItem(code: code)
|
||||||
|
},
|
||||||
|
set: { newValue in
|
||||||
|
if newValue == nil { app.clearPendingApprove() }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ApproveCodeItem: Identifiable {
|
||||||
|
let code: String
|
||||||
|
var id: String { code }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SplashView: View {
|
struct SplashView: View {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,14 @@ struct SettingsScreen: View {
|
||||||
.foregroundStyle(McColors.text)
|
.foregroundStyle(McColors.text)
|
||||||
}
|
}
|
||||||
.listRowBackground(McColors.bgElevated)
|
.listRowBackground(McColors.bgElevated)
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
DevicesScreen()
|
||||||
|
} label: {
|
||||||
|
Label("Apparaten", systemImage: "tv.and.hifispeaker.fill")
|
||||||
|
.foregroundStyle(McColors.text)
|
||||||
|
}
|
||||||
|
.listRowBackground(McColors.bgElevated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +90,7 @@ struct SettingsScreen: View {
|
||||||
|
|
||||||
Section("Info") {
|
Section("Info") {
|
||||||
LabeledContent("App", value: "VMC iOS")
|
LabeledContent("App", value: "VMC iOS")
|
||||||
LabeledContent("Versie", value: "0.4.3")
|
LabeledContent("Versie", value: "0.4.6")
|
||||||
LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
|
LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
|
||||||
}
|
}
|
||||||
.listRowBackground(McColors.bgElevated)
|
.listRowBackground(McColors.bgElevated)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ struct VMCApp: App {
|
||||||
RootView()
|
RootView()
|
||||||
.environmentObject(appModel)
|
.environmentObject(appModel)
|
||||||
.preferredColorScheme(.dark)
|
.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. */
|
/** Directe app-login (e-mail + wachtwoord) → refresh token. */
|
||||||
app.post(
|
app.post(
|
||||||
"/api/v1/client/login",
|
"/api/v1/client/login",
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,71 @@ export class ViewerService {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listMyDevices(viewerId: string, currentDeviceId: string) {
|
||||||
|
const devices = await prisma.viewerDevice.findMany({
|
||||||
|
where: { viewerUserId: viewerId, revoked: false },
|
||||||
|
select: { id: true, name: true, platform: true, lastSeenAt: true, createdAt: true },
|
||||||
|
orderBy: { lastSeenAt: "desc" },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
devices: devices.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
platform: d.platform,
|
||||||
|
lastSeenAt: d.lastSeenAt?.toISOString() ?? null,
|
||||||
|
createdAt: d.createdAt.toISOString(),
|
||||||
|
current: d.id === currentDeviceId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeMyDevice(viewerId: string, deviceId: string, currentDeviceId: string) {
|
||||||
|
const device = await prisma.viewerDevice.findFirst({
|
||||||
|
where: { id: deviceId, viewerUserId: viewerId, revoked: false },
|
||||||
|
});
|
||||||
|
if (!device) throw new AppError("NOT_FOUND", "Apparaat niet gevonden", 404);
|
||||||
|
if (device.id === currentDeviceId) {
|
||||||
|
throw new AppError("CONFLICT", "Je kunt dit apparaat niet intrekken terwijl je erop bent ingelogd", 409);
|
||||||
|
}
|
||||||
|
return this.revokeDevice(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-app goedkeuren: viewer is al ingelogd (Bearer) en bevestigt een TV/device-code.
|
||||||
|
*/
|
||||||
|
async approveDeviceCode(viewerId: string, codeRaw: string) {
|
||||||
|
const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId } });
|
||||||
|
if (!viewer || !viewer.enabled || !viewer.appAccess) {
|
||||||
|
throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await prisma.deviceLinkCode.findUnique({
|
||||||
|
where: { codeHash: hashLinkCode(codeRaw) },
|
||||||
|
});
|
||||||
|
if (!row) throw new AppError("NOT_FOUND", "Onbekende code", 404);
|
||||||
|
if (row.consumedAt) throw new AppError("CONFLICT", "Code al gebruikt", 409);
|
||||||
|
if (row.expiresAt.getTime() < Date.now()) {
|
||||||
|
throw new AppError("EXPIRED", "Code verlopen — start opnieuw op het apparaat", 410);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimToken = generateSecureToken(24);
|
||||||
|
await prisma.deviceLinkCode.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: {
|
||||||
|
consumedAt: new Date(),
|
||||||
|
viewerUserId: viewer.id,
|
||||||
|
claimTokenHash: hashToken(claimToken),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
deviceName: row.deviceName,
|
||||||
|
platform: row.platform,
|
||||||
|
viewer: { id: viewer.id, email: viewer.email, name: viewer.name },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async createDeviceCode(deviceName: string, platform = "android_tv") {
|
async createDeviceCode(deviceName: string, platform = "android_tv") {
|
||||||
const name = deviceName.trim().slice(0, 80) || "Android TV";
|
const name = deviceName.trim().slice(0, 80) || "Android TV";
|
||||||
const code = generateLinkCode(8);
|
const code = generateLinkCode(8);
|
||||||
|
|
@ -287,13 +352,16 @@ export class ViewerService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const linkUrl = `${this.publicBase()}/link`;
|
const linkUrl = `${this.publicBase()}/link`;
|
||||||
|
// Deep link voor VMC-app (in-app goedkeuren). Web /link blijft beschikbaar als fallback.
|
||||||
|
const qrPayload = `vmc://link?code=${encodeURIComponent(code)}`;
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
expiresAt: expiresAt.toISOString(),
|
expiresAt: expiresAt.toISOString(),
|
||||||
expiresInSeconds: Math.floor(CODE_TTL_MS / 1000),
|
expiresInSeconds: Math.floor(CODE_TTL_MS / 1000),
|
||||||
pollIntervalSeconds: POLL_INTERVAL_SEC,
|
pollIntervalSeconds: POLL_INTERVAL_SEC,
|
||||||
linkUrl,
|
linkUrl,
|
||||||
qrPayload: `${linkUrl}?code=${encodeURIComponent(code)}`,
|
qrPayload,
|
||||||
|
appLink: qrPayload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue