Add ends-at times, compact red topnav, and custom profile avatars.

Show end times on detail/player, tighten TV chrome, and let profiles toggle or upload a cropped photo via API and mobile/iOS.
This commit is contained in:
Jos Vooges | STH 2026-09-13 03:04:47 +02:00
parent 94c04e0381
commit e4aba1950b
28 changed files with 1673 additions and 275 deletions

View file

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

View file

@ -303,6 +303,8 @@ class ApiClient(
profileId: String,
name: String? = null,
avatarId: String? = null,
showAvatar: Boolean? = null,
clearAvatarImage: Boolean = false,
isKids: Boolean? = null,
pin: String? = null,
clearPin: Boolean = false,
@ -310,6 +312,8 @@ class ApiClient(
val body = JSONObject()
if (name != null) body.put("name", name)
if (avatarId != null) body.put("avatarId", avatarId)
if (showAvatar != null) body.put("showAvatar", showAvatar)
if (clearAvatarImage) body.put("clearAvatarImage", true)
if (isKids != null) body.put("isKids", isKids)
if (clearPin) body.put("clearPin", true)
else if (!pin.isNullOrBlank()) body.put("pin", pin)
@ -319,6 +323,28 @@ class ApiClient(
parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() }
}
suspend fun uploadProfileAvatar(
profileId: String,
imageBase64: String,
mimeType: String = "image/jpeg",
): ViewerProfile = withContext(Dispatchers.IO) {
val body = JSONObject()
.put("imageBase64", imageBase64)
.put("mimeType", mimeType)
val req = authed("$baseUrl/api/v1/client/profiles/$profileId/avatar")
.post(body.toString().toRequestBody(jsonMedia))
.build()
parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() }
}
fun absoluteUrl(path: String?): String? {
if (path.isNullOrBlank()) return null
if (path.startsWith("http")) return path
return "$baseUrl$path"
}
fun bearerToken(): String? = token
suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build()
execute(req)
@ -755,6 +781,9 @@ data class ViewerProfile(
val isOwner: Boolean,
val isKids: Boolean,
val hasPin: Boolean,
val showAvatar: Boolean = true,
val hasCustomAvatar: Boolean = false,
val avatarUrl: String? = null,
)
data class ProfilesPayload(
@ -863,4 +892,7 @@ private fun JSONObject.toProfile(): ViewerProfile = ViewerProfile(
isOwner = optBoolean("isOwner"),
isKids = optBoolean("isKids"),
hasPin = optBoolean("hasPin"),
showAvatar = if (has("showAvatar")) optBoolean("showAvatar") else true,
hasCustomAvatar = optBoolean("hasCustomAvatar"),
avatarUrl = optString("avatarUrl").ifBlank { null },
)

View file

@ -17,6 +17,8 @@ class SessionStore(private val context: Context) {
private val profileIdKey = stringPreferencesKey("profile_id")
private val profileNameKey = stringPreferencesKey("profile_name")
private val profileAvatarKey = stringPreferencesKey("profile_avatar")
private val profileShowAvatarKey = stringPreferencesKey("profile_show_avatar")
private val profileAvatarUrlKey = stringPreferencesKey("profile_avatar_url")
private val recentSearchKey = stringPreferencesKey("recent_searches")
val refreshToken: Flow<String?> = context.dataStore.data.map { it[tokenKey] }
@ -26,6 +28,12 @@ class SessionStore(private val context: Context) {
val profileId: Flow<String?> = context.dataStore.data.map { it[profileIdKey] }
val profileName: Flow<String?> = context.dataStore.data.map { it[profileNameKey] }
val profileAvatarId: Flow<String?> = context.dataStore.data.map { it[profileAvatarKey] }
val profileShowAvatar: Flow<Boolean> = context.dataStore.data.map {
it[profileShowAvatarKey] != "0"
}
val profileAvatarUrl: Flow<String?> = context.dataStore.data.map {
it[profileAvatarUrlKey]?.takeIf { u -> u.isNotBlank() }
}
val recentSearches: Flow<List<String>> = context.dataStore.data.map { prefs ->
val raw = prefs[recentSearchKey].orEmpty()
@ -48,23 +56,40 @@ class SessionStore(private val context: Context) {
prefs.remove(profileIdKey)
prefs.remove(profileNameKey)
prefs.remove(profileAvatarKey)
prefs.remove(profileShowAvatarKey)
prefs.remove(profileAvatarUrlKey)
} else {
prefs[tokenKey] = token
}
}
}
suspend fun setProfile(id: String?, name: String? = null, avatarId: String? = null) {
suspend fun setProfile(
id: String?,
name: String? = null,
avatarId: String? = null,
showAvatar: Boolean? = null,
avatarUrl: String? = null,
) {
context.dataStore.edit { prefs ->
if (id.isNullOrBlank()) {
prefs.remove(profileIdKey)
prefs.remove(profileNameKey)
prefs.remove(profileAvatarKey)
prefs.remove(profileShowAvatarKey)
prefs.remove(profileAvatarUrlKey)
} else {
prefs[profileIdKey] = id
if (!name.isNullOrBlank()) prefs[profileNameKey] = name else prefs.remove(profileNameKey)
if (!avatarId.isNullOrBlank()) prefs[profileAvatarKey] = avatarId
else prefs.remove(profileAvatarKey)
if (showAvatar != null) {
prefs[profileShowAvatarKey] = if (showAvatar) "1" else "0"
}
if (avatarUrl != null) {
if (avatarUrl.isBlank()) prefs.remove(profileAvatarUrlKey)
else prefs[profileAvatarUrlKey] = avatarUrl
}
}
}
}

View file

@ -108,6 +108,8 @@ fun McNav(
var approveError by remember { mutableStateOf<String?>(null) }
var approveDoneName by remember { mutableStateOf<String?>(null) }
val profileAvatarId by store.profileAvatarId.collectAsState(initial = null)
val profileAvatarUrl by store.profileAvatarUrl.collectAsState(initial = null)
val profileShowAvatar by store.profileShowAvatar.collectAsState(initial = true)
val profileName by store.profileName.collectAsState(initial = null)
// Blijft behouden als main uit compositie gaat (bijv. live player)
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
@ -133,7 +135,13 @@ fun McNav(
if (authed) {
val me = runCatching { api.clientMe() }.getOrNull()
if (me?.profile != null) {
store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId)
store.setProfile(
me.profile.id,
me.profile.name,
me.profile.avatarId,
me.profile.showAvatar,
me.profile.avatarUrl ?: "",
)
hasProfile = true
canManageDevices = me.profile.isOwner
} else {
@ -257,6 +265,9 @@ fun McNav(
tvFocusNonce = tvFocusNonce,
onTvFocusContent = { tvFocusNonce++ },
profileAvatarId = profileAvatarId,
profileAvatarUrl = api.absoluteUrl(profileAvatarUrl),
profileShowAvatar = profileShowAvatar,
profileAuthHeader = api.bearerToken()?.let { "Bearer $it" },
profileName = profileName,
canManageDevices = canManageDevices,
onOpenMovie = { nav.navigate("movie/$it") },
@ -440,6 +451,9 @@ private fun MainHub(
tvFocusNonce: Int,
onTvFocusContent: () -> Unit,
profileAvatarId: String?,
profileAvatarUrl: String?,
profileShowAvatar: Boolean,
profileAuthHeader: String?,
profileName: String?,
canManageDevices: Boolean,
onOpenMovie: (String) -> Unit,
@ -636,6 +650,9 @@ private fun MainHub(
MainTab.Settings -> SettingsScreen(
api = api,
profileAvatarId = profileAvatarId,
profileAvatarUrl = api.absoluteUrl(profileAvatarUrl),
profileShowAvatar = profileShowAvatar,
profileAuthHeader = api.bearerToken()?.let { "Bearer $it" },
profileName = profileName,
canManageDevices = canManageDevices,
onBack = {
@ -1238,6 +1255,9 @@ private fun ShelfBlock(
private fun SettingsScreen(
api: ApiClient,
profileAvatarId: String? = null,
profileAvatarUrl: String? = null,
profileShowAvatar: Boolean = true,
profileAuthHeader: String? = null,
profileName: String? = null,
canManageDevices: Boolean = false,
onBack: () -> Unit,
@ -1330,8 +1350,24 @@ private fun SettingsScreen(
.background(Color(0xFF2E2E3A)),
contentAlignment = Alignment.Center,
) {
if (profileShowAvatar && !profileAvatarUrl.isNullOrBlank()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(profileAvatarUrl)
.apply {
if (!profileAuthHeader.isNullOrBlank()) {
addHeader("Authorization", profileAuthHeader)
}
}
.build(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(avatarEmoji(profileAvatarId ?: "fox"), fontSize = 22.sp)
}
}
Column(Modifier.weight(1f)) {
Text(
profileName?.takeIf { it.isNotBlank() } ?: "Profiel",

View file

@ -1,9 +1,18 @@
package nl.vonas.mediacluster.tv.ui.profiles
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Base64
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -14,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
@ -25,11 +35,13 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -39,23 +51,34 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.data.ViewerProfile
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
import java.io.ByteArrayOutputStream
import kotlin.math.roundToInt
private enum class ProfilesMode { Pick, Pin, Create, Edit, DeleteConfirm }
@ -81,20 +104,65 @@ fun ProfilesScreen(
var createKids by remember { mutableStateOf(false) }
var createPin by remember { mutableStateOf("") }
var clearPin by remember { mutableStateOf(false) }
var createShowAvatar by remember { mutableStateOf(true) }
var pendingAvatarBase64 by remember { mutableStateOf<String?>(null) }
var pendingAvatarPreview by remember { mutableStateOf<Bitmap?>(null) }
var clearCustomAvatar by remember { mutableStateOf(false) }
var cropSource by remember { mutableStateOf<Bitmap?>(null) }
var editingId by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
fun resetAvatarDraft() {
pendingAvatarBase64 = null
pendingAvatarPreview = null
clearCustomAvatar = false
cropSource = null
}
fun openEdit(profile: ViewerProfile) {
editingId = profile.id
createName = profile.name
createAvatar = profile.avatarId
createKids = profile.isKids
createShowAvatar = profile.showAvatar
createPin = ""
clearPin = false
resetAvatarDraft()
pending = profile
mode = ProfilesMode.Edit
error = null
}
fun persistLocalProfile(saved: ViewerProfile) {
scope.launch {
val localId = store.profileId.first()
if (localId == saved.id || editingId == localId) {
store.setProfile(
saved.id,
saved.name,
saved.avatarId,
saved.showAvatar,
saved.avatarUrl ?: "",
)
}
}
}
val photoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
val bmp = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream)
}
}
if (bmp != null) cropSource = bmp
else error = "Foto laden mislukt"
}
}
fun reload() {
scope.launch {
loading = true
@ -129,7 +197,13 @@ fun ProfilesScreen(
error = null
runCatching { api.selectProfile(profile.id, pinValue) }
.onSuccess { selected ->
store.setProfile(selected.id, selected.name, selected.avatarId)
store.setProfile(
selected.id,
selected.name,
selected.avatarId,
selected.showAvatar,
selected.avatarUrl ?: "",
)
onProfileSelected()
}
.onFailure {
@ -195,6 +269,8 @@ fun ProfilesScreen(
createKids = false
createPin = ""
clearPin = false
createShowAvatar = true
resetAvatarDraft()
mode = ProfilesMode.Create
},
)
@ -217,12 +293,29 @@ fun ProfilesScreen(
},
)
ProfilesMode.Create, ProfilesMode.Edit -> ProfileFormContent(
ProfilesMode.Create, ProfilesMode.Edit -> {
ProfileFormContent(
title = if (mode == ProfilesMode.Edit) "Profiel bewerken" else "Nieuw profiel",
name = createName,
onNameChange = { createName = it.take(32) },
avatarId = createAvatar,
onAvatarChange = { createAvatar = it },
showAvatar = createShowAvatar,
onShowAvatarToggle = { createShowAvatar = !createShowAvatar },
hasCustomAvatar = pending?.hasCustomAvatar == true && !clearCustomAvatar,
customAvatarUrl = api.absoluteUrl(pending?.avatarUrl),
authHeader = api.bearerToken()?.let { "Bearer $it" },
pendingPreview = pendingAvatarPreview,
onPickPhoto = {
photoPicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
)
},
onClearCustomPhoto = {
pendingAvatarBase64 = null
pendingAvatarPreview = null
clearCustomAvatar = true
},
isKids = createKids,
onKidsToggle = { createKids = !createKids },
showKidsToggle = canManage && (mode == ProfilesMode.Create || pending?.isOwner != true),
@ -247,6 +340,7 @@ fun ProfilesScreen(
mode = ProfilesMode.Pick
editingId = null
pending = null
resetAvatarDraft()
error = null
}
},
@ -257,28 +351,40 @@ fun ProfilesScreen(
error = null
val editId = editingId
runCatching {
if (editId != null) {
var saved = if (editId != null) {
api.updateProfile(
profileId = editId,
name = createName.trim(),
avatarId = createAvatar,
showAvatar = createShowAvatar,
clearAvatarImage = clearCustomAvatar && pendingAvatarBase64 == null,
isKids = if (canManage && pending?.isOwner != true) createKids else null,
pin = createPin.ifBlank { null },
clearPin = clearPin,
)
} else {
api.createProfile(
var created = api.createProfile(
name = createName.trim(),
avatarId = createAvatar,
isKids = createKids,
pin = createPin.ifBlank { null },
)
if (!createShowAvatar) {
created = api.updateProfile(
profileId = created.id,
showAvatar = false,
)
}
created
}
val avatarB64 = pendingAvatarBase64
if (avatarB64 != null) {
saved = api.uploadProfileAvatar(saved.id, avatarB64)
}
saved
}.onSuccess { saved ->
val localId = store.profileId.first()
if (localId == saved.id || editId == localId) {
store.setProfile(saved.id, saved.name, saved.avatarId)
}
persistLocalProfile(saved)
resetAvatarDraft()
if (!switcherMode && !canManage) {
onProfileSelected()
} else {
@ -294,6 +400,23 @@ fun ProfilesScreen(
}
},
)
cropSource?.let { source ->
SquareCropDialog(
bitmap = source,
onDismiss = { cropSource = null },
onConfirm = { cropped ->
cropSource = null
clearCustomAvatar = false
pendingAvatarPreview = cropped
scope.launch {
pendingAvatarBase64 = withContext(Dispatchers.IO) {
jpegBase64(cropped)
}
}
},
)
}
}
ProfilesMode.DeleteConfirm -> DeleteContent(
name = pending?.name ?: "profiel",
@ -577,6 +700,14 @@ private fun ProfileFormContent(
onNameChange: (String) -> Unit,
avatarId: String,
onAvatarChange: (String) -> Unit,
showAvatar: Boolean,
onShowAvatarToggle: () -> Unit,
hasCustomAvatar: Boolean,
customAvatarUrl: String?,
authHeader: String?,
pendingPreview: Bitmap?,
onPickPhoto: () -> Unit,
onClearCustomPhoto: () -> Unit,
isKids: Boolean,
onKidsToggle: () -> Unit,
showKidsToggle: Boolean,
@ -591,6 +722,7 @@ private fun ProfileFormContent(
onCancel: () -> Unit,
onSave: () -> Unit,
) {
val ctx = LocalContext.current
Column(
Modifier
.fillMaxSize()
@ -600,6 +732,56 @@ private fun ProfileFormContent(
) {
Text(title, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(20.dp))
Text("Profielfoto", color = Color(0xFFCCCCCC), fontSize = 14.sp, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(10.dp))
Box(
Modifier
.size(96.dp)
.clip(CircleShape)
.background(Color(0xFF2A2A32)),
contentAlignment = Alignment.Center,
) {
when {
pendingPreview != null -> {
Image(
bitmap = pendingPreview.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
hasCustomAvatar && !customAvatarUrl.isNullOrBlank() -> {
AsyncImage(
model = ImageRequest.Builder(ctx)
.data(customAvatarUrl)
.apply {
if (!authHeader.isNullOrBlank()) {
addHeader("Authorization", authHeader)
}
}
.build(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
else -> Text(avatarEmoji(avatarId), fontSize = 42.sp)
}
}
Spacer(Modifier.height(12.dp))
McButton(
if (showAvatar) "Profielfoto aan/uit: aan" else "Profielfoto aan/uit: uit",
onClick = onShowAvatarToggle,
)
Spacer(Modifier.height(8.dp))
McButton("Foto kiezen", onClick = onPickPhoto)
if (hasCustomAvatar || pendingPreview != null) {
Spacer(Modifier.height(8.dp))
McButton("Foto verwijderen", onClick = onClearCustomPhoto)
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = name,
onValueChange = onNameChange,
@ -681,6 +863,99 @@ private fun ProfileFormContent(
}
}
@Composable
private fun SquareCropDialog(
bitmap: Bitmap,
onDismiss: () -> Unit,
onConfirm: (Bitmap) -> Unit,
) {
val side = minOf(bitmap.width, bitmap.height).toFloat()
val maxOffsetX = ((bitmap.width - side) / 2f).coerceAtLeast(0f)
val maxOffsetY = ((bitmap.height - side) / 2f).coerceAtLeast(0f)
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
val density = androidx.compose.ui.platform.LocalDensity.current
val previewPx = with(density) { 280.dp.toPx() }
val imgScale = previewPx / side
AlertDialog(
onDismissRequest = onDismiss,
containerColor = Color(0xFF1C1C24),
title = {
Text("Foto bijsnijden", color = Color.White, fontWeight = FontWeight.SemiBold)
},
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Sleep om bij te snijden (vierkant)",
color = Color(0xFFAAAAAA),
fontSize = 13.sp,
)
Spacer(Modifier.height(12.dp))
Box(
Modifier
.size(280.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.Black)
.pointerInput(bitmap) {
detectDragGestures { change, drag ->
change.consume()
offsetX = (offsetX + drag.x / imgScale)
.coerceIn(-maxOffsetX, maxOffsetX)
offsetY = (offsetY + drag.y / imgScale)
.coerceIn(-maxOffsetY, maxOffsetY)
}
},
contentAlignment = Alignment.Center,
) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.size(
width = with(density) { (bitmap.width * imgScale).toDp() },
height = with(density) { (bitmap.height * imgScale).toDp() },
)
.offset {
IntOffset(
(offsetX * imgScale).roundToInt(),
(offsetY * imgScale).roundToInt(),
)
},
contentScale = ContentScale.FillBounds,
)
}
}
},
confirmButton = {
McButton("Gebruik", primary = true, onClick = {
onConfirm(cropSquare(bitmap, offsetX, offsetY))
})
},
dismissButton = {
McButton("Annuleer", onClick = onDismiss)
},
)
}
private fun cropSquare(bitmap: Bitmap, offsetX: Float, offsetY: Float): Bitmap {
val side = minOf(bitmap.width, bitmap.height)
val centerX = bitmap.width / 2f + offsetX
val centerY = bitmap.height / 2f + offsetY
val left = (centerX - side / 2f).roundToInt().coerceIn(0, bitmap.width - side)
val top = (centerY - side / 2f).roundToInt().coerceIn(0, bitmap.height - side)
val square = Bitmap.createBitmap(bitmap, left, top, side, side)
val outSize = 512.coerceAtMost(side)
return if (square.width == outSize) square
else Bitmap.createScaledBitmap(square, outSize, outSize, true)
}
private fun jpegBase64(bitmap: Bitmap): String {
val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, baos)
return Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun DeleteContent(

View file

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

View file

@ -251,6 +251,8 @@ class ApiClient(
seriesId: String? = null,
positionMs: Long,
durationMs: Long,
completed: Boolean? = null,
hiddenFromContinue: Boolean? = null,
) = withContext(Dispatchers.IO) {
val o = JSONObject()
.put("mediaType", mediaType)
@ -259,6 +261,8 @@ class ApiClient(
if (movieId != null) o.put("movieId", movieId)
if (episodeId != null) o.put("episodeId", episodeId)
if (seriesId != null) o.put("seriesId", seriesId)
if (completed != null) o.put("completed", completed)
if (hiddenFromContinue != null) o.put("hiddenFromContinue", hiddenFromContinue)
val req = authed("$baseUrl/api/v1/client/progress")
.post(o.toString().toRequestBody(jsonMedia))
.build()
@ -368,6 +372,8 @@ class ApiClient(
profileId: String,
name: String? = null,
avatarId: String? = null,
showAvatar: Boolean? = null,
clearAvatarImage: Boolean = false,
isKids: Boolean? = null,
pin: String? = null,
clearPin: Boolean = false,
@ -375,6 +381,8 @@ class ApiClient(
val body = JSONObject()
if (name != null) body.put("name", name)
if (avatarId != null) body.put("avatarId", avatarId)
if (showAvatar != null) body.put("showAvatar", showAvatar)
if (clearAvatarImage) body.put("clearAvatarImage", true)
if (isKids != null) body.put("isKids", isKids)
if (clearPin) body.put("clearPin", true)
else if (!pin.isNullOrBlank()) body.put("pin", pin)
@ -384,6 +392,28 @@ class ApiClient(
parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() }
}
suspend fun uploadProfileAvatar(
profileId: String,
imageBase64: String,
mimeType: String = "image/jpeg",
): ViewerProfile = withContext(Dispatchers.IO) {
val body = JSONObject()
.put("imageBase64", imageBase64)
.put("mimeType", mimeType)
val req = authed("$baseUrl/api/v1/client/profiles/$profileId/avatar")
.post(body.toString().toRequestBody(jsonMedia))
.build()
parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() }
}
fun absoluteUrl(path: String?): String? {
if (path.isNullOrBlank()) return null
if (path.startsWith("http")) return path
return "$baseUrl$path"
}
fun bearerToken(): String? = token
suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build()
execute(req)
@ -739,6 +769,9 @@ data class ViewerProfile(
val isOwner: Boolean,
val isKids: Boolean,
val hasPin: Boolean,
val showAvatar: Boolean = true,
val hasCustomAvatar: Boolean = false,
val avatarUrl: String? = null,
)
data class ProfilesPayload(
@ -849,4 +882,7 @@ private fun JSONObject.toProfile(): ViewerProfile = ViewerProfile(
isOwner = optBoolean("isOwner"),
isKids = optBoolean("isKids"),
hasPin = optBoolean("hasPin"),
showAvatar = if (has("showAvatar")) optBoolean("showAvatar") else true,
hasCustomAvatar = optBoolean("hasCustomAvatar"),
avatarUrl = optString("avatarUrl").ifBlank { null },
)

View file

@ -18,6 +18,8 @@ class SessionStore(private val context: Context) {
private val profileIdKey = stringPreferencesKey("profile_id")
private val profileNameKey = stringPreferencesKey("profile_name")
private val profileAvatarKey = stringPreferencesKey("profile_avatar")
private val profileShowAvatarKey = stringPreferencesKey("profile_show_avatar")
private val profileAvatarUrlKey = stringPreferencesKey("profile_avatar_url")
private val recentSearchKey = stringPreferencesKey("recent_searches")
private val homeCacheKey = stringPreferencesKey("home_cache_json")
private val tvFavoritesKey = stringPreferencesKey("tv_favorites")
@ -31,6 +33,12 @@ class SessionStore(private val context: Context) {
val profileId: Flow<String?> = context.dataStore.data.map { it[profileIdKey] }
val profileName: Flow<String?> = context.dataStore.data.map { it[profileNameKey] }
val profileAvatarId: Flow<String?> = context.dataStore.data.map { it[profileAvatarKey] }
val profileShowAvatar: Flow<Boolean> = context.dataStore.data.map {
it[profileShowAvatarKey] != "0"
}
val profileAvatarUrl: Flow<String?> = context.dataStore.data.map {
it[profileAvatarUrlKey]?.takeIf { u -> u.isNotBlank() }
}
val recentSearches: Flow<List<String>> = context.dataStore.data.map { prefs ->
val raw = prefs[recentSearchKey].orEmpty()
@ -67,23 +75,40 @@ class SessionStore(private val context: Context) {
prefs.remove(profileIdKey)
prefs.remove(profileNameKey)
prefs.remove(profileAvatarKey)
prefs.remove(profileShowAvatarKey)
prefs.remove(profileAvatarUrlKey)
} else {
prefs[tokenKey] = token
}
}
}
suspend fun setProfile(id: String?, name: String? = null, avatarId: String? = null) {
suspend fun setProfile(
id: String?,
name: String? = null,
avatarId: String? = null,
showAvatar: Boolean? = null,
avatarUrl: String? = null,
) {
context.dataStore.edit { prefs ->
if (id.isNullOrBlank()) {
prefs.remove(profileIdKey)
prefs.remove(profileNameKey)
prefs.remove(profileAvatarKey)
prefs.remove(profileShowAvatarKey)
prefs.remove(profileAvatarUrlKey)
} else {
prefs[profileIdKey] = id
if (!name.isNullOrBlank()) prefs[profileNameKey] = name else prefs.remove(profileNameKey)
if (!avatarId.isNullOrBlank()) prefs[profileAvatarKey] = avatarId
else prefs.remove(profileAvatarKey)
if (showAvatar != null) {
prefs[profileShowAvatarKey] = if (showAvatar) "1" else "0"
}
if (avatarUrl != null) {
if (avatarUrl.isBlank()) prefs.remove(profileAvatarUrlKey)
else prefs[profileAvatarUrlKey] = avatarUrl
}
}
}
}

View file

@ -370,6 +370,45 @@ fun BrowseScreen(
continueMenu = null
}
}
McButton("Markeer als gezien") {
if (!menuReady) return@McButton
scope.launch {
runCatching {
if (menuItem.type.equals("movie", true)) {
api.markWatched(movieId = menuItem.id)
} else {
api.markWatched(episodeId = menuItem.id)
}
items = items.filterNot { it.id == menuItem.id && it.progressId == menuItem.progressId }
total = (total - 1).coerceAtLeast(0)
}.onFailure { error = it.message }
continueMenu = null
}
}
when {
menuItem.type.equals("movie", true) -> {
McButton("Ga naar film") {
if (!menuReady) return@McButton
continueMenu = null
onOpenMovie(menuItem.id)
}
}
!menuItem.seriesId.isNullOrBlank() -> {
McButton("Ga naar serie") {
if (!menuReady) return@McButton
val sid = menuItem.seriesId!!
continueMenu = null
onOpenSeries(sid)
}
}
menuItem.type.equals("series", true) -> {
McButton("Ga naar serie") {
if (!menuReady) return@McButton
continueMenu = null
onOpenSeries(menuItem.id)
}
}
}
McButton("Annuleren") { continueMenu = null }
}
}

View file

@ -34,12 +34,21 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
import nl.vonas.mediacluster.tv.ui.components.formatEndsAt
import nl.vonas.mediacluster.tv.ui.components.rememberClockTickMs
import nl.vonas.mediacluster.tv.ui.components.remainingMsFromRuntimeMinutes
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
@ -245,9 +254,9 @@ private fun DetailActionButton(
focusRequester: FocusRequester? = null,
onClick: () -> Unit,
) {
val bg = if (primary) Color.White else Color(0xE6181818)
val bgFocus = if (primary) Color(0xFFE8E8E8) else Color.White
val fg = if (primary) Color.Black else Color.White
val bg = Color(0xE6181818)
val bgFocus = Color.White
val fg = Color.White
Surface(
onClick = onClick,
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
@ -260,20 +269,16 @@ private fun DetailActionButton(
contentColor = fg,
focusedContentColor = Color.Black,
),
border = if (primary) {
ClickableSurfaceDefaults.border()
} else {
ClickableSurfaceDefaults.border(
border = ClickableSurfaceDefaults.border(
border = Border(
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)),
shape = RoundedCornerShape(4.dp),
),
focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White),
border = androidx.compose.foundation.BorderStroke(2.dp, Color.White),
shape = RoundedCornerShape(4.dp),
),
)
},
),
) {
Text(
text = label,
@ -286,6 +291,52 @@ private fun DetailActionButton(
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun ExpandableOverview(
text: String,
modifier: Modifier = Modifier,
collapsedLines: Int = 4,
) {
var expanded by remember { mutableStateOf(false) }
Surface(
onClick = { expanded = !expanded },
modifier = modifier,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.01f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = Color(0x22FFFFFF),
contentColor = McColors.TextDim,
focusedContentColor = Color.White,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x66FFFFFF)),
shape = RoundedCornerShape(6.dp),
),
),
) {
Column(Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) {
Text(
text,
color = Color.Unspecified,
fontSize = 15.sp,
maxLines = if (expanded) Int.MAX_VALUE else collapsedLines,
overflow = TextOverflow.Ellipsis,
lineHeight = 22.sp,
)
Spacer(Modifier.height(4.dp))
Text(
if (expanded) "OK · inklappen" else "OK · alles lezen",
color = Color(0xFFAAAAAA),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun MovieDetailScreen(
@ -364,6 +415,10 @@ fun MovieDetailScreen(
}
val selected = versions.firstOrNull { it.id == selectedVersionId } ?: versions.firstOrNull()
val nowTick = rememberClockTickMs()
val endsAtLabel = remember(runtime, nowTick) {
remainingMsFromRuntimeMinutes(runtime)?.let { formatEndsAt(it, nowTick) }
}
val metaParts = buildList {
year?.let { add(it.toString()) }
formatRuntime(runtime)?.let(::add)
@ -379,7 +434,7 @@ fun MovieDetailScreen(
Modifier
.fillMaxHeight()
.fillMaxWidth(0.58f)
.padding(start = 44.dp, end = 20.dp, top = 24.dp, bottom = 24.dp)
.padding(start = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset, top = 24.dp, bottom = 24.dp)
.verticalScroll(scroll),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
@ -387,6 +442,14 @@ fun MovieDetailScreen(
Spacer(Modifier.height(4.dp))
DetailLogoOrTitle(logoUrl, title)
MetaChips(metaParts)
if (endsAtLabel != null) {
Text(
endsAtLabel,
color = Color(0xFF9A9AA8),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
}
if (versions.size > 1) {
SelectedQualityLine(selected)
}
@ -415,14 +478,7 @@ fun MovieDetailScreen(
}
}
if (overview.isNotBlank()) {
Text(
overview,
color = McColors.TextDim,
fontSize = 15.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
lineHeight = 21.sp,
)
ExpandableOverview(overview)
}
if (director.isNotEmpty()) {
Text(
@ -539,11 +595,38 @@ fun SeriesDetailScreen(
var genres by remember { mutableStateOf<List<String>>(emptyList()) }
var trailerKey by remember { mutableStateOf<String?>(null) }
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) }
var selectedSeason by remember { mutableIntStateOf(0) }
var selectedSeason by rememberSaveable(seriesId) { mutableStateOf<Int?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var onWatchlist by remember { mutableStateOf(false) }
var episodeMenu by remember { mutableStateOf<EpisodeRow?>(null) }
var menuReady by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val firstEpFocus = remember { FocusRequester() }
val menuFocus = remember { FocusRequester() }
fun applyEpisodeCompleted(episodeId: String, completed: Boolean) {
seasons = seasons.map { season ->
val eps = season.optJSONArray("episodes") ?: return@map season
val copy = JSONObject(season.toString())
val newEps = JSONArray()
for (i in 0 until eps.length()) {
val e = JSONObject(eps.getJSONObject(i).toString())
if (e.optString("id") == episodeId) {
e.put("completed", completed)
if (completed) {
e.put("positionMs", 1)
e.put("durationMs", 1)
} else {
e.put("positionMs", 0)
e.put("durationMs", 0)
}
}
newEps.put(e)
}
copy.put("episodes", newEps)
copy
}
}
LaunchedEffect(seriesId) {
try {
@ -565,14 +648,18 @@ fun SeriesDetailScreen(
for (i in 0 until arr.length()) list += arr.getJSONObject(i)
}
seasons = list
selectedSeason = list.firstOrNull()?.optInt("seasonNumber") ?: 0
val current = selectedSeason
if (current == null || list.none { it.optInt("seasonNumber") == current }) {
selectedSeason = list.firstOrNull()?.optInt("seasonNumber")
}
} catch (e: Exception) {
error = e.message
}
}
val episodes = remember(seasons, selectedSeason) {
val season = seasons.firstOrNull { it.optInt("seasonNumber") == selectedSeason }
val seasonNumber = selectedSeason ?: 0
val episodes = remember(seasons, seasonNumber) {
val season = seasons.firstOrNull { it.optInt("seasonNumber") == seasonNumber }
val eps = season?.optJSONArray("episodes") ?: return@remember emptyList()
buildList {
for (i in 0 until eps.length()) {
@ -598,10 +685,14 @@ fun SeriesDetailScreen(
}
}
LaunchedEffect(episodes) {
if (episodes.isNotEmpty()) {
LaunchedEffect(episodeMenu) {
if (episodeMenu != null) {
menuReady = false
delay(80)
runCatching { firstEpFocus.requestFocus() }
menuReady = true
runCatching { menuFocus.requestFocus() }
} else {
menuReady = false
}
}
@ -610,12 +701,22 @@ fun SeriesDetailScreen(
genres.take(3).forEach(::add)
rating?.let { add("★ $it") }
}
val playEpPreview = remember(episodes) {
episodes.firstOrNull { !it.completed && it.positionMs > 30_000L }
?: episodes.firstOrNull { !it.completed }
?: episodes.firstOrNull()
}
BackHandler(onBack = onBack)
Box(Modifier.fillMaxSize()) {
DetailBackdrop(backdrop)
LazyColumn(
contentPadding = PaddingValues(start = 44.dp, end = 44.dp, top = 28.dp, bottom = 40.dp),
contentPadding = PaddingValues(
start = HomeChrome.EdgeInset,
end = HomeChrome.EdgeInset,
top = 28.dp,
bottom = 40.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
@ -624,25 +725,15 @@ fun SeriesDetailScreen(
DetailLogoOrTitle(logoUrl, title)
MetaChips(metaParts)
if (overview.isNotBlank()) {
Text(
ExpandableOverview(
overview,
color = McColors.TextDim,
fontSize = 15.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
lineHeight = 22.sp,
modifier = Modifier.fillMaxWidth(0.72f).padding(top = 4.dp),
)
}
if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val playEp = remember(episodes) {
episodes.firstOrNull { !it.completed && it.positionMs > 30_000L }
?: episodes.firstOrNull { !it.completed }
?: episodes.firstOrNull()
}
playEp?.let { ep ->
playEpPreview?.let { ep ->
val resume = !ep.completed && ep.positionMs > 30_000L
DetailActionButton(
if (resume) "Verder kijken" else "Afspelen",
@ -675,7 +766,7 @@ fun SeriesDetailScreen(
val n = s.optInt("seasonNumber")
DetailActionButton(
"Seizoen $n",
primary = n == selectedSeason,
primary = n == seasonNumber,
) { selectedSeason = n }
}
}
@ -683,7 +774,7 @@ fun SeriesDetailScreen(
Spacer(Modifier.height(4.dp))
Text("Afleveringen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
itemsIndexed(episodes) { index, ep ->
itemsIndexed(episodes) { _, ep ->
EpisodeCard(
episode = ep,
focusRequester = null,
@ -695,9 +786,80 @@ fun SeriesDetailScreen(
if (ep.completed) 0L else ep.positionMs,
)
},
onLongClick = { episodeMenu = ep },
)
}
}
val menuEp = episodeMenu
if (menuEp != null) {
BackHandler { episodeMenu = null }
Dialog(
onDismissRequest = { episodeMenu = null },
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
),
) {
Box(
Modifier
.fillMaxSize()
.background(Color(0xCC000000)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.width(440.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF1A1A1A))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
"S${menuEp.season}E${menuEp.number} · ${menuEp.title}",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
)
Spacer(Modifier.height(4.dp))
if (menuEp.completed) {
McButton("Markeer als niet gekeken", focusRequester = menuFocus) {
if (!menuReady) return@McButton
scope.launch {
runCatching {
api.postProgress(
mediaType = "EPISODE",
episodeId = menuEp.id,
seriesId = seriesId,
positionMs = 0,
durationMs = 0,
completed = false,
hiddenFromContinue = true,
)
applyEpisodeCompleted(menuEp.id, false)
}.onFailure { error = it.message }
episodeMenu = null
}
}
} else {
McButton("Markeer als gezien", focusRequester = menuFocus) {
if (!menuReady) return@McButton
scope.launch {
runCatching {
api.markWatched(episodeId = menuEp.id)
applyEpisodeCompleted(menuEp.id, true)
}.onFailure { error = it.message }
episodeMenu = null
}
}
}
McButton("Annuleren") { episodeMenu = null }
}
}
}
}
}
}
@ -728,11 +890,21 @@ private fun EpisodeCard(
episode: EpisodeRow,
focusRequester: FocusRequester?,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
) {
var focused by remember { mutableStateOf(false) }
val endsAt = if (focused) {
remainingMsFromRuntimeMinutes(episode.runtime, episode.positionMs, episode.completed)
?.let { formatEndsAt(it) }
} else {
null
}
Surface(
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focused = it.isFocused }
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
@ -817,9 +989,14 @@ private fun EpisodeCard(
lineHeight = 18.sp,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
formatRuntime(episode.runtime)?.let {
Text(it, color = Color(0xFF888888), fontSize = 12.sp)
}
if (endsAt != null) {
Text(endsAt, color = Color(0xFFFFD54F), fontSize = 12.sp, fontWeight = FontWeight.Medium)
}
}
}
}
}

View file

@ -119,6 +119,8 @@ fun McNav() {
var hasProfile by remember { mutableStateOf(false) }
var isOwner by remember { mutableStateOf(false) }
val profileAvatarId by store.profileAvatarId.collectAsState(initial = null)
val profileShowAvatar by store.profileShowAvatar.collectAsState(initial = true)
val profileAvatarUrl by store.profileAvatarUrl.collectAsState(initial = null)
val profileName by store.profileName.collectAsState(initial = null)
// Blijft behouden als main uit compositie gaat (bijv. live player)
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
@ -145,7 +147,13 @@ fun McNav() {
val me = runCatching { api.clientMe() }.getOrNull()
when {
me?.profile != null -> {
store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId)
store.setProfile(
me.profile.id,
me.profile.name,
me.profile.avatarId,
me.profile.showAvatar,
me.profile.avatarUrl,
)
hasProfile = true
isOwner = me.profile.isOwner
}
@ -284,6 +292,8 @@ fun McNav() {
tvFocusNonce = tvFocusNonce,
onTvFocusContent = { tvFocusNonce++ },
profileAvatarId = profileAvatarId,
profileShowAvatar = profileShowAvatar,
profileAvatarUrl = profileAvatarUrl,
profileName = profileName,
onOpenMovie = { nav.navigate("movie/$it") },
onOpenSeries = { nav.navigate("series/$it") },
@ -442,6 +452,8 @@ private fun MainHub(
tvFocusNonce: Int,
onTvFocusContent: () -> Unit,
profileAvatarId: String?,
profileShowAvatar: Boolean = true,
profileAvatarUrl: String? = null,
profileName: String?,
onOpenMovie: (String) -> Unit,
onOpenSeries: (String) -> Unit,
@ -457,6 +469,8 @@ private fun MainHub(
var navFocused by remember { mutableStateOf(false) }
var exitConfirm by remember { mutableStateOf(false) }
var homeChromeCompact by remember { mutableStateOf(false) }
var topNavFocusToken by remember { mutableIntStateOf(0) }
var coldStartNavFocus by rememberSaveable { mutableStateOf(true) }
val navFocus = remember { FocusRequester() }
val homeContentFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() }
@ -487,7 +501,9 @@ private fun MainHub(
}
val visibleTabs = remember(iptvEnabled) {
MainTab.entries.filter { it != MainTab.Settings && (it != MainTab.Tv || iptvEnabled) }
MainTab.entries.filter {
it != MainTab.Settings && it != MainTab.Search && (it != MainTab.Tv || iptvEnabled)
}
}
val movieSubs = remember(shelfPrefs) {
@ -532,9 +548,15 @@ private fun MainHub(
fun focusTopNav() {
navFocused = true
exitConfirm = false
hubScope.launch {
delay(40)
runCatching { navFocus.requestFocus() }
topNavFocusToken++
}
// App-start: focus op Home in het menu, niet op Afspelen in de hero
LaunchedEffect(Unit) {
if (coldStartNavFocus) {
coldStartNavFocus = false
delay(120)
focusTopNav()
}
}
@ -554,7 +576,7 @@ private fun MainHub(
focusTopNav()
}
}
// Films/Series/…: Back in top-nav → Home (kort uitgeschakeld na tab-wissel tegen race)
// Films/Series/…: Back in top-nav → Home
var navBackToHomeArmed by remember { mutableStateOf(false) }
LaunchedEffect(tab, navFocused) {
navBackToHomeArmed = false
@ -577,13 +599,6 @@ private fun MainHub(
focusTopNav()
}
LaunchedEffect(navFocused) {
if (navFocused) {
delay(40)
runCatching { navFocus.requestFocus() }
}
}
LaunchedEffect(tvFocusNonce) {
if (tab == MainTab.Tv) {
navFocused = false
@ -617,13 +632,22 @@ private fun MainHub(
compact = tab == MainTab.Home && homeChromeCompact,
overlay = overlay,
profileAvatarEmoji = avatarEmoji(profileAvatarId ?: "fox"),
profileAvatarUrl = api.absoluteUrl(profileAvatarUrl),
profileShowAvatar = profileShowAvatar,
profileAuthHeader = api.bearerToken()?.let { "Bearer $it" },
profileLabel = profileName,
onProfileClick = onSwitchProfile,
onSearchClick = {
onTabChange(MainTab.Search)
exitConfirm = false
homeChromeCompact = false
focusTopNav()
},
onSettingsClick = {
onTabChange(MainTab.Settings)
exitConfirm = false
homeChromeCompact = false
navFocused = true
focusTopNav()
},
onSelect = {
if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
@ -664,6 +688,7 @@ private fun MainHub(
contentDownFocus = if (tab == MainTab.Home) homeContentFocus else null,
subItems = subItems,
selectedSubId = selectedSubId,
focusSelectedToken = topNavFocusToken,
onSelectSub = { item ->
if (item.id == "__search__") {
onTabChange(MainTab.Search)
@ -1034,15 +1059,19 @@ private fun HomeScreen(
}
}
// Bij elke verschijning van Home (ook na serie/film/player): focus op content, niet op top-nav.
// Bij terugkeer van detail: geen jump naar Afspelen/top. Alleen content-focus als we gescrolld waren.
LaunchedEffect(Unit) {
delay(80)
val scrolled = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 40
if (scrolled) {
onContentFocused()
val deadline = System.currentTimeMillis() + 900
while (!playFocusReady && System.currentTimeMillis() < deadline) {
val deadline = System.currentTimeMillis() + 700
while (!shelfFocusReady && System.currentTimeMillis() < deadline) {
delay(40)
}
delay(40)
runCatching { contentFocusRequester.requestFocus() }
runCatching { firstShelfFocus.requestFocus() }
}
}
val spotlight = home?.spotlight.orEmpty().ifEmpty {
@ -1186,7 +1215,7 @@ private fun HomeScreen(
Spacer(Modifier.height(shelfStart))
}
if (error != null) {
item { Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = 40.dp)) }
item { Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset)) }
}
if (continueItems.isNotEmpty()) {
val preview = continueItems.take(5)
@ -1322,7 +1351,7 @@ private fun HomeScreen(
if (!menuReady) return@McButton
scope.launch {
runCatching {
if (menuItem.type == "movie") {
if (menuItem.type.equals("movie", true)) {
api.markWatched(movieId = menuItem.id)
} else {
api.markWatched(episodeId = menuItem.id)
@ -1332,6 +1361,23 @@ private fun HomeScreen(
continueMenu = null
}
}
when {
menuItem.type.equals("movie", true) -> {
McButton("Ga naar film") {
if (!menuReady) return@McButton
continueMenu = null
onOpenMovie(menuItem.id)
}
}
!menuItem.seriesId.isNullOrBlank() -> {
McButton("Ga naar serie") {
if (!menuReady) return@McButton
val sid = menuItem.seriesId!!
continueMenu = null
onOpenSeries(sid)
}
}
}
McButton("Annuleren") { continueMenu = null }
}
}
@ -1402,7 +1448,7 @@ private fun SpotlightHero(
Modifier
.fillMaxSize()
.padding(top = HomeChrome.BadgeTop)
.padding(start = 48.dp, end = 20.dp, bottom = 36.dp),
.padding(start = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset, bottom = 36.dp),
verticalArrangement = Arrangement.Top,
) {
Column(
@ -1517,7 +1563,7 @@ private fun ShelfBlock(
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 48.dp),
.padding(horizontal = HomeChrome.EdgeInset),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@ -1553,7 +1599,12 @@ private fun ShelfBlock(
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
// Extra ruimte zodat focusedScale (1.10) posters niet afknipt
contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 28.dp, bottom = 28.dp),
contentPadding = PaddingValues(
start = HomeChrome.EdgeInset,
end = HomeChrome.EdgeInset,
top = 28.dp,
bottom = 28.dp,
),
) {
itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item ->
if (index == 0 && firstItemFocusRequester != null) {
@ -1637,7 +1688,7 @@ private fun SettingsScreen(
LazyColumn(
Modifier
.fillMaxSize()
.padding(horizontal = 48.dp, vertical = 28.dp),
.padding(horizontal = HomeChrome.EdgeInset, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(bottom = 40.dp),
) {
@ -1881,7 +1932,7 @@ private fun WatchlistScreen(
Modifier
.fillMaxSize()
.background(Color(0xFF0B0B0F))
.padding(horizontal = 40.dp, vertical = 20.dp),
.padding(horizontal = HomeChrome.EdgeInset, vertical = 20.dp),
) {
Text("Watchlist", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(6.dp))

View file

@ -77,6 +77,8 @@ import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
import nl.vonas.mediacluster.tv.playback.listTracks
import nl.vonas.mediacluster.tv.playback.selectTrack
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.components.formatEndsAt
import nl.vonas.mediacluster.tv.ui.components.remainingMsFromDuration
import nl.vonas.mediacluster.tv.ui.theme.McColors
import android.util.Base64
import java.io.File
@ -587,6 +589,11 @@ fun PlayerScreen(
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(formatTime(positionMs), color = Color(0xFFB3B3B3), fontSize = 13.sp)
val endsAtBar = remainingMsFromDuration(durationMs, positionMs)
?.let { formatEndsAt(it) }
if (endsAtBar != null) {
Text(endsAtBar, color = Color(0xFFFFD54F), fontSize = 13.sp)
}
Text(formatTime(durationMs), color = Color(0xFFB3B3B3), fontSize = 13.sp)
}

View file

@ -57,9 +57,10 @@ fun McButton(
onFocused: (() -> Unit)? = null,
onClick: () -> Unit,
) {
val bg = if (primary) Color.White else Color(0x33FFFFFF)
val bgFocus = if (primary) Color.White else Color.White
val fg = if (primary) Color.Black else Color.White
// Primary: gedempt tot focus; focused = helder wit (duidelijk verschil met buurknoppen)
val bg = if (primary) Color(0x66FFFFFF) else Color(0x33FFFFFF)
val bgFocus = Color.White
val fg = Color.White
val fgFocus = Color.Black
Surface(
onClick = onClick,
@ -89,6 +90,19 @@ fun McButton(
contentColor = fg,
focusedContentColor = fgFocus,
),
border = ClickableSurfaceDefaults.border(
border = Border(
border = androidx.compose.foundation.BorderStroke(
width = if (primary) 1.dp else 0.dp,
color = if (primary) Color(0x88FFFFFF) else Color.Transparent,
),
shape = RoundedCornerShape(4.dp),
),
focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(2.dp, Color.White),
shape = RoundedCornerShape(4.dp),
),
),
) {
Text(
text = label.ifBlank { "—" },

View file

@ -85,13 +85,13 @@ fun SkeletonShelfRow(
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
SkeletonBox(
Modifier
.padding(horizontal = 48.dp)
.padding(horizontal = HomeChrome.EdgeInset)
.width(titleWidth)
.height(18.dp),
shape = RoundedCornerShape(3.dp),
)
LazyRow(
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 12.dp),
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
items(posterCount) {
@ -108,7 +108,7 @@ fun SkeletonBillboard(height: Dp = 480.dp) {
Column(
Modifier
.align(Alignment.BottomStart)
.padding(start = 48.dp, bottom = 36.dp),
.padding(start = HomeChrome.EdgeInset, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
SkeletonBox(Modifier.width(48.dp).height(12.dp))
@ -147,7 +147,7 @@ fun EmptyState(
Column(
modifier
.fillMaxWidth()
.padding(horizontal = 48.dp, vertical = 40.dp),
.padding(horizontal = HomeChrome.EdgeInset, vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {

View file

@ -0,0 +1,50 @@
package nl.vonas.mediacluster.tv.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val timeFmt = SimpleDateFormat("HH:mm", Locale.getDefault())
/** Huidige kloktijd, vernieuwt elke 30s. */
@Composable
fun rememberClockTickMs(): Long {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (isActive) {
now = System.currentTimeMillis()
delay(30_000)
}
}
return now
}
fun formatClock(ms: Long = System.currentTimeMillis()): String =
timeFmt.format(Date(ms))
/** Eindtijd op basis van resterende speelduur. */
fun formatEndsAt(remainingMs: Long, nowMs: Long = System.currentTimeMillis()): String? {
if (remainingMs < 30_000L) return null
return "Eindigt om ${timeFmt.format(Date(nowMs + remainingMs))}"
}
fun remainingMsFromRuntimeMinutes(runtimeMin: Int?, positionMs: Long = 0L, completed: Boolean = false): Long? {
if (runtimeMin == null || runtimeMin <= 0 || completed) return null
val total = runtimeMin * 60_000L
val remaining = (total - positionMs.coerceAtLeast(0L)).coerceAtLeast(0L)
return remaining.takeIf { it >= 30_000L }
}
fun remainingMsFromDuration(durationMs: Long, positionMs: Long): Long? {
if (durationMs <= 0L) return null
val remaining = (durationMs - positionMs.coerceAtLeast(0L)).coerceAtLeast(0L)
return remaining.takeIf { it >= 30_000L }
}

View file

@ -8,49 +8,63 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import coil.request.ImageRequest
import kotlinx.coroutines.delay
import nl.vonas.mediacluster.tv.ui.theme.McColors
import androidx.compose.foundation.BorderStroke
import androidx.compose.ui.layout.ContentScale
/**
* Berekende hoogte van de Home overlay-topnav (geen subtabs).
*
* TopNavBar overlay-row:
* - vertical padding: 14.dp + 14.dp
* - tab-inhoud: 8.dp + ~21.dp tekst + 4.dp + 3.dp underline + 8.dp ≈ 44.dp
* - totaal ≈ 72.dp; TV Surface min. interactive ≈ 48.dp → veilig 76.dp
* TopNavBar overlay-row (compact, zonder underline):
* - vertical padding: 10.dp + 10.dp
* - tab-tekst ~21.dp → veilig 56.dp
*/
object HomeChrome {
val TopNavHeight = 76.dp
val TopNavHeight = 56.dp
/** Lucht tussen onderkant menu en badge. */
val BadgeGap = 12.dp
val BadgeTop = TopNavHeight + BadgeGap // 88.dp
val BadgeTop = TopNavHeight + BadgeGap
/** Horizontale schermmarge (links = rechts). */
val EdgeInset = 22.dp
}
enum class MainTab(val label: String) {
@ -87,10 +101,17 @@ fun TopNavBar(
/** Omlaag vanuit menu → Home-content (Afspelen / planken). */
contentDownFocus: FocusRequester? = null,
profileAvatarEmoji: String? = null,
profileAvatarUrl: String? = null,
profileShowAvatar: Boolean = true,
profileAuthHeader: String? = null,
profileLabel: String? = null,
onProfileClick: (() -> Unit)? = null,
/** Tandwiel links van het profiel-icoon (Settings-tab). */
/** Zoek-icoon (rechts, naast tandwiel). */
onSearchClick: (() -> Unit)? = null,
/** Tandwiel links van klok/profiel (Settings-tab). */
onSettingsClick: (() -> Unit)? = null,
/** Verhoog om focus op de actieve tab te zetten (niet op Home als eerste child). */
focusSelectedToken: Int = 0,
) {
val alpha = when {
// Overlay: transparantie zit in de gradient, niet in ekstra fade
@ -98,7 +119,7 @@ fun TopNavBar(
compact -> 0.42f
else -> 1f
}
val vPad = if (compact && !overlay) 8.dp else 14.dp
val vPad = if (compact && !overlay) 6.dp else 10.dp
val downLink = Modifier.then(
if (contentDownFocus != null) {
Modifier.focusProperties { down = contentDownFocus }
@ -106,7 +127,27 @@ fun TopNavBar(
Modifier
},
)
// Semi-transparant of vast: home overlay vs vaste tabs
val textTabs = remember(visibleTabs) {
visibleTabs.filter { it != MainTab.Search && it != MainTab.Settings }
}
val tabFocus = remember(textTabs) {
textTabs.associateWith { FocusRequester() }
}
val searchFocus = remember { FocusRequester() }
val settingsFocus = remember { FocusRequester() }
LaunchedEffect(focusSelectedToken, selected) {
if (focusSelectedToken <= 0) return@LaunchedEffect
delay(50)
when {
selected == MainTab.Search && onSearchClick != null ->
runCatching { searchFocus.requestFocus() }
selected == MainTab.Settings && onSettingsClick != null ->
runCatching { settingsFocus.requestFocus() }
else -> runCatching { tabFocus[selected]?.requestFocus() }
}
}
Column(
modifier
.fillMaxWidth()
@ -128,7 +169,7 @@ fun TopNavBar(
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp, vertical = vPad),
.padding(horizontal = HomeChrome.EdgeInset, vertical = vPad),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@ -137,33 +178,42 @@ fun TopNavBar(
color = McColors.Brand,
fontSize = if (compact && !overlay) 22.sp else 28.sp,
fontWeight = FontWeight.Black,
modifier = Modifier.padding(end = 16.dp),
modifier = Modifier
.height(if (compact && !overlay) 32.dp else 36.dp)
.padding(end = 12.dp)
.wrapContentHeight(Alignment.CenterVertically),
)
visibleTabs.forEach { tab ->
textTabs.forEach { tab ->
val active = selected == tab
val color by animateColorAsState(
when {
active -> Color.White
active -> McColors.Brand
overlay -> Color(0xFFE8E8E8)
else -> Color(0xFF808080)
},
label = "tabColor",
)
val fr = tabFocus[tab]
Surface(
onClick = { onSelect(tab) },
modifier = downLink,
modifier = downLink
.height(if (compact && !overlay) 32.dp else 36.dp)
.then(if (fr != null) Modifier.focusRequester(fr) else Modifier),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (active) Color(0x33FFFFFF) else Color.Transparent,
focusedContainerColor = Color(0x44FFFFFF),
containerColor = Color.Transparent,
focusedContainerColor = Color(0x33FFFFFF),
contentColor = color,
focusedContentColor = Color.White,
// Actieve tab blijft rood, ook met focus
focusedContentColor = if (active) McColors.Brand else Color.White,
),
) {
Column(
Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
Box(
Modifier
.fillMaxHeight()
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center,
) {
Text(
tab.label,
@ -171,69 +221,123 @@ fun TopNavBar(
fontSize = 16.sp,
fontWeight = if (active) FontWeight.Bold else FontWeight.Medium,
)
Spacer(Modifier.height(4.dp))
}
}
}
if (onSearchClick != null) {
val searchActive = selected == MainTab.Search
Surface(
onClick = onSearchClick,
modifier = downLink
.height(if (compact && !overlay) 32.dp else 36.dp)
.focusRequester(searchFocus),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = Color(0x33FFFFFF),
contentColor = if (searchActive) McColors.Brand else Color.White,
focusedContentColor = if (searchActive) McColors.Brand else Color.White,
),
) {
Box(
Modifier
.width(if (active) 28.dp else 0.dp)
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(if (active) McColors.Brand else Color.Transparent),
.fillMaxHeight()
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = "Zoeken",
modifier = Modifier.size(16.dp),
tint = Color.Unspecified,
)
}
}
}
Spacer(Modifier.weight(1f))
if (onSettingsClick != null) {
val settingsActive = selected == MainTab.Settings
Surface(
onClick = onSettingsClick,
modifier = downLink,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(50)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
modifier = downLink
.height(if (compact && !overlay) 32.dp else 36.dp)
.focusRequester(settingsFocus),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (settingsActive) Color(0x33FFFFFF) else Color.Transparent,
focusedContainerColor = Color(0x55FFFFFF),
contentColor = Color.White,
focusedContentColor = Color.White,
containerColor = Color.Transparent,
focusedContainerColor = Color(0x33FFFFFF),
contentColor = if (settingsActive) McColors.Brand else Color.White,
focusedContentColor = if (settingsActive) McColors.Brand else Color.White,
),
) {
Box(
Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
Modifier
.fillMaxHeight()
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = "Instellingen",
modifier = Modifier.size(22.dp),
tint = Color.White,
modifier = Modifier.size(16.dp),
tint = Color.Unspecified,
)
}
}
}
if (onProfileClick != null && profileAvatarEmoji != null) {
Spacer(Modifier.weight(1f))
if (onProfileClick != null && (!profileLabel.isNullOrBlank() || profileAvatarEmoji != null)) {
Surface(
onClick = onProfileClick,
modifier = downLink,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(50)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0x33FFFFFF),
focusedContainerColor = Color(0x55FFFFFF),
containerColor = Color.Transparent,
focusedContainerColor = Color(0x22FFFFFF),
contentColor = Color.White,
focusedContentColor = Color.White,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(1.dp, Color.White),
shape = RoundedCornerShape(6.dp),
),
),
) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(profileAvatarEmoji, fontSize = 20.sp)
if (profileShowAvatar) {
if (!profileAvatarUrl.isNullOrBlank()) {
val ctx = LocalContext.current
AsyncImage(
model = ImageRequest.Builder(ctx)
.data(profileAvatarUrl)
.apply {
if (!profileAuthHeader.isNullOrBlank()) {
addHeader("Authorization", profileAuthHeader)
}
}
.build(),
contentDescription = null,
modifier = Modifier
.size(22.dp)
.clip(RoundedCornerShape(50)),
contentScale = ContentScale.Crop,
)
} else if (profileAvatarEmoji != null) {
Text(profileAvatarEmoji, fontSize = 15.sp)
}
}
if (!profileLabel.isNullOrBlank()) {
Text(
profileLabel,
color = Color.White,
fontSize = 14.sp,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
@ -241,11 +345,19 @@ fun TopNavBar(
}
}
}
val clockTick = rememberClockTickMs()
Text(
formatClock(clockTick),
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(start = 6.dp),
)
}
if (subItems.isNotEmpty()) {
LazyRow(
contentPadding = PaddingValues(horizontal = 40.dp, vertical = 4.dp),
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
@ -48,7 +49,9 @@ import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.data.ViewerProfile
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
import nl.vonas.mediacluster.tv.ui.theme.McColors
import androidx.compose.foundation.BorderStroke
private enum class ProfilesMode { Pick, Pin, Create, Edit, DeleteConfirm }
@ -73,8 +76,10 @@ fun ProfilesScreen(
var createKids by remember { mutableStateOf(false) }
var createPin by remember { mutableStateOf("") }
var clearPin by remember { mutableStateOf(false) }
var createShowAvatar by remember { mutableStateOf(true) }
var editingId by remember { mutableStateOf<String?>(null) }
val firstFocus = remember { FocusRequester() }
val avatarFocus = remember { FocusRequester() }
fun openEdit(profile: ViewerProfile) {
editingId = profile.id
@ -82,6 +87,7 @@ fun ProfilesScreen(
createName = profile.name
createAvatar = profile.avatarId
createKids = profile.isKids
createShowAvatar = profile.showAvatar
createPin = ""
clearPin = false
error = null
@ -116,12 +122,25 @@ fun ProfilesScreen(
runCatching { firstFocus.requestFocus() }
}
LaunchedEffect(mode) {
if (mode == ProfilesMode.Create || mode == ProfilesMode.Edit) {
kotlinx.coroutines.delay(80)
runCatching { avatarFocus.requestFocus() }
}
}
fun select(profile: ViewerProfile, pinValue: String? = null) {
scope.launch {
error = null
runCatching { api.selectProfile(profile.id, pinValue) }
.onSuccess { selected ->
store.setProfile(selected.id, selected.name, selected.avatarId)
store.setProfile(
selected.id,
selected.name,
selected.avatarId,
selected.showAvatar,
selected.avatarUrl,
)
onProfileSelected()
}
.onFailure {
@ -148,7 +167,7 @@ fun ProfilesScreen(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 48.dp, vertical = 36.dp),
.padding(horizontal = HomeChrome.EdgeInset, vertical = 36.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
@ -276,6 +295,43 @@ fun ProfilesScreen(
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(12.dp))
Text("Kies een avatar", color = Color(0xFFCCCCCC), fontSize = 14.sp)
Spacer(Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(PROFILE_AVATARS) { av ->
val selected = createAvatar == av.id
Surface(
onClick = { createAvatar = av.id },
modifier = if (selected) Modifier.focusRequester(avatarFocus) else Modifier,
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) McColors.Brand else Color(0xFF2A2A32),
focusedContainerColor = McColors.Brand,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, Color.White),
shape = CircleShape,
),
),
) {
Box(
Modifier.size(56.dp),
contentAlignment = Alignment.Center,
) {
Text(av.emoji, fontSize = 26.sp)
}
}
}
}
Spacer(Modifier.height(12.dp))
ActionChip(
if (createShowAvatar) "Profielfoto: aan" else "Profielfoto: uit",
) {
createShowAvatar = !createShowAvatar
}
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = createName,
onValueChange = { createName = it.take(32) },
@ -291,29 +347,6 @@ fun ProfilesScreen(
cursorColor = McColors.Brand,
),
)
Spacer(Modifier.height(12.dp))
Text("Kies een avatar", color = Color(0xFFCCCCCC), fontSize = 14.sp)
Spacer(Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(PROFILE_AVATARS) { av ->
Surface(
onClick = { createAvatar = av.id },
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (createAvatar == av.id) McColors.Brand else Color(0xFF2A2A32),
focusedContainerColor = McColors.Brand,
),
) {
Box(
Modifier.size(56.dp),
contentAlignment = Alignment.Center,
) {
Text(av.emoji, fontSize = 26.sp)
}
}
}
}
if (canManage && (!isEdit || pending?.isOwner != true)) {
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
@ -344,6 +377,7 @@ fun ProfilesScreen(
profileId = editId,
name = createName.trim(),
avatarId = createAvatar,
showAvatar = createShowAvatar,
isKids = if (canManage && pending?.isOwner != true) createKids else null,
pin = createPin.ifBlank { null },
clearPin = clearPin,
@ -359,7 +393,13 @@ fun ProfilesScreen(
}.onSuccess { saved ->
val localId = store.profileId.first()
if (localId == saved.id || editId == localId) {
store.setProfile(saved.id, saved.name, saved.avatarId)
store.setProfile(
saved.id,
saved.name,
saved.avatarId,
saved.showAvatar,
saved.avatarUrl,
)
}
if (!switcherMode && !canManage) {
onProfileSelected()
@ -466,6 +506,12 @@ private fun ProfileTile(
containerColor = Color(0xFF1C1C24),
focusedContainerColor = Color(0xFF2A2A36),
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, Color.White),
shape = RoundedCornerShape(14.dp),
),
),
) {
Column(
Modifier
@ -527,6 +573,12 @@ private fun AddProfileTile(onClick: () -> Unit) {
containerColor = Color(0xFF16161E),
focusedContainerColor = Color(0xFF242430),
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, Color.White),
shape = RoundedCornerShape(14.dp),
),
),
) {
Column(
Modifier
@ -583,6 +635,12 @@ private fun ActionChip(label: String, primary: Boolean = false, onClick: () -> U
contentColor = Color.White,
focusedContentColor = Color.White,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, Color.White),
shape = RoundedCornerShape(8.dp),
),
),
) {
Text(
label,

View file

@ -169,6 +169,8 @@ actor ApiClient {
profileId: String,
name: String? = nil,
avatarId: String? = nil,
showAvatar: Bool? = nil,
clearAvatarImage: Bool = false,
isKids: Bool? = nil,
pin: String? = nil,
clearPin: Bool = false
@ -176,6 +178,8 @@ actor ApiClient {
var body: [String: Any] = [:]
if let name { body["name"] = name }
if let avatarId { body["avatarId"] = avatarId }
if let showAvatar { body["showAvatar"] = showAvatar }
if clearAvatarImage { body["clearAvatarImage"] = true }
if let isKids { body["isKids"] = isKids }
if clearPin {
body["clearPin"] = true
@ -189,6 +193,24 @@ actor ApiClient {
return wrap.profile
}
func uploadProfileAvatar(profileId: String, imageBase64: String, mimeType: String = "image/jpeg") async throws -> ViewerProfile {
let encoded = profileId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? profileId
struct Wrap: Decodable { let profile: ViewerProfile }
let wrap: Wrap = try await post("/api/v1/client/profiles/\(encoded)/avatar", json: [
"imageBase64": imageBase64,
"mimeType": mimeType,
])
return wrap.profile
}
func absoluteUrl(_ path: String?) -> URL? {
guard let path, !path.isEmpty else { return nil }
if path.hasPrefix("http") { return URL(string: path) }
return URL(string: baseUrl + path)
}
func bearerToken() -> String? { token }
func deleteProfile(profileId: String) async throws {
let encoded = profileId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? profileId
let _: Empty = try await requestEmpty("/api/v1/client/profiles/\(encoded)", method: "DELETE")

View file

@ -82,7 +82,13 @@ final class AppModel: ObservableObject {
let me = try await api.clientMe()
iptvEnabled = me.iptvEnabled
if let profile = me.profile {
store.setProfile(id: profile.id, name: profile.name, avatarId: profile.avatarId)
store.setProfile(
id: profile.id,
name: profile.name,
avatarId: profile.avatarId,
showAvatar: profile.showsAvatar,
avatarUrl: profile.avatarUrl ?? ""
)
favorites.bind(profileId: profile.id)
await favorites.refresh(using: api)
if let payload = try? await api.listProfiles() {
@ -117,7 +123,13 @@ final class AppModel: ObservableObject {
}
func didSelectProfile(_ profile: ViewerProfile) {
store.setProfile(id: profile.id, name: profile.name, avatarId: profile.avatarId)
store.setProfile(
id: profile.id,
name: profile.name,
avatarId: profile.avatarId,
showAvatar: profile.showsAvatar,
avatarUrl: profile.avatarUrl ?? ""
)
favorites.bind(profileId: profile.id)
Task {
await favorites.refresh(using: api)

View file

@ -72,6 +72,12 @@ struct ViewerProfile: Decodable, Identifiable, Hashable {
let isOwner: Bool?
let isKids: Bool?
let hasPin: Bool?
let showAvatar: Bool?
let hasCustomAvatar: Bool?
let avatarUrl: String?
var showsAvatar: Bool { showAvatar ?? true }
var hasCustomPhoto: Bool { hasCustomAvatar == true }
}
struct ProfilesPayload: Decodable {

View file

@ -9,6 +9,8 @@ final class SessionStore: ObservableObject {
@Published var profileId: String?
@Published var profileName: String?
@Published var profileAvatarId: String?
@Published var profileShowAvatar: Bool = true
@Published var profileAvatarUrl: String?
private let defaults = UserDefaults.standard
private enum Key {
@ -17,6 +19,8 @@ final class SessionStore: ObservableObject {
static let profileId = "vmc.profile_id"
static let profileName = "vmc.profile_name"
static let profileAvatar = "vmc.profile_avatar"
static let profileShowAvatar = "vmc.profile_show_avatar"
static let profileAvatarUrl = "vmc.profile_avatar_url"
}
init() {
@ -25,6 +29,9 @@ final class SessionStore: ObservableObject {
profileId = defaults.string(forKey: Key.profileId)
profileName = defaults.string(forKey: Key.profileName)
profileAvatarId = defaults.string(forKey: Key.profileAvatar)
let showRaw = defaults.string(forKey: Key.profileShowAvatar)
profileShowAvatar = showRaw != "0"
profileAvatarUrl = defaults.string(forKey: Key.profileAvatarUrl)
}
var isAuthed: Bool { !(refreshToken ?? "").isEmpty }
@ -47,7 +54,13 @@ final class SessionStore: ObservableObject {
defaults.set(apiBase, forKey: Key.api)
}
func setProfile(id: String?, name: String?, avatarId: String?) {
func setProfile(
id: String?,
name: String?,
avatarId: String?,
showAvatar: Bool? = nil,
avatarUrl: String? = nil
) {
profileId = id
profileName = name
profileAvatarId = avatarId
@ -57,12 +70,29 @@ final class SessionStore: ObservableObject {
else { defaults.removeObject(forKey: Key.profileName) }
if let avatarId, !avatarId.isEmpty { defaults.set(avatarId, forKey: Key.profileAvatar) }
else { defaults.removeObject(forKey: Key.profileAvatar) }
if let showAvatar {
profileShowAvatar = showAvatar
defaults.set(showAvatar ? "1" : "0", forKey: Key.profileShowAvatar)
}
if let avatarUrl {
if avatarUrl.isEmpty {
profileAvatarUrl = nil
defaults.removeObject(forKey: Key.profileAvatarUrl)
} else {
profileAvatarUrl = avatarUrl
defaults.set(avatarUrl, forKey: Key.profileAvatarUrl)
}
}
} else {
defaults.removeObject(forKey: Key.profileId)
defaults.removeObject(forKey: Key.profileName)
defaults.removeObject(forKey: Key.profileAvatar)
defaults.removeObject(forKey: Key.profileShowAvatar)
defaults.removeObject(forKey: Key.profileAvatarUrl)
profileName = nil
profileAvatarId = nil
profileShowAvatar = true
profileAvatarUrl = nil
}
}
}

View file

@ -49,6 +49,8 @@
<string>_http._tcp</string>
<string>_https._tcp</string>
</array>
<key>NSPhotoLibraryUsageDescription</key>
<string>VMC gebruikt je fotobibliotheek om een profielfoto te kiezen.</string>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>

View file

@ -1,4 +1,6 @@
import SwiftUI
import PhotosUI
import UIKit
struct SettingsScreen: View {
@EnvironmentObject private var app: AppModel
@ -118,8 +120,14 @@ struct EditProfileScreen: View {
@State private var name = ""
@State private var avatarId = "fox"
@State private var showAvatar = true
@State private var hasCustomAvatar = false
@State private var remoteAvatarUrl: String?
@State private var pin = ""
@State private var clearPin = false
@State private var clearCustomAvatar = false
@State private var pendingImage: UIImage?
@State private var pickerItem: PhotosPickerItem?
@State private var message: String?
@State private var error: String?
@State private var saving = false
@ -128,6 +136,33 @@ struct EditProfileScreen: View {
var body: some View {
Form {
Section("Profielfoto") {
HStack {
Spacer()
profilePhotoPreview
.frame(width: 96, height: 96)
.clipShape(Circle())
Spacer()
}
Toggle("Profielfoto aan/uit", isOn: $showAvatar)
.tint(McColors.accent)
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Foto kiezen", systemImage: "photo")
}
if hasCustomAvatar || pendingImage != nil {
Button("Foto verwijderen", role: .destructive) {
pendingImage = nil
pickerItem = nil
clearCustomAvatar = true
hasCustomAvatar = false
}
}
}
.listRowBackground(McColors.bgElevated)
.onChange(of: pickerItem) { _, item in
Task { await loadPicked(item) }
}
Section("Naam") {
TextField("Naam", text: $name)
.foregroundStyle(McColors.text)
@ -190,9 +225,54 @@ struct EditProfileScreen: View {
.background(McColors.bg.ignoresSafeArea())
.navigationTitle("Mijn profiel")
.navigationBarTitleDisplayMode(.inline)
.task {
.task { await load() }
}
@ViewBuilder
private var profilePhotoPreview: some View {
if let pendingImage {
Image(uiImage: pendingImage)
.resizable()
.scaledToFill()
} else if hasCustomAvatar, let remoteAvatarUrl {
AuthenticatedAvatarImage(path: remoteAvatarUrl)
} else {
Text(ProfileAvatars.emoji(for: avatarId))
.font(.system(size: 44))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(McColors.surface)
}
}
private func load() async {
name = app.store.profileName ?? ""
avatarId = app.store.profileAvatarId ?? "fox"
showAvatar = app.store.profileShowAvatar
remoteAvatarUrl = app.store.profileAvatarUrl
hasCustomAvatar = !(app.store.profileAvatarUrl ?? "").isEmpty
if let profileId {
if let payload = try? await app.api.listProfiles(),
let mine = payload.profiles.first(where: { $0.id == profileId }) {
name = mine.name
avatarId = mine.avatarId ?? "fox"
showAvatar = mine.showsAvatar
hasCustomAvatar = mine.hasCustomPhoto
remoteAvatarUrl = mine.avatarUrl
}
}
}
private func loadPicked(_ item: PhotosPickerItem?) async {
guard let item else { return }
do {
if let data = try await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
pendingImage = image.squared()
clearCustomAvatar = false
hasCustomAvatar = true
}
} catch {
self.error = error.localizedDescription
}
}
@ -202,14 +282,31 @@ struct EditProfileScreen: View {
error = nil
message = nil
do {
let updated = try await app.api.updateProfile(
var updated = try await app.api.updateProfile(
profileId: profileId,
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
avatarId: avatarId,
showAvatar: showAvatar,
clearAvatarImage: clearCustomAvatar && pendingImage == nil,
pin: clearPin ? nil : (pin.isEmpty ? nil : pin),
clearPin: clearPin
)
app.store.setProfile(id: updated.id, name: updated.name, avatarId: updated.avatarId)
if let pendingImage,
let jpeg = pendingImage.jpegData(compressionQuality: 0.85) {
let b64 = jpeg.base64EncodedString()
updated = try await app.api.uploadProfileAvatar(profileId: updated.id, imageBase64: b64)
}
app.store.setProfile(
id: updated.id,
name: updated.name,
avatarId: updated.avatarId,
showAvatar: updated.showsAvatar,
avatarUrl: updated.avatarUrl ?? ""
)
hasCustomAvatar = updated.hasCustomPhoto
remoteAvatarUrl = updated.avatarUrl
pendingImage = nil
clearCustomAvatar = false
message = "Opgeslagen"
pin = ""
clearPin = false
@ -379,14 +476,66 @@ struct AdminEditProfileScreen: View {
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var avatarId = "fox"
@State private var showAvatar = true
@State private var hasCustomAvatar = false
@State private var remoteAvatarUrl: String?
@State private var isKids = false
@State private var pin = ""
@State private var clearPin = false
@State private var clearCustomAvatar = false
@State private var pendingImage: UIImage?
@State private var pickerItem: PhotosPickerItem?
@State private var error: String?
@State private var message: String?
var body: some View {
Form {
Section("Profielfoto") {
HStack {
Spacer()
Group {
if let pendingImage {
Image(uiImage: pendingImage).resizable().scaledToFill()
} else if hasCustomAvatar, let remoteAvatarUrl {
AuthenticatedAvatarImage(path: remoteAvatarUrl)
} else {
Text(ProfileAvatars.emoji(for: avatarId))
.font(.system(size: 40))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(McColors.surface)
}
}
.frame(width: 88, height: 88)
.clipShape(Circle())
Spacer()
}
Toggle("Profielfoto aan/uit", isOn: $showAvatar)
.tint(McColors.accent)
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Foto kiezen", systemImage: "photo")
}
if hasCustomAvatar || pendingImage != nil {
Button("Foto verwijderen", role: .destructive) {
pendingImage = nil
pickerItem = nil
clearCustomAvatar = true
hasCustomAvatar = false
}
}
}
.listRowBackground(McColors.bgElevated)
.onChange(of: pickerItem) { _, item in
Task {
guard let item else { return }
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
pendingImage = image.squared()
clearCustomAvatar = false
hasCustomAvatar = true
}
}
}
Section("Naam") {
TextField("Naam", text: $name)
}
@ -446,22 +595,44 @@ struct AdminEditProfileScreen: View {
name = profile.name
avatarId = profile.avatarId ?? "fox"
isKids = profile.isKids == true
showAvatar = profile.showsAvatar
hasCustomAvatar = profile.hasCustomPhoto
remoteAvatarUrl = profile.avatarUrl
}
}
private func save() async {
do {
let updated = try await app.api.updateProfile(
var updated = try await app.api.updateProfile(
profileId: profile.id,
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
avatarId: avatarId,
showAvatar: showAvatar,
clearAvatarImage: clearCustomAvatar && pendingImage == nil,
isKids: canManage && profile.isOwner != true ? isKids : nil,
pin: clearPin ? nil : (pin.isEmpty ? nil : pin),
clearPin: clearPin
)
if updated.id == app.store.profileId {
app.store.setProfile(id: updated.id, name: updated.name, avatarId: updated.avatarId)
if let pendingImage,
let jpeg = pendingImage.jpegData(compressionQuality: 0.85) {
updated = try await app.api.uploadProfileAvatar(
profileId: updated.id,
imageBase64: jpeg.base64EncodedString()
)
}
if updated.id == app.store.profileId {
app.store.setProfile(
id: updated.id,
name: updated.name,
avatarId: updated.avatarId,
showAvatar: updated.showsAvatar,
avatarUrl: updated.avatarUrl ?? ""
)
}
hasCustomAvatar = updated.hasCustomPhoto
remoteAvatarUrl = updated.avatarUrl
pendingImage = nil
clearCustomAvatar = false
message = "Opgeslagen"
error = nil
} catch {
@ -479,6 +650,61 @@ struct AdminEditProfileScreen: View {
}
}
struct AuthenticatedAvatarImage: View {
@EnvironmentObject private var app: AppModel
let path: String
@State private var image: UIImage?
var body: some View {
Group {
if let image {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(McColors.surface)
}
}
.task(id: path) { await load() }
}
private func load() async {
guard let url = await app.api.absoluteUrl(path) else { return }
var req = URLRequest(url: url)
if let token = await app.api.bearerToken() {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let (data, _) = try? await URLSession.shared.data(for: req),
let ui = UIImage(data: data) {
image = ui
}
}
}
private extension UIImage {
func squared() -> UIImage {
let side = min(size.width, size.height)
let origin = CGPoint(x: (size.width - side) / 2, y: (size.height - side) / 2)
let crop = CGRect(origin: origin, size: CGSize(width: side, height: side))
guard let cg = cgImage?.cropping(to: CGRect(
x: crop.origin.x * scale,
y: crop.origin.y * scale,
width: crop.size.width * scale,
height: crop.size.height * scale
)) else { return self }
let squared = UIImage(cgImage: cg, scale: scale, orientation: imageOrientation)
let target: CGFloat = 512
let format = UIGraphicsImageRendererFormat.default()
format.scale = 1
let renderer = UIGraphicsImageRenderer(size: CGSize(width: target, height: target), format: format)
return renderer.image { _ in
squared.draw(in: CGRect(x: 0, y: 0, width: target, height: target))
}
}
}
struct ShelfPrefsScreen: View {
@EnvironmentObject private var app: AppModel
@State private var prefs: [ShelfPref] = []

View file

@ -117,6 +117,10 @@ model ViewerProfile {
viewerUserId String @map("viewer_user_id")
name String
avatarId String @default("fox") @map("avatar_id")
/** Eigen geüploade foto (relatief pad onder data/profile-avatars). */
avatarImage String? @map("avatar_image")
/** Toon avatar/foto in UI (uit = alleen naam). */
showAvatar Boolean @default(true) @map("show_avatar")
isOwner Boolean @default(false) @map("is_owner")
isKids Boolean @default(false) @map("is_kids")
pinHash String? @map("pin_hash")

View file

@ -562,6 +562,17 @@ async function main() {
console.warn("Could not ensure viewer client tables:", err);
}
try {
await prisma.$executeRawUnsafe(
`ALTER TABLE "viewer_profiles" ADD COLUMN IF NOT EXISTS "avatar_image" TEXT`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE "viewer_profiles" ADD COLUMN IF NOT EXISTS "show_avatar" BOOLEAN NOT NULL DEFAULT true`
);
} catch (err) {
console.warn("Could not ensure viewer_profiles avatar columns:", err);
}
// Repair drifted episode.series_id so series.episodes and seasons.episodes stay aligned.
try {
await prisma.$executeRawUnsafe(`

View file

@ -1,6 +1,8 @@
import { prisma } from "../database/client";
import { hashPassword, verifyPassword } from "../security/crypto";
import { AppError } from "../security/errors";
import fs from "node:fs";
import path from "node:path";
/** Vaste avatar-set (client toont emoji/tekening per id). */
export const PROFILE_AVATARS = [
@ -21,6 +23,7 @@ export type ProfileAvatarId = (typeof PROFILE_AVATARS)[number]["id"];
const MAX_PROFILES = 8;
const PIN_ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
const PIN_MAX_ATTEMPTS = 5;
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
/** In-memory PIN rate limit: profileId:deviceId → attempts */
const pinAttempts = new Map<string, { count: number; resetAt: number }>();
@ -38,18 +41,35 @@ function normalizePin(pin: string | null | undefined): string | null {
return digits;
}
export function profileAvatarsDir(): string {
const root = process.env.DATA_DIR || path.join(process.cwd(), "data");
const dir = path.join(root, "profile-avatars");
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function avatarFilePath(profileId: string, ext = "jpg"): string {
return path.join(profileAvatarsDir(), `${profileId}.${ext}`);
}
function publicProfile(row: {
id: string;
name: string;
avatarId: string;
avatarImage?: string | null;
showAvatar?: boolean | null;
isOwner: boolean;
isKids: boolean;
pinHash: string | null;
}) {
const hasCustomAvatar = !!row.avatarImage;
return {
id: row.id,
name: row.name,
avatarId: row.avatarId,
showAvatar: row.showAvatar !== false,
hasCustomAvatar,
avatarUrl: hasCustomAvatar ? `/api/v1/client/profiles/${row.id}/avatar` : null,
isOwner: row.isOwner,
isKids: row.isKids,
hasPin: !!row.pinHash,
@ -94,6 +114,7 @@ export async function ensureOwnerProfile(
viewerUserId: viewerId,
name: name.slice(0, 32),
avatarId: "fox",
showAvatar: true,
isOwner: true,
isKids: false,
},
@ -120,6 +141,7 @@ export class ViewerProfileService {
input: {
name?: string;
avatarId?: string;
showAvatar?: boolean;
isKids?: boolean;
pin?: string | null;
}
@ -130,25 +152,21 @@ export class ViewerProfileService {
throw new AppError("LIMIT", `Maximaal ${MAX_PROFILES} profielen`, 400);
}
const name = (input.name || "").trim().slice(0, 32);
if (name.length < 1) throw new AppError("INVALID_REQUEST", "Naam verplicht", 400);
const name = (input.name?.trim() || `Profiel ${count + 1}`).slice(0, 32);
const avatarId = input.avatarId || "fox";
if (!isAvatarId(avatarId)) {
throw new AppError("INVALID_REQUEST", "Ongeldige avatar", 400);
}
const pin = normalizePin(input.pin);
const isKids = input.isKids === true;
try {
const row = await prisma.viewerProfile.create({
data: {
viewerUserId: viewerId,
name,
avatarId,
showAvatar: input.showAvatar !== false,
isOwner: false,
isKids,
isKids: !!input.isKids,
pinHash: pin ? await hashPassword(pin) : null,
},
});
@ -165,6 +183,8 @@ export class ViewerProfileService {
input: {
name?: string;
avatarId?: string;
showAvatar?: boolean;
clearAvatarImage?: boolean;
isKids?: boolean;
pin?: string | null;
clearPin?: boolean;
@ -198,6 +218,8 @@ export class ViewerProfileService {
const data: {
name?: string;
avatarId?: string;
showAvatar?: boolean;
avatarImage?: string | null;
isKids?: boolean;
pinHash?: string | null;
} = {};
@ -213,8 +235,18 @@ export class ViewerProfileService {
}
data.avatarId = input.avatarId;
}
if (input.showAvatar !== undefined) {
data.showAvatar = !!input.showAvatar;
}
if (input.clearAvatarImage === true) {
data.avatarImage = null;
try {
fs.unlinkSync(avatarFilePath(profileId));
} catch {
/* ignore */
}
}
if (input.isKids !== undefined && isOwnerActing) {
// Owner-profiel mag geen kids worden
if (target.isOwner && input.isKids) {
throw new AppError("INVALID_REQUEST", "Hoofdprofiel kan geen kids-profiel zijn", 400);
}
@ -238,6 +270,78 @@ export class ViewerProfileService {
}
}
async setAvatarImage(
viewerId: string,
activeProfileId: string | null,
profileId: string,
imageBase64: string,
mimeType?: string
) {
const target = await prisma.viewerProfile.findFirst({
where: { id: profileId, viewerUserId: viewerId },
});
if (!target) throw new AppError("NOT_FOUND", "Profiel niet gevonden", 404);
const isSelf = activeProfileId === profileId;
const active = activeProfileId
? await prisma.viewerProfile.findFirst({
where: { id: activeProfileId, viewerUserId: viewerId },
})
: null;
if (!active?.isOwner && !isSelf) {
throw new AppError("FORBIDDEN", "Geen rechten om dit profiel te wijzigen", 403);
}
const raw = imageBase64.replace(/^data:image\/\w+;base64,/, "");
let buf: Buffer;
try {
buf = Buffer.from(raw, "base64");
} catch {
throw new AppError("INVALID_REQUEST", "Ongeldige afbeelding", 400);
}
if (buf.length < 32 || buf.length > MAX_AVATAR_BYTES) {
throw new AppError("INVALID_REQUEST", "Foto moet tussen 1KB en 2MB zijn", 400);
}
const mime = (mimeType || "image/jpeg").toLowerCase();
if (!mime.startsWith("image/")) {
throw new AppError("INVALID_REQUEST", "Alleen afbeeldingen toegestaan", 400);
}
const ext = mime.includes("png") ? "png" : mime.includes("webp") ? "webp" : "jpg";
const fileName = `${profileId}.${ext}`;
const full = path.join(profileAvatarsDir(), fileName);
// Verwijder oude extensies
for (const old of ["jpg", "jpeg", "png", "webp"]) {
try {
fs.unlinkSync(path.join(profileAvatarsDir(), `${profileId}.${old}`));
} catch {
/* ignore */
}
}
fs.writeFileSync(full, buf);
const row = await prisma.viewerProfile.update({
where: { id: profileId },
data: { avatarImage: fileName, showAvatar: true },
});
return publicProfile(row);
}
async readAvatarImage(viewerId: string, profileId: string): Promise<{ buf: Buffer; contentType: string }> {
const target = await prisma.viewerProfile.findFirst({
where: { id: profileId, viewerUserId: viewerId },
});
if (!target?.avatarImage) {
throw new AppError("NOT_FOUND", "Geen profielfoto", 404);
}
const full = path.join(profileAvatarsDir(), target.avatarImage);
if (!fs.existsSync(full)) {
throw new AppError("NOT_FOUND", "Geen profielfoto", 404);
}
const ext = path.extname(target.avatarImage).toLowerCase();
const contentType =
ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg";
return { buf: fs.readFileSync(full), contentType };
}
async remove(viewerId: string, activeProfileId: string | null, profileId: string) {
await assertCanManage(viewerId, activeProfileId);
@ -256,6 +360,13 @@ export class ViewerProfileService {
where: { activeProfileId: profileId },
data: { activeProfileId: null },
});
for (const old of ["jpg", "jpeg", "png", "webp"]) {
try {
fs.unlinkSync(path.join(profileAvatarsDir(), `${profileId}.${old}`));
} catch {
/* ignore */
}
}
await prisma.viewerProfile.delete({ where: { id: profileId } });
return { ok: true };
}
@ -277,19 +388,14 @@ export class ViewerProfileService {
let bucket = pinAttempts.get(key);
if (!bucket || bucket.resetAt < now) {
bucket = { count: 0, resetAt: now + PIN_ATTEMPT_WINDOW_MS };
pinAttempts.set(key, bucket);
}
if (bucket.count >= PIN_MAX_ATTEMPTS) {
throw new AppError(
"RATE_LIMITED",
"Te veel PIN-pogingen. Probeer later opnieuw.",
429
);
throw new AppError("RATE_LIMIT", "Te veel PIN-pogingen, probeer later", 429);
}
const ok = pin ? await verifyPassword(profile.pinHash, String(pin).replace(/\D/g, "")) : false;
const ok = pin ? await verifyPassword(String(pin), profile.pinHash) : false;
if (!ok) {
bucket.count += 1;
pinAttempts.set(key, bucket);
throw new AppError("FORBIDDEN", "Onjuiste PIN", 403);
}
pinAttempts.delete(key);
@ -297,9 +403,8 @@ export class ViewerProfileService {
await prisma.viewerDevice.update({
where: { id: deviceId },
data: { activeProfileId: profile.id },
data: { activeProfileId: profileId, lastSeenAt: new Date() },
});
return { profile: publicProfile(profile) };
}

View file

@ -483,6 +483,8 @@ export function registerViewerRoutes(
const body = (request.body || {}) as {
name?: string;
avatarId?: string;
showAvatar?: boolean;
clearAvatarImage?: boolean;
isKids?: boolean;
pin?: string | null;
clearPin?: boolean;
@ -491,6 +493,39 @@ export function registerViewerRoutes(
return { profile };
});
app.post(
"/api/v1/client/profiles/:id/avatar",
{
config: { rateLimit: { max: 20, timeWindow: "1 minute" } },
},
async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const { id } = request.params as { id: string };
const body = (request.body || {}) as {
imageBase64?: string;
mimeType?: string;
};
if (!body.imageBase64) {
throw new AppError("INVALID_REQUEST", "imageBase64 verplicht", 400);
}
const profile = await profiles.setAvatarImage(
auth.viewerId,
auth.profileId,
id,
body.imageBase64,
body.mimeType
);
return { profile };
}
);
app.get("/api/v1/client/profiles/:id/avatar", async (request, reply) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const { id } = request.params as { id: string };
const { buf, contentType } = await profiles.readAvatarImage(auth.viewerId, id);
return reply.type(contentType).send(buf);
});
app.delete("/api/v1/client/profiles/:id", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const { id } = request.params as { id: string };

View file

@ -43,6 +43,9 @@ export type AuthedViewer = {
id: string;
name: string;
avatarId: string;
showAvatar: boolean;
hasCustomAvatar: boolean;
avatarUrl: string | null;
isOwner: boolean;
isKids: boolean;
hasPin: boolean;
@ -617,6 +620,11 @@ export class ViewerService {
id: profile.id,
name: profile.name,
avatarId: profile.avatarId,
showAvatar: profile.showAvatar !== false,
hasCustomAvatar: !!profile.avatarImage,
avatarUrl: profile.avatarImage
? `/api/v1/client/profiles/${profile.id}/avatar`
: null,
isOwner: profile.isOwner,
isKids: profile.isKids,
hasPin: !!profile.pinHash,