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" applicationId = "nl.vonas.mediacluster"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 19 versionCode = 20
versionName = "1.0.18" versionName = "1.0.19"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -303,6 +303,8 @@ class ApiClient(
profileId: String, profileId: String,
name: String? = null, name: String? = null,
avatarId: String? = null, avatarId: String? = null,
showAvatar: Boolean? = null,
clearAvatarImage: Boolean = false,
isKids: Boolean? = null, isKids: Boolean? = null,
pin: String? = null, pin: String? = null,
clearPin: Boolean = false, clearPin: Boolean = false,
@ -310,6 +312,8 @@ class ApiClient(
val body = JSONObject() val body = JSONObject()
if (name != null) body.put("name", name) if (name != null) body.put("name", name)
if (avatarId != null) body.put("avatarId", avatarId) 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 (isKids != null) body.put("isKids", isKids)
if (clearPin) body.put("clearPin", true) if (clearPin) body.put("clearPin", true)
else if (!pin.isNullOrBlank()) body.put("pin", pin) else if (!pin.isNullOrBlank()) body.put("pin", pin)
@ -319,6 +323,28 @@ class ApiClient(
parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() } 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) { suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build() val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build()
execute(req) execute(req)
@ -755,6 +781,9 @@ data class ViewerProfile(
val isOwner: Boolean, val isOwner: Boolean,
val isKids: Boolean, val isKids: Boolean,
val hasPin: Boolean, val hasPin: Boolean,
val showAvatar: Boolean = true,
val hasCustomAvatar: Boolean = false,
val avatarUrl: String? = null,
) )
data class ProfilesPayload( data class ProfilesPayload(
@ -863,4 +892,7 @@ private fun JSONObject.toProfile(): ViewerProfile = ViewerProfile(
isOwner = optBoolean("isOwner"), isOwner = optBoolean("isOwner"),
isKids = optBoolean("isKids"), isKids = optBoolean("isKids"),
hasPin = optBoolean("hasPin"), 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 profileIdKey = stringPreferencesKey("profile_id")
private val profileNameKey = stringPreferencesKey("profile_name") private val profileNameKey = stringPreferencesKey("profile_name")
private val profileAvatarKey = stringPreferencesKey("profile_avatar") 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 recentSearchKey = stringPreferencesKey("recent_searches")
val refreshToken: Flow<String?> = context.dataStore.data.map { it[tokenKey] } 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 profileId: Flow<String?> = context.dataStore.data.map { it[profileIdKey] }
val profileName: Flow<String?> = context.dataStore.data.map { it[profileNameKey] } val profileName: Flow<String?> = context.dataStore.data.map { it[profileNameKey] }
val profileAvatarId: Flow<String?> = context.dataStore.data.map { it[profileAvatarKey] } 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 recentSearches: Flow<List<String>> = context.dataStore.data.map { prefs ->
val raw = prefs[recentSearchKey].orEmpty() val raw = prefs[recentSearchKey].orEmpty()
@ -48,23 +56,40 @@ class SessionStore(private val context: Context) {
prefs.remove(profileIdKey) prefs.remove(profileIdKey)
prefs.remove(profileNameKey) prefs.remove(profileNameKey)
prefs.remove(profileAvatarKey) prefs.remove(profileAvatarKey)
prefs.remove(profileShowAvatarKey)
prefs.remove(profileAvatarUrlKey)
} else { } else {
prefs[tokenKey] = token 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 -> context.dataStore.edit { prefs ->
if (id.isNullOrBlank()) { if (id.isNullOrBlank()) {
prefs.remove(profileIdKey) prefs.remove(profileIdKey)
prefs.remove(profileNameKey) prefs.remove(profileNameKey)
prefs.remove(profileAvatarKey) prefs.remove(profileAvatarKey)
prefs.remove(profileShowAvatarKey)
prefs.remove(profileAvatarUrlKey)
} else { } else {
prefs[profileIdKey] = id prefs[profileIdKey] = id
if (!name.isNullOrBlank()) prefs[profileNameKey] = name else prefs.remove(profileNameKey) if (!name.isNullOrBlank()) prefs[profileNameKey] = name else prefs.remove(profileNameKey)
if (!avatarId.isNullOrBlank()) prefs[profileAvatarKey] = avatarId if (!avatarId.isNullOrBlank()) prefs[profileAvatarKey] = avatarId
else prefs.remove(profileAvatarKey) 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 approveError by remember { mutableStateOf<String?>(null) }
var approveDoneName by remember { mutableStateOf<String?>(null) } var approveDoneName by remember { mutableStateOf<String?>(null) }
val profileAvatarId by store.profileAvatarId.collectAsState(initial = 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) val profileName by store.profileName.collectAsState(initial = null)
// Blijft behouden als main uit compositie gaat (bijv. live player) // Blijft behouden als main uit compositie gaat (bijv. live player)
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) } var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
@ -133,7 +135,13 @@ fun McNav(
if (authed) { if (authed) {
val me = runCatching { api.clientMe() }.getOrNull() val me = runCatching { api.clientMe() }.getOrNull()
if (me?.profile != null) { 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 hasProfile = true
canManageDevices = me.profile.isOwner canManageDevices = me.profile.isOwner
} else { } else {
@ -257,6 +265,9 @@ fun McNav(
tvFocusNonce = tvFocusNonce, tvFocusNonce = tvFocusNonce,
onTvFocusContent = { tvFocusNonce++ }, onTvFocusContent = { tvFocusNonce++ },
profileAvatarId = profileAvatarId, profileAvatarId = profileAvatarId,
profileAvatarUrl = api.absoluteUrl(profileAvatarUrl),
profileShowAvatar = profileShowAvatar,
profileAuthHeader = api.bearerToken()?.let { "Bearer $it" },
profileName = profileName, profileName = profileName,
canManageDevices = canManageDevices, canManageDevices = canManageDevices,
onOpenMovie = { nav.navigate("movie/$it") }, onOpenMovie = { nav.navigate("movie/$it") },
@ -440,6 +451,9 @@ private fun MainHub(
tvFocusNonce: Int, tvFocusNonce: Int,
onTvFocusContent: () -> Unit, onTvFocusContent: () -> Unit,
profileAvatarId: String?, profileAvatarId: String?,
profileAvatarUrl: String?,
profileShowAvatar: Boolean,
profileAuthHeader: String?,
profileName: String?, profileName: String?,
canManageDevices: Boolean, canManageDevices: Boolean,
onOpenMovie: (String) -> Unit, onOpenMovie: (String) -> Unit,
@ -636,6 +650,9 @@ private fun MainHub(
MainTab.Settings -> SettingsScreen( MainTab.Settings -> SettingsScreen(
api = api, api = api,
profileAvatarId = profileAvatarId, profileAvatarId = profileAvatarId,
profileAvatarUrl = api.absoluteUrl(profileAvatarUrl),
profileShowAvatar = profileShowAvatar,
profileAuthHeader = api.bearerToken()?.let { "Bearer $it" },
profileName = profileName, profileName = profileName,
canManageDevices = canManageDevices, canManageDevices = canManageDevices,
onBack = { onBack = {
@ -1238,6 +1255,9 @@ private fun ShelfBlock(
private fun SettingsScreen( private fun SettingsScreen(
api: ApiClient, api: ApiClient,
profileAvatarId: String? = null, profileAvatarId: String? = null,
profileAvatarUrl: String? = null,
profileShowAvatar: Boolean = true,
profileAuthHeader: String? = null,
profileName: String? = null, profileName: String? = null,
canManageDevices: Boolean = false, canManageDevices: Boolean = false,
onBack: () -> Unit, onBack: () -> Unit,
@ -1330,8 +1350,24 @@ private fun SettingsScreen(
.background(Color(0xFF2E2E3A)), .background(Color(0xFF2E2E3A)),
contentAlignment = Alignment.Center, 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) Text(avatarEmoji(profileAvatarId ?: "fox"), fontSize = 22.sp)
} }
}
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Text(
profileName?.takeIf { it.isNotBlank() } ?: "Profiel", profileName?.takeIf { it.isNotBlank() } ?: "Profiel",

View file

@ -1,9 +1,18 @@
package nl.vonas.mediacluster.tv.ui.profiles 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -14,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding 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.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -39,23 +51,34 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text 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.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SessionStore import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.data.ViewerProfile import nl.vonas.mediacluster.tv.data.ViewerProfile
import nl.vonas.mediacluster.tv.ui.components.McButton import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens 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 } private enum class ProfilesMode { Pick, Pin, Create, Edit, DeleteConfirm }
@ -81,20 +104,65 @@ fun ProfilesScreen(
var createKids by remember { mutableStateOf(false) } var createKids by remember { mutableStateOf(false) }
var createPin by remember { mutableStateOf("") } var createPin by remember { mutableStateOf("") }
var clearPin by remember { mutableStateOf(false) } 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) } 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) { fun openEdit(profile: ViewerProfile) {
editingId = profile.id editingId = profile.id
createName = profile.name createName = profile.name
createAvatar = profile.avatarId createAvatar = profile.avatarId
createKids = profile.isKids createKids = profile.isKids
createShowAvatar = profile.showAvatar
createPin = "" createPin = ""
clearPin = false clearPin = false
resetAvatarDraft()
pending = profile pending = profile
mode = ProfilesMode.Edit mode = ProfilesMode.Edit
error = null 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() { fun reload() {
scope.launch { scope.launch {
loading = true loading = true
@ -129,7 +197,13 @@ fun ProfilesScreen(
error = null error = null
runCatching { api.selectProfile(profile.id, pinValue) } runCatching { api.selectProfile(profile.id, pinValue) }
.onSuccess { selected -> .onSuccess { selected ->
store.setProfile(selected.id, selected.name, selected.avatarId) store.setProfile(
selected.id,
selected.name,
selected.avatarId,
selected.showAvatar,
selected.avatarUrl ?: "",
)
onProfileSelected() onProfileSelected()
} }
.onFailure { .onFailure {
@ -195,6 +269,8 @@ fun ProfilesScreen(
createKids = false createKids = false
createPin = "" createPin = ""
clearPin = false clearPin = false
createShowAvatar = true
resetAvatarDraft()
mode = ProfilesMode.Create 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", title = if (mode == ProfilesMode.Edit) "Profiel bewerken" else "Nieuw profiel",
name = createName, name = createName,
onNameChange = { createName = it.take(32) }, onNameChange = { createName = it.take(32) },
avatarId = createAvatar, avatarId = createAvatar,
onAvatarChange = { createAvatar = it }, 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, isKids = createKids,
onKidsToggle = { createKids = !createKids }, onKidsToggle = { createKids = !createKids },
showKidsToggle = canManage && (mode == ProfilesMode.Create || pending?.isOwner != true), showKidsToggle = canManage && (mode == ProfilesMode.Create || pending?.isOwner != true),
@ -247,6 +340,7 @@ fun ProfilesScreen(
mode = ProfilesMode.Pick mode = ProfilesMode.Pick
editingId = null editingId = null
pending = null pending = null
resetAvatarDraft()
error = null error = null
} }
}, },
@ -257,28 +351,40 @@ fun ProfilesScreen(
error = null error = null
val editId = editingId val editId = editingId
runCatching { runCatching {
if (editId != null) { var saved = if (editId != null) {
api.updateProfile( api.updateProfile(
profileId = editId, profileId = editId,
name = createName.trim(), name = createName.trim(),
avatarId = createAvatar, avatarId = createAvatar,
showAvatar = createShowAvatar,
clearAvatarImage = clearCustomAvatar && pendingAvatarBase64 == null,
isKids = if (canManage && pending?.isOwner != true) createKids else null, isKids = if (canManage && pending?.isOwner != true) createKids else null,
pin = createPin.ifBlank { null }, pin = createPin.ifBlank { null },
clearPin = clearPin, clearPin = clearPin,
) )
} else { } else {
api.createProfile( var created = api.createProfile(
name = createName.trim(), name = createName.trim(),
avatarId = createAvatar, avatarId = createAvatar,
isKids = createKids, isKids = createKids,
pin = createPin.ifBlank { null }, 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 -> }.onSuccess { saved ->
val localId = store.profileId.first() persistLocalProfile(saved)
if (localId == saved.id || editId == localId) { resetAvatarDraft()
store.setProfile(saved.id, saved.name, saved.avatarId)
}
if (!switcherMode && !canManage) { if (!switcherMode && !canManage) {
onProfileSelected() onProfileSelected()
} else { } 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( ProfilesMode.DeleteConfirm -> DeleteContent(
name = pending?.name ?: "profiel", name = pending?.name ?: "profiel",
@ -577,6 +700,14 @@ private fun ProfileFormContent(
onNameChange: (String) -> Unit, onNameChange: (String) -> Unit,
avatarId: String, avatarId: String,
onAvatarChange: (String) -> Unit, onAvatarChange: (String) -> Unit,
showAvatar: Boolean,
onShowAvatarToggle: () -> Unit,
hasCustomAvatar: Boolean,
customAvatarUrl: String?,
authHeader: String?,
pendingPreview: Bitmap?,
onPickPhoto: () -> Unit,
onClearCustomPhoto: () -> Unit,
isKids: Boolean, isKids: Boolean,
onKidsToggle: () -> Unit, onKidsToggle: () -> Unit,
showKidsToggle: Boolean, showKidsToggle: Boolean,
@ -591,6 +722,7 @@ private fun ProfileFormContent(
onCancel: () -> Unit, onCancel: () -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
) { ) {
val ctx = LocalContext.current
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -600,6 +732,56 @@ private fun ProfileFormContent(
) { ) {
Text(title, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold) Text(title, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(20.dp)) 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( OutlinedTextField(
value = name, value = name,
onValueChange = onNameChange, 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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun DeleteContent( private fun DeleteContent(

View file

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

View file

@ -251,6 +251,8 @@ class ApiClient(
seriesId: String? = null, seriesId: String? = null,
positionMs: Long, positionMs: Long,
durationMs: Long, durationMs: Long,
completed: Boolean? = null,
hiddenFromContinue: Boolean? = null,
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
val o = JSONObject() val o = JSONObject()
.put("mediaType", mediaType) .put("mediaType", mediaType)
@ -259,6 +261,8 @@ class ApiClient(
if (movieId != null) o.put("movieId", movieId) if (movieId != null) o.put("movieId", movieId)
if (episodeId != null) o.put("episodeId", episodeId) if (episodeId != null) o.put("episodeId", episodeId)
if (seriesId != null) o.put("seriesId", seriesId) 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") val req = authed("$baseUrl/api/v1/client/progress")
.post(o.toString().toRequestBody(jsonMedia)) .post(o.toString().toRequestBody(jsonMedia))
.build() .build()
@ -368,6 +372,8 @@ class ApiClient(
profileId: String, profileId: String,
name: String? = null, name: String? = null,
avatarId: String? = null, avatarId: String? = null,
showAvatar: Boolean? = null,
clearAvatarImage: Boolean = false,
isKids: Boolean? = null, isKids: Boolean? = null,
pin: String? = null, pin: String? = null,
clearPin: Boolean = false, clearPin: Boolean = false,
@ -375,6 +381,8 @@ class ApiClient(
val body = JSONObject() val body = JSONObject()
if (name != null) body.put("name", name) if (name != null) body.put("name", name)
if (avatarId != null) body.put("avatarId", avatarId) 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 (isKids != null) body.put("isKids", isKids)
if (clearPin) body.put("clearPin", true) if (clearPin) body.put("clearPin", true)
else if (!pin.isNullOrBlank()) body.put("pin", pin) else if (!pin.isNullOrBlank()) body.put("pin", pin)
@ -384,6 +392,28 @@ class ApiClient(
parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() } 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) { suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build() val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build()
execute(req) execute(req)
@ -739,6 +769,9 @@ data class ViewerProfile(
val isOwner: Boolean, val isOwner: Boolean,
val isKids: Boolean, val isKids: Boolean,
val hasPin: Boolean, val hasPin: Boolean,
val showAvatar: Boolean = true,
val hasCustomAvatar: Boolean = false,
val avatarUrl: String? = null,
) )
data class ProfilesPayload( data class ProfilesPayload(
@ -849,4 +882,7 @@ private fun JSONObject.toProfile(): ViewerProfile = ViewerProfile(
isOwner = optBoolean("isOwner"), isOwner = optBoolean("isOwner"),
isKids = optBoolean("isKids"), isKids = optBoolean("isKids"),
hasPin = optBoolean("hasPin"), 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 profileIdKey = stringPreferencesKey("profile_id")
private val profileNameKey = stringPreferencesKey("profile_name") private val profileNameKey = stringPreferencesKey("profile_name")
private val profileAvatarKey = stringPreferencesKey("profile_avatar") 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 recentSearchKey = stringPreferencesKey("recent_searches")
private val homeCacheKey = stringPreferencesKey("home_cache_json") private val homeCacheKey = stringPreferencesKey("home_cache_json")
private val tvFavoritesKey = stringPreferencesKey("tv_favorites") 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 profileId: Flow<String?> = context.dataStore.data.map { it[profileIdKey] }
val profileName: Flow<String?> = context.dataStore.data.map { it[profileNameKey] } val profileName: Flow<String?> = context.dataStore.data.map { it[profileNameKey] }
val profileAvatarId: Flow<String?> = context.dataStore.data.map { it[profileAvatarKey] } 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 recentSearches: Flow<List<String>> = context.dataStore.data.map { prefs ->
val raw = prefs[recentSearchKey].orEmpty() val raw = prefs[recentSearchKey].orEmpty()
@ -67,23 +75,40 @@ class SessionStore(private val context: Context) {
prefs.remove(profileIdKey) prefs.remove(profileIdKey)
prefs.remove(profileNameKey) prefs.remove(profileNameKey)
prefs.remove(profileAvatarKey) prefs.remove(profileAvatarKey)
prefs.remove(profileShowAvatarKey)
prefs.remove(profileAvatarUrlKey)
} else { } else {
prefs[tokenKey] = token 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 -> context.dataStore.edit { prefs ->
if (id.isNullOrBlank()) { if (id.isNullOrBlank()) {
prefs.remove(profileIdKey) prefs.remove(profileIdKey)
prefs.remove(profileNameKey) prefs.remove(profileNameKey)
prefs.remove(profileAvatarKey) prefs.remove(profileAvatarKey)
prefs.remove(profileShowAvatarKey)
prefs.remove(profileAvatarUrlKey)
} else { } else {
prefs[profileIdKey] = id prefs[profileIdKey] = id
if (!name.isNullOrBlank()) prefs[profileNameKey] = name else prefs.remove(profileNameKey) if (!name.isNullOrBlank()) prefs[profileNameKey] = name else prefs.remove(profileNameKey)
if (!avatarId.isNullOrBlank()) prefs[profileAvatarKey] = avatarId if (!avatarId.isNullOrBlank()) prefs[profileAvatarKey] = avatarId
else prefs.remove(profileAvatarKey) 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 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 } McButton("Annuleren") { continueMenu = null }
} }
} }

View file

@ -34,12 +34,21 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.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.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -245,9 +254,9 @@ private fun DetailActionButton(
focusRequester: FocusRequester? = null, focusRequester: FocusRequester? = null,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val bg = if (primary) Color.White else Color(0xE6181818) val bg = Color(0xE6181818)
val bgFocus = if (primary) Color(0xFFE8E8E8) else Color.White val bgFocus = Color.White
val fg = if (primary) Color.Black else Color.White val fg = Color.White
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier, modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
@ -260,20 +269,16 @@ private fun DetailActionButton(
contentColor = fg, contentColor = fg,
focusedContentColor = Color.Black, focusedContentColor = Color.Black,
), ),
border = if (primary) { border = ClickableSurfaceDefaults.border(
ClickableSurfaceDefaults.border()
} else {
ClickableSurfaceDefaults.border(
border = Border( border = Border(
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)), border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)),
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
), ),
focusedBorder = Border( focusedBorder = Border(
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White), border = androidx.compose.foundation.BorderStroke(2.dp, Color.White),
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
), ),
) ),
},
) { ) {
Text( Text(
text = label, 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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun MovieDetailScreen( fun MovieDetailScreen(
@ -364,6 +415,10 @@ fun MovieDetailScreen(
} }
val selected = versions.firstOrNull { it.id == selectedVersionId } ?: versions.firstOrNull() 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 { val metaParts = buildList {
year?.let { add(it.toString()) } year?.let { add(it.toString()) }
formatRuntime(runtime)?.let(::add) formatRuntime(runtime)?.let(::add)
@ -379,7 +434,7 @@ fun MovieDetailScreen(
Modifier Modifier
.fillMaxHeight() .fillMaxHeight()
.fillMaxWidth(0.58f) .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), .verticalScroll(scroll),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@ -387,6 +442,14 @@ fun MovieDetailScreen(
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
DetailLogoOrTitle(logoUrl, title) DetailLogoOrTitle(logoUrl, title)
MetaChips(metaParts) MetaChips(metaParts)
if (endsAtLabel != null) {
Text(
endsAtLabel,
color = Color(0xFF9A9AA8),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
}
if (versions.size > 1) { if (versions.size > 1) {
SelectedQualityLine(selected) SelectedQualityLine(selected)
} }
@ -415,14 +478,7 @@ fun MovieDetailScreen(
} }
} }
if (overview.isNotBlank()) { if (overview.isNotBlank()) {
Text( ExpandableOverview(overview)
overview,
color = McColors.TextDim,
fontSize = 15.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
lineHeight = 21.sp,
)
} }
if (director.isNotEmpty()) { if (director.isNotEmpty()) {
Text( Text(
@ -539,11 +595,38 @@ fun SeriesDetailScreen(
var genres by remember { mutableStateOf<List<String>>(emptyList()) } var genres by remember { mutableStateOf<List<String>>(emptyList()) }
var trailerKey by remember { mutableStateOf<String?>(null) } var trailerKey by remember { mutableStateOf<String?>(null) }
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) } 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 error by remember { mutableStateOf<String?>(null) }
var onWatchlist by remember { mutableStateOf(false) } var onWatchlist by remember { mutableStateOf(false) }
var episodeMenu by remember { mutableStateOf<EpisodeRow?>(null) }
var menuReady by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val firstEpFocus = remember { FocusRequester() } 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) { LaunchedEffect(seriesId) {
try { try {
@ -565,14 +648,18 @@ fun SeriesDetailScreen(
for (i in 0 until arr.length()) list += arr.getJSONObject(i) for (i in 0 until arr.length()) list += arr.getJSONObject(i)
} }
seasons = list 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) { } catch (e: Exception) {
error = e.message error = e.message
} }
} }
val episodes = remember(seasons, selectedSeason) { val seasonNumber = selectedSeason ?: 0
val season = seasons.firstOrNull { it.optInt("seasonNumber") == selectedSeason } val episodes = remember(seasons, seasonNumber) {
val season = seasons.firstOrNull { it.optInt("seasonNumber") == seasonNumber }
val eps = season?.optJSONArray("episodes") ?: return@remember emptyList() val eps = season?.optJSONArray("episodes") ?: return@remember emptyList()
buildList { buildList {
for (i in 0 until eps.length()) { for (i in 0 until eps.length()) {
@ -598,10 +685,14 @@ fun SeriesDetailScreen(
} }
} }
LaunchedEffect(episodes) { LaunchedEffect(episodeMenu) {
if (episodes.isNotEmpty()) { if (episodeMenu != null) {
menuReady = false
delay(80) delay(80)
runCatching { firstEpFocus.requestFocus() } menuReady = true
runCatching { menuFocus.requestFocus() }
} else {
menuReady = false
} }
} }
@ -610,12 +701,22 @@ fun SeriesDetailScreen(
genres.take(3).forEach(::add) genres.take(3).forEach(::add)
rating?.let { add("★ $it") } 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) BackHandler(onBack = onBack)
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
DetailBackdrop(backdrop) DetailBackdrop(backdrop)
LazyColumn( 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), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item { item {
@ -624,25 +725,15 @@ fun SeriesDetailScreen(
DetailLogoOrTitle(logoUrl, title) DetailLogoOrTitle(logoUrl, title)
MetaChips(metaParts) MetaChips(metaParts)
if (overview.isNotBlank()) { if (overview.isNotBlank()) {
Text( ExpandableOverview(
overview, overview,
color = McColors.TextDim,
fontSize = 15.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
lineHeight = 22.sp,
modifier = Modifier.fillMaxWidth(0.72f).padding(top = 4.dp), modifier = Modifier.fillMaxWidth(0.72f).padding(top = 4.dp),
) )
} }
if (error != null) Text(error!!, color = McColors.Danger) if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val playEp = remember(episodes) { playEpPreview?.let { ep ->
episodes.firstOrNull { !it.completed && it.positionMs > 30_000L }
?: episodes.firstOrNull { !it.completed }
?: episodes.firstOrNull()
}
playEp?.let { ep ->
val resume = !ep.completed && ep.positionMs > 30_000L val resume = !ep.completed && ep.positionMs > 30_000L
DetailActionButton( DetailActionButton(
if (resume) "Verder kijken" else "Afspelen", if (resume) "Verder kijken" else "Afspelen",
@ -675,7 +766,7 @@ fun SeriesDetailScreen(
val n = s.optInt("seasonNumber") val n = s.optInt("seasonNumber")
DetailActionButton( DetailActionButton(
"Seizoen $n", "Seizoen $n",
primary = n == selectedSeason, primary = n == seasonNumber,
) { selectedSeason = n } ) { selectedSeason = n }
} }
} }
@ -683,7 +774,7 @@ fun SeriesDetailScreen(
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text("Afleveringen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) Text("Afleveringen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
} }
itemsIndexed(episodes) { index, ep -> itemsIndexed(episodes) { _, ep ->
EpisodeCard( EpisodeCard(
episode = ep, episode = ep,
focusRequester = null, focusRequester = null,
@ -695,9 +786,80 @@ fun SeriesDetailScreen(
if (ep.completed) 0L else ep.positionMs, 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, episode: EpisodeRow,
focusRequester: FocusRequester?, focusRequester: FocusRequester?,
onClick: () -> Unit, 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( Surface(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { focused = it.isFocused }
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier), .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
@ -817,9 +989,14 @@ private fun EpisodeCard(
lineHeight = 18.sp, lineHeight = 18.sp,
) )
} }
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
formatRuntime(episode.runtime)?.let { formatRuntime(episode.runtime)?.let {
Text(it, color = Color(0xFF888888), fontSize = 12.sp) 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 hasProfile by remember { mutableStateOf(false) }
var isOwner by remember { mutableStateOf(false) } var isOwner by remember { mutableStateOf(false) }
val profileAvatarId by store.profileAvatarId.collectAsState(initial = null) 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) val profileName by store.profileName.collectAsState(initial = null)
// Blijft behouden als main uit compositie gaat (bijv. live player) // Blijft behouden als main uit compositie gaat (bijv. live player)
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) } var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
@ -145,7 +147,13 @@ fun McNav() {
val me = runCatching { api.clientMe() }.getOrNull() val me = runCatching { api.clientMe() }.getOrNull()
when { when {
me?.profile != null -> { 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 hasProfile = true
isOwner = me.profile.isOwner isOwner = me.profile.isOwner
} }
@ -284,6 +292,8 @@ fun McNav() {
tvFocusNonce = tvFocusNonce, tvFocusNonce = tvFocusNonce,
onTvFocusContent = { tvFocusNonce++ }, onTvFocusContent = { tvFocusNonce++ },
profileAvatarId = profileAvatarId, profileAvatarId = profileAvatarId,
profileShowAvatar = profileShowAvatar,
profileAvatarUrl = profileAvatarUrl,
profileName = profileName, profileName = profileName,
onOpenMovie = { nav.navigate("movie/$it") }, onOpenMovie = { nav.navigate("movie/$it") },
onOpenSeries = { nav.navigate("series/$it") }, onOpenSeries = { nav.navigate("series/$it") },
@ -442,6 +452,8 @@ private fun MainHub(
tvFocusNonce: Int, tvFocusNonce: Int,
onTvFocusContent: () -> Unit, onTvFocusContent: () -> Unit,
profileAvatarId: String?, profileAvatarId: String?,
profileShowAvatar: Boolean = true,
profileAvatarUrl: String? = null,
profileName: String?, profileName: String?,
onOpenMovie: (String) -> Unit, onOpenMovie: (String) -> Unit,
onOpenSeries: (String) -> Unit, onOpenSeries: (String) -> Unit,
@ -457,6 +469,8 @@ private fun MainHub(
var navFocused by remember { mutableStateOf(false) } var navFocused by remember { mutableStateOf(false) }
var exitConfirm by remember { mutableStateOf(false) } var exitConfirm by remember { mutableStateOf(false) }
var homeChromeCompact 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 navFocus = remember { FocusRequester() }
val homeContentFocus = remember { FocusRequester() } val homeContentFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() } val exitFocus = remember { FocusRequester() }
@ -487,7 +501,9 @@ private fun MainHub(
} }
val visibleTabs = remember(iptvEnabled) { 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) { val movieSubs = remember(shelfPrefs) {
@ -532,9 +548,15 @@ private fun MainHub(
fun focusTopNav() { fun focusTopNav() {
navFocused = true navFocused = true
exitConfirm = false exitConfirm = false
hubScope.launch { topNavFocusToken++
delay(40) }
runCatching { navFocus.requestFocus() }
// 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() 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) } var navBackToHomeArmed by remember { mutableStateOf(false) }
LaunchedEffect(tab, navFocused) { LaunchedEffect(tab, navFocused) {
navBackToHomeArmed = false navBackToHomeArmed = false
@ -577,13 +599,6 @@ private fun MainHub(
focusTopNav() focusTopNav()
} }
LaunchedEffect(navFocused) {
if (navFocused) {
delay(40)
runCatching { navFocus.requestFocus() }
}
}
LaunchedEffect(tvFocusNonce) { LaunchedEffect(tvFocusNonce) {
if (tab == MainTab.Tv) { if (tab == MainTab.Tv) {
navFocused = false navFocused = false
@ -617,13 +632,22 @@ private fun MainHub(
compact = tab == MainTab.Home && homeChromeCompact, compact = tab == MainTab.Home && homeChromeCompact,
overlay = overlay, overlay = overlay,
profileAvatarEmoji = avatarEmoji(profileAvatarId ?: "fox"), profileAvatarEmoji = avatarEmoji(profileAvatarId ?: "fox"),
profileAvatarUrl = api.absoluteUrl(profileAvatarUrl),
profileShowAvatar = profileShowAvatar,
profileAuthHeader = api.bearerToken()?.let { "Bearer $it" },
profileLabel = profileName, profileLabel = profileName,
onProfileClick = onSwitchProfile, onProfileClick = onSwitchProfile,
onSearchClick = {
onTabChange(MainTab.Search)
exitConfirm = false
homeChromeCompact = false
focusTopNav()
},
onSettingsClick = { onSettingsClick = {
onTabChange(MainTab.Settings) onTabChange(MainTab.Settings)
exitConfirm = false exitConfirm = false
homeChromeCompact = false homeChromeCompact = false
navFocused = true focusTopNav()
}, },
onSelect = { onSelect = {
if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
@ -664,6 +688,7 @@ private fun MainHub(
contentDownFocus = if (tab == MainTab.Home) homeContentFocus else null, contentDownFocus = if (tab == MainTab.Home) homeContentFocus else null,
subItems = subItems, subItems = subItems,
selectedSubId = selectedSubId, selectedSubId = selectedSubId,
focusSelectedToken = topNavFocusToken,
onSelectSub = { item -> onSelectSub = { item ->
if (item.id == "__search__") { if (item.id == "__search__") {
onTabChange(MainTab.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) { LaunchedEffect(Unit) {
delay(80)
val scrolled = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 40
if (scrolled) {
onContentFocused() onContentFocused()
val deadline = System.currentTimeMillis() + 900 val deadline = System.currentTimeMillis() + 700
while (!playFocusReady && System.currentTimeMillis() < deadline) { while (!shelfFocusReady && System.currentTimeMillis() < deadline) {
delay(40) delay(40)
} }
delay(40) delay(40)
runCatching { contentFocusRequester.requestFocus() } runCatching { firstShelfFocus.requestFocus() }
}
} }
val spotlight = home?.spotlight.orEmpty().ifEmpty { val spotlight = home?.spotlight.orEmpty().ifEmpty {
@ -1186,7 +1215,7 @@ private fun HomeScreen(
Spacer(Modifier.height(shelfStart)) Spacer(Modifier.height(shelfStart))
} }
if (error != null) { 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()) { if (continueItems.isNotEmpty()) {
val preview = continueItems.take(5) val preview = continueItems.take(5)
@ -1322,7 +1351,7 @@ private fun HomeScreen(
if (!menuReady) return@McButton if (!menuReady) return@McButton
scope.launch { scope.launch {
runCatching { runCatching {
if (menuItem.type == "movie") { if (menuItem.type.equals("movie", true)) {
api.markWatched(movieId = menuItem.id) api.markWatched(movieId = menuItem.id)
} else { } else {
api.markWatched(episodeId = menuItem.id) api.markWatched(episodeId = menuItem.id)
@ -1332,6 +1361,23 @@ private fun HomeScreen(
continueMenu = null 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 } McButton("Annuleren") { continueMenu = null }
} }
} }
@ -1402,7 +1448,7 @@ private fun SpotlightHero(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = HomeChrome.BadgeTop) .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, verticalArrangement = Arrangement.Top,
) { ) {
Column( Column(
@ -1517,7 +1563,7 @@ private fun ShelfBlock(
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 48.dp), .padding(horizontal = HomeChrome.EdgeInset),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@ -1553,7 +1599,12 @@ private fun ShelfBlock(
LazyRow( LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
// Extra ruimte zodat focusedScale (1.10) posters niet afknipt // Extra ruimte zodat focusedScale (1.10) posters niet afknipt
contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 28.dp, bottom = 28.dp), contentPadding = PaddingValues(
start = HomeChrome.EdgeInset,
end = HomeChrome.EdgeInset,
top = 28.dp,
bottom = 28.dp,
),
) { ) {
itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item -> itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item ->
if (index == 0 && firstItemFocusRequester != null) { if (index == 0 && firstItemFocusRequester != null) {
@ -1637,7 +1688,7 @@ private fun SettingsScreen(
LazyColumn( LazyColumn(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 48.dp, vertical = 28.dp), .padding(horizontal = HomeChrome.EdgeInset, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(bottom = 40.dp), contentPadding = PaddingValues(bottom = 40.dp),
) { ) {
@ -1881,7 +1932,7 @@ private fun WatchlistScreen(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color(0xFF0B0B0F)) .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) Text("Watchlist", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(6.dp)) 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.listTracks
import nl.vonas.mediacluster.tv.playback.selectTrack import nl.vonas.mediacluster.tv.playback.selectTrack
import nl.vonas.mediacluster.tv.ui.components.McButton 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 nl.vonas.mediacluster.tv.ui.theme.McColors
import android.util.Base64 import android.util.Base64
import java.io.File import java.io.File
@ -587,6 +589,11 @@ fun PlayerScreen(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text(formatTime(positionMs), color = Color(0xFFB3B3B3), fontSize = 13.sp) 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) Text(formatTime(durationMs), color = Color(0xFFB3B3B3), fontSize = 13.sp)
} }

View file

@ -57,9 +57,10 @@ fun McButton(
onFocused: (() -> Unit)? = null, onFocused: (() -> Unit)? = null,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val bg = if (primary) Color.White else Color(0x33FFFFFF) // Primary: gedempt tot focus; focused = helder wit (duidelijk verschil met buurknoppen)
val bgFocus = if (primary) Color.White else Color.White val bg = if (primary) Color(0x66FFFFFF) else Color(0x33FFFFFF)
val fg = if (primary) Color.Black else Color.White val bgFocus = Color.White
val fg = Color.White
val fgFocus = Color.Black val fgFocus = Color.Black
Surface( Surface(
onClick = onClick, onClick = onClick,
@ -89,6 +90,19 @@ fun McButton(
contentColor = fg, contentColor = fg,
focusedContentColor = fgFocus, 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(
text = label.ifBlank { "—" }, text = label.ifBlank { "—" },

View file

@ -85,13 +85,13 @@ fun SkeletonShelfRow(
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
SkeletonBox( SkeletonBox(
Modifier Modifier
.padding(horizontal = 48.dp) .padding(horizontal = HomeChrome.EdgeInset)
.width(titleWidth) .width(titleWidth)
.height(18.dp), .height(18.dp),
shape = RoundedCornerShape(3.dp), shape = RoundedCornerShape(3.dp),
) )
LazyRow( LazyRow(
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 12.dp), contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
items(posterCount) { items(posterCount) {
@ -108,7 +108,7 @@ fun SkeletonBillboard(height: Dp = 480.dp) {
Column( Column(
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.padding(start = 48.dp, bottom = 36.dp), .padding(start = HomeChrome.EdgeInset, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
SkeletonBox(Modifier.width(48.dp).height(12.dp)) SkeletonBox(Modifier.width(48.dp).height(12.dp))
@ -147,7 +147,7 @@ fun EmptyState(
Column( Column(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 48.dp, vertical = 40.dp), .padding(horizontal = HomeChrome.EdgeInset, vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp), 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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage
import coil.request.ImageRequest
import kotlinx.coroutines.delay
import nl.vonas.mediacluster.tv.ui.theme.McColors 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). * Berekende hoogte van de Home overlay-topnav (geen subtabs).
* *
* TopNavBar overlay-row: * TopNavBar overlay-row (compact, zonder underline):
* - vertical padding: 14.dp + 14.dp * - vertical padding: 10.dp + 10.dp
* - tab-inhoud: 8.dp + ~21.dp tekst + 4.dp + 3.dp underline + 8.dp ≈ 44.dp * - tab-tekst ~21.dp → veilig 56.dp
* - totaal ≈ 72.dp; TV Surface min. interactive ≈ 48.dp → veilig 76.dp
*/ */
object HomeChrome { object HomeChrome {
val TopNavHeight = 76.dp val TopNavHeight = 56.dp
/** Lucht tussen onderkant menu en badge. */ /** Lucht tussen onderkant menu en badge. */
val BadgeGap = 12.dp 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) { enum class MainTab(val label: String) {
@ -87,10 +101,17 @@ fun TopNavBar(
/** Omlaag vanuit menu → Home-content (Afspelen / planken). */ /** Omlaag vanuit menu → Home-content (Afspelen / planken). */
contentDownFocus: FocusRequester? = null, contentDownFocus: FocusRequester? = null,
profileAvatarEmoji: String? = null, profileAvatarEmoji: String? = null,
profileAvatarUrl: String? = null,
profileShowAvatar: Boolean = true,
profileAuthHeader: String? = null,
profileLabel: String? = null, profileLabel: String? = null,
onProfileClick: (() -> Unit)? = 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, onSettingsClick: (() -> Unit)? = null,
/** Verhoog om focus op de actieve tab te zetten (niet op Home als eerste child). */
focusSelectedToken: Int = 0,
) { ) {
val alpha = when { val alpha = when {
// Overlay: transparantie zit in de gradient, niet in ekstra fade // Overlay: transparantie zit in de gradient, niet in ekstra fade
@ -98,7 +119,7 @@ fun TopNavBar(
compact -> 0.42f compact -> 0.42f
else -> 1f 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( val downLink = Modifier.then(
if (contentDownFocus != null) { if (contentDownFocus != null) {
Modifier.focusProperties { down = contentDownFocus } Modifier.focusProperties { down = contentDownFocus }
@ -106,7 +127,27 @@ fun TopNavBar(
Modifier 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( Column(
modifier modifier
.fillMaxWidth() .fillMaxWidth()
@ -128,7 +169,7 @@ fun TopNavBar(
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 40.dp, vertical = vPad), .padding(horizontal = HomeChrome.EdgeInset, vertical = vPad),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@ -137,33 +178,42 @@ fun TopNavBar(
color = McColors.Brand, color = McColors.Brand,
fontSize = if (compact && !overlay) 22.sp else 28.sp, fontSize = if (compact && !overlay) 22.sp else 28.sp,
fontWeight = FontWeight.Black, 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 active = selected == tab
val color by animateColorAsState( val color by animateColorAsState(
when { when {
active -> Color.White active -> McColors.Brand
overlay -> Color(0xFFE8E8E8) overlay -> Color(0xFFE8E8E8)
else -> Color(0xFF808080) else -> Color(0xFF808080)
}, },
label = "tabColor", label = "tabColor",
) )
val fr = tabFocus[tab]
Surface( Surface(
onClick = { onSelect(tab) }, 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)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (active) Color(0x33FFFFFF) else Color.Transparent, containerColor = Color.Transparent,
focusedContainerColor = Color(0x44FFFFFF), focusedContainerColor = Color(0x33FFFFFF),
contentColor = color, contentColor = color,
focusedContentColor = Color.White, // Actieve tab blijft rood, ook met focus
focusedContentColor = if (active) McColors.Brand else Color.White,
), ),
) { ) {
Column( Box(
Modifier.padding(horizontal = 14.dp, vertical = 8.dp), Modifier
horizontalAlignment = Alignment.CenterHorizontally, .fillMaxHeight()
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center,
) { ) {
Text( Text(
tab.label, tab.label,
@ -171,69 +221,123 @@ fun TopNavBar(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = if (active) FontWeight.Bold else FontWeight.Medium, 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( Box(
Modifier Modifier
.width(if (active) 28.dp else 0.dp) .fillMaxHeight()
.height(3.dp) .padding(horizontal = 8.dp),
.clip(RoundedCornerShape(2.dp)) contentAlignment = Alignment.Center,
.background(if (active) McColors.Brand else Color.Transparent), ) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = "Zoeken",
modifier = Modifier.size(16.dp),
tint = Color.Unspecified,
) )
} }
} }
} }
Spacer(Modifier.weight(1f))
if (onSettingsClick != null) { if (onSettingsClick != null) {
val settingsActive = selected == MainTab.Settings val settingsActive = selected == MainTab.Settings
Surface( Surface(
onClick = onSettingsClick, onClick = onSettingsClick,
modifier = downLink, modifier = downLink
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(50)), .height(if (compact && !overlay) 32.dp else 36.dp)
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), .focusRequester(settingsFocus),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (settingsActive) Color(0x33FFFFFF) else Color.Transparent, containerColor = Color.Transparent,
focusedContainerColor = Color(0x55FFFFFF), focusedContainerColor = Color(0x33FFFFFF),
contentColor = Color.White, contentColor = if (settingsActive) McColors.Brand else Color.White,
focusedContentColor = Color.White, focusedContentColor = if (settingsActive) McColors.Brand else Color.White,
), ),
) { ) {
Box( Box(
Modifier.padding(horizontal = 12.dp, vertical = 10.dp), Modifier
.fillMaxHeight()
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Settings, imageVector = Icons.Filled.Settings,
contentDescription = "Instellingen", contentDescription = "Instellingen",
modifier = Modifier.size(22.dp), modifier = Modifier.size(16.dp),
tint = Color.White, tint = Color.Unspecified,
) )
} }
} }
} }
if (onProfileClick != null && profileAvatarEmoji != null) { Spacer(Modifier.weight(1f))
if (onProfileClick != null && (!profileLabel.isNullOrBlank() || profileAvatarEmoji != null)) {
Surface( Surface(
onClick = onProfileClick, onClick = onProfileClick,
modifier = downLink, modifier = downLink,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(50)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0x33FFFFFF), containerColor = Color.Transparent,
focusedContainerColor = Color(0x55FFFFFF), focusedContainerColor = Color(0x22FFFFFF),
contentColor = Color.White, contentColor = Color.White,
focusedContentColor = Color.White, focusedContentColor = Color.White,
), ),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(1.dp, Color.White),
shape = RoundedCornerShape(6.dp),
),
),
) { ) {
Row( Row(
Modifier.padding(horizontal = 12.dp, vertical = 8.dp), Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, 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()) { if (!profileLabel.isNullOrBlank()) {
Text( Text(
profileLabel, profileLabel,
color = Color.White, color = Color.White,
fontSize = 14.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
maxLines = 1, 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()) { if (subItems.isNotEmpty()) {
LazyRow( LazyRow(
contentPadding = PaddingValues(horizontal = 40.dp, vertical = 4.dp), contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
@ -48,7 +49,9 @@ import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SessionStore import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.data.ViewerProfile import nl.vonas.mediacluster.tv.data.ViewerProfile
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McColors
import androidx.compose.foundation.BorderStroke
private enum class ProfilesMode { Pick, Pin, Create, Edit, DeleteConfirm } private enum class ProfilesMode { Pick, Pin, Create, Edit, DeleteConfirm }
@ -73,8 +76,10 @@ fun ProfilesScreen(
var createKids by remember { mutableStateOf(false) } var createKids by remember { mutableStateOf(false) }
var createPin by remember { mutableStateOf("") } var createPin by remember { mutableStateOf("") }
var clearPin by remember { mutableStateOf(false) } var clearPin by remember { mutableStateOf(false) }
var createShowAvatar by remember { mutableStateOf(true) }
var editingId by remember { mutableStateOf<String?>(null) } var editingId by remember { mutableStateOf<String?>(null) }
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
val avatarFocus = remember { FocusRequester() }
fun openEdit(profile: ViewerProfile) { fun openEdit(profile: ViewerProfile) {
editingId = profile.id editingId = profile.id
@ -82,6 +87,7 @@ fun ProfilesScreen(
createName = profile.name createName = profile.name
createAvatar = profile.avatarId createAvatar = profile.avatarId
createKids = profile.isKids createKids = profile.isKids
createShowAvatar = profile.showAvatar
createPin = "" createPin = ""
clearPin = false clearPin = false
error = null error = null
@ -116,12 +122,25 @@ fun ProfilesScreen(
runCatching { firstFocus.requestFocus() } 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) { fun select(profile: ViewerProfile, pinValue: String? = null) {
scope.launch { scope.launch {
error = null error = null
runCatching { api.selectProfile(profile.id, pinValue) } runCatching { api.selectProfile(profile.id, pinValue) }
.onSuccess { selected -> .onSuccess { selected ->
store.setProfile(selected.id, selected.name, selected.avatarId) store.setProfile(
selected.id,
selected.name,
selected.avatarId,
selected.showAvatar,
selected.avatarUrl,
)
onProfileSelected() onProfileSelected()
} }
.onFailure { .onFailure {
@ -148,7 +167,7 @@ fun ProfilesScreen(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 48.dp, vertical = 36.dp), .padding(horizontal = HomeChrome.EdgeInset, vertical = 36.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
@ -276,6 +295,43 @@ fun ProfilesScreen(
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Spacer(Modifier.height(12.dp)) 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( OutlinedTextField(
value = createName, value = createName,
onValueChange = { createName = it.take(32) }, onValueChange = { createName = it.take(32) },
@ -291,29 +347,6 @@ fun ProfilesScreen(
cursorColor = McColors.Brand, 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)) { if (canManage && (!isEdit || pending?.isOwner != true)) {
Spacer(Modifier.height(14.dp)) Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
@ -344,6 +377,7 @@ fun ProfilesScreen(
profileId = editId, profileId = editId,
name = createName.trim(), name = createName.trim(),
avatarId = createAvatar, avatarId = createAvatar,
showAvatar = createShowAvatar,
isKids = if (canManage && pending?.isOwner != true) createKids else null, isKids = if (canManage && pending?.isOwner != true) createKids else null,
pin = createPin.ifBlank { null }, pin = createPin.ifBlank { null },
clearPin = clearPin, clearPin = clearPin,
@ -359,7 +393,13 @@ fun ProfilesScreen(
}.onSuccess { saved -> }.onSuccess { saved ->
val localId = store.profileId.first() val localId = store.profileId.first()
if (localId == saved.id || editId == localId) { 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) { if (!switcherMode && !canManage) {
onProfileSelected() onProfileSelected()
@ -466,6 +506,12 @@ private fun ProfileTile(
containerColor = Color(0xFF1C1C24), containerColor = Color(0xFF1C1C24),
focusedContainerColor = Color(0xFF2A2A36), focusedContainerColor = Color(0xFF2A2A36),
), ),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, Color.White),
shape = RoundedCornerShape(14.dp),
),
),
) { ) {
Column( Column(
Modifier Modifier
@ -527,6 +573,12 @@ private fun AddProfileTile(onClick: () -> Unit) {
containerColor = Color(0xFF16161E), containerColor = Color(0xFF16161E),
focusedContainerColor = Color(0xFF242430), focusedContainerColor = Color(0xFF242430),
), ),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, Color.White),
shape = RoundedCornerShape(14.dp),
),
),
) { ) {
Column( Column(
Modifier Modifier
@ -583,6 +635,12 @@ private fun ActionChip(label: String, primary: Boolean = false, onClick: () -> U
contentColor = Color.White, contentColor = Color.White,
focusedContentColor = Color.White, focusedContentColor = Color.White,
), ),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, Color.White),
shape = RoundedCornerShape(8.dp),
),
),
) { ) {
Text( Text(
label, label,

View file

@ -169,6 +169,8 @@ actor ApiClient {
profileId: String, profileId: String,
name: String? = nil, name: String? = nil,
avatarId: String? = nil, avatarId: String? = nil,
showAvatar: Bool? = nil,
clearAvatarImage: Bool = false,
isKids: Bool? = nil, isKids: Bool? = nil,
pin: String? = nil, pin: String? = nil,
clearPin: Bool = false clearPin: Bool = false
@ -176,6 +178,8 @@ actor ApiClient {
var body: [String: Any] = [:] var body: [String: Any] = [:]
if let name { body["name"] = name } if let name { body["name"] = name }
if let avatarId { body["avatarId"] = avatarId } if let avatarId { body["avatarId"] = avatarId }
if let showAvatar { body["showAvatar"] = showAvatar }
if clearAvatarImage { body["clearAvatarImage"] = true }
if let isKids { body["isKids"] = isKids } if let isKids { body["isKids"] = isKids }
if clearPin { if clearPin {
body["clearPin"] = true body["clearPin"] = true
@ -189,6 +193,24 @@ actor ApiClient {
return wrap.profile 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 { func deleteProfile(profileId: String) async throws {
let encoded = profileId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? profileId let encoded = profileId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? profileId
let _: Empty = try await requestEmpty("/api/v1/client/profiles/\(encoded)", method: "DELETE") 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() let me = try await api.clientMe()
iptvEnabled = me.iptvEnabled iptvEnabled = me.iptvEnabled
if let profile = me.profile { 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) favorites.bind(profileId: profile.id)
await favorites.refresh(using: api) await favorites.refresh(using: api)
if let payload = try? await api.listProfiles() { if let payload = try? await api.listProfiles() {
@ -117,7 +123,13 @@ final class AppModel: ObservableObject {
} }
func didSelectProfile(_ profile: ViewerProfile) { 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) favorites.bind(profileId: profile.id)
Task { Task {
await favorites.refresh(using: api) await favorites.refresh(using: api)

View file

@ -72,6 +72,12 @@ struct ViewerProfile: Decodable, Identifiable, Hashable {
let isOwner: Bool? let isOwner: Bool?
let isKids: Bool? let isKids: Bool?
let hasPin: 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 { struct ProfilesPayload: Decodable {

View file

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

View file

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

View file

@ -1,4 +1,6 @@
import SwiftUI import SwiftUI
import PhotosUI
import UIKit
struct SettingsScreen: View { struct SettingsScreen: View {
@EnvironmentObject private var app: AppModel @EnvironmentObject private var app: AppModel
@ -118,8 +120,14 @@ struct EditProfileScreen: View {
@State private var name = "" @State private var name = ""
@State private var avatarId = "fox" @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 pin = ""
@State private var clearPin = false @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 message: String?
@State private var error: String? @State private var error: String?
@State private var saving = false @State private var saving = false
@ -128,6 +136,33 @@ struct EditProfileScreen: View {
var body: some View { var body: some View {
Form { 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") { Section("Naam") {
TextField("Naam", text: $name) TextField("Naam", text: $name)
.foregroundStyle(McColors.text) .foregroundStyle(McColors.text)
@ -190,9 +225,54 @@ struct EditProfileScreen: View {
.background(McColors.bg.ignoresSafeArea()) .background(McColors.bg.ignoresSafeArea())
.navigationTitle("Mijn profiel") .navigationTitle("Mijn profiel")
.navigationBarTitleDisplayMode(.inline) .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 ?? "" name = app.store.profileName ?? ""
avatarId = app.store.profileAvatarId ?? "fox" 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 error = nil
message = nil message = nil
do { do {
let updated = try await app.api.updateProfile( var updated = try await app.api.updateProfile(
profileId: profileId, profileId: profileId,
name: name.trimmingCharacters(in: .whitespacesAndNewlines), name: name.trimmingCharacters(in: .whitespacesAndNewlines),
avatarId: avatarId, avatarId: avatarId,
showAvatar: showAvatar,
clearAvatarImage: clearCustomAvatar && pendingImage == nil,
pin: clearPin ? nil : (pin.isEmpty ? nil : pin), pin: clearPin ? nil : (pin.isEmpty ? nil : pin),
clearPin: clearPin 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" message = "Opgeslagen"
pin = "" pin = ""
clearPin = false clearPin = false
@ -379,14 +476,66 @@ struct AdminEditProfileScreen: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var name = "" @State private var name = ""
@State private var avatarId = "fox" @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 isKids = false
@State private var pin = "" @State private var pin = ""
@State private var clearPin = false @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 error: String?
@State private var message: String? @State private var message: String?
var body: some View { var body: some View {
Form { 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") { Section("Naam") {
TextField("Naam", text: $name) TextField("Naam", text: $name)
} }
@ -446,22 +595,44 @@ struct AdminEditProfileScreen: View {
name = profile.name name = profile.name
avatarId = profile.avatarId ?? "fox" avatarId = profile.avatarId ?? "fox"
isKids = profile.isKids == true isKids = profile.isKids == true
showAvatar = profile.showsAvatar
hasCustomAvatar = profile.hasCustomPhoto
remoteAvatarUrl = profile.avatarUrl
} }
} }
private func save() async { private func save() async {
do { do {
let updated = try await app.api.updateProfile( var updated = try await app.api.updateProfile(
profileId: profile.id, profileId: profile.id,
name: name.trimmingCharacters(in: .whitespacesAndNewlines), name: name.trimmingCharacters(in: .whitespacesAndNewlines),
avatarId: avatarId, avatarId: avatarId,
showAvatar: showAvatar,
clearAvatarImage: clearCustomAvatar && pendingImage == nil,
isKids: canManage && profile.isOwner != true ? isKids : nil, isKids: canManage && profile.isOwner != true ? isKids : nil,
pin: clearPin ? nil : (pin.isEmpty ? nil : pin), pin: clearPin ? nil : (pin.isEmpty ? nil : pin),
clearPin: clearPin clearPin: clearPin
) )
if updated.id == app.store.profileId { if let pendingImage,
app.store.setProfile(id: updated.id, name: updated.name, avatarId: updated.avatarId) 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" message = "Opgeslagen"
error = nil error = nil
} catch { } 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 { struct ShelfPrefsScreen: View {
@EnvironmentObject private var app: AppModel @EnvironmentObject private var app: AppModel
@State private var prefs: [ShelfPref] = [] @State private var prefs: [ShelfPref] = []

View file

@ -117,6 +117,10 @@ model ViewerProfile {
viewerUserId String @map("viewer_user_id") viewerUserId String @map("viewer_user_id")
name String name String
avatarId String @default("fox") @map("avatar_id") 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") isOwner Boolean @default(false) @map("is_owner")
isKids Boolean @default(false) @map("is_kids") isKids Boolean @default(false) @map("is_kids")
pinHash String? @map("pin_hash") pinHash String? @map("pin_hash")

View file

@ -562,6 +562,17 @@ async function main() {
console.warn("Could not ensure viewer client tables:", err); 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. // Repair drifted episode.series_id so series.episodes and seasons.episodes stay aligned.
try { try {
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`

View file

@ -1,6 +1,8 @@
import { prisma } from "../database/client"; import { prisma } from "../database/client";
import { hashPassword, verifyPassword } from "../security/crypto"; import { hashPassword, verifyPassword } from "../security/crypto";
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
import fs from "node:fs";
import path from "node:path";
/** Vaste avatar-set (client toont emoji/tekening per id). */ /** Vaste avatar-set (client toont emoji/tekening per id). */
export const PROFILE_AVATARS = [ export const PROFILE_AVATARS = [
@ -21,6 +23,7 @@ export type ProfileAvatarId = (typeof PROFILE_AVATARS)[number]["id"];
const MAX_PROFILES = 8; const MAX_PROFILES = 8;
const PIN_ATTEMPT_WINDOW_MS = 15 * 60 * 1000; const PIN_ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
const PIN_MAX_ATTEMPTS = 5; const PIN_MAX_ATTEMPTS = 5;
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
/** In-memory PIN rate limit: profileId:deviceId → attempts */ /** In-memory PIN rate limit: profileId:deviceId → attempts */
const pinAttempts = new Map<string, { count: number; resetAt: number }>(); const pinAttempts = new Map<string, { count: number; resetAt: number }>();
@ -38,18 +41,35 @@ function normalizePin(pin: string | null | undefined): string | null {
return digits; 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: { function publicProfile(row: {
id: string; id: string;
name: string; name: string;
avatarId: string; avatarId: string;
avatarImage?: string | null;
showAvatar?: boolean | null;
isOwner: boolean; isOwner: boolean;
isKids: boolean; isKids: boolean;
pinHash: string | null; pinHash: string | null;
}) { }) {
const hasCustomAvatar = !!row.avatarImage;
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
avatarId: row.avatarId, avatarId: row.avatarId,
showAvatar: row.showAvatar !== false,
hasCustomAvatar,
avatarUrl: hasCustomAvatar ? `/api/v1/client/profiles/${row.id}/avatar` : null,
isOwner: row.isOwner, isOwner: row.isOwner,
isKids: row.isKids, isKids: row.isKids,
hasPin: !!row.pinHash, hasPin: !!row.pinHash,
@ -94,6 +114,7 @@ export async function ensureOwnerProfile(
viewerUserId: viewerId, viewerUserId: viewerId,
name: name.slice(0, 32), name: name.slice(0, 32),
avatarId: "fox", avatarId: "fox",
showAvatar: true,
isOwner: true, isOwner: true,
isKids: false, isKids: false,
}, },
@ -120,6 +141,7 @@ export class ViewerProfileService {
input: { input: {
name?: string; name?: string;
avatarId?: string; avatarId?: string;
showAvatar?: boolean;
isKids?: boolean; isKids?: boolean;
pin?: string | null; pin?: string | null;
} }
@ -130,25 +152,21 @@ export class ViewerProfileService {
throw new AppError("LIMIT", `Maximaal ${MAX_PROFILES} profielen`, 400); throw new AppError("LIMIT", `Maximaal ${MAX_PROFILES} profielen`, 400);
} }
const name = (input.name || "").trim().slice(0, 32); const name = (input.name?.trim() || `Profiel ${count + 1}`).slice(0, 32);
if (name.length < 1) throw new AppError("INVALID_REQUEST", "Naam verplicht", 400);
const avatarId = input.avatarId || "fox"; const avatarId = input.avatarId || "fox";
if (!isAvatarId(avatarId)) { if (!isAvatarId(avatarId)) {
throw new AppError("INVALID_REQUEST", "Ongeldige avatar", 400); throw new AppError("INVALID_REQUEST", "Ongeldige avatar", 400);
} }
const pin = normalizePin(input.pin); const pin = normalizePin(input.pin);
const isKids = input.isKids === true;
try { try {
const row = await prisma.viewerProfile.create({ const row = await prisma.viewerProfile.create({
data: { data: {
viewerUserId: viewerId, viewerUserId: viewerId,
name, name,
avatarId, avatarId,
showAvatar: input.showAvatar !== false,
isOwner: false, isOwner: false,
isKids, isKids: !!input.isKids,
pinHash: pin ? await hashPassword(pin) : null, pinHash: pin ? await hashPassword(pin) : null,
}, },
}); });
@ -165,6 +183,8 @@ export class ViewerProfileService {
input: { input: {
name?: string; name?: string;
avatarId?: string; avatarId?: string;
showAvatar?: boolean;
clearAvatarImage?: boolean;
isKids?: boolean; isKids?: boolean;
pin?: string | null; pin?: string | null;
clearPin?: boolean; clearPin?: boolean;
@ -198,6 +218,8 @@ export class ViewerProfileService {
const data: { const data: {
name?: string; name?: string;
avatarId?: string; avatarId?: string;
showAvatar?: boolean;
avatarImage?: string | null;
isKids?: boolean; isKids?: boolean;
pinHash?: string | null; pinHash?: string | null;
} = {}; } = {};
@ -213,8 +235,18 @@ export class ViewerProfileService {
} }
data.avatarId = input.avatarId; 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) { if (input.isKids !== undefined && isOwnerActing) {
// Owner-profiel mag geen kids worden
if (target.isOwner && input.isKids) { if (target.isOwner && input.isKids) {
throw new AppError("INVALID_REQUEST", "Hoofdprofiel kan geen kids-profiel zijn", 400); 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) { async remove(viewerId: string, activeProfileId: string | null, profileId: string) {
await assertCanManage(viewerId, activeProfileId); await assertCanManage(viewerId, activeProfileId);
@ -256,6 +360,13 @@ export class ViewerProfileService {
where: { activeProfileId: profileId }, where: { activeProfileId: profileId },
data: { activeProfileId: null }, 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 } }); await prisma.viewerProfile.delete({ where: { id: profileId } });
return { ok: true }; return { ok: true };
} }
@ -277,19 +388,14 @@ export class ViewerProfileService {
let bucket = pinAttempts.get(key); let bucket = pinAttempts.get(key);
if (!bucket || bucket.resetAt < now) { if (!bucket || bucket.resetAt < now) {
bucket = { count: 0, resetAt: now + PIN_ATTEMPT_WINDOW_MS }; bucket = { count: 0, resetAt: now + PIN_ATTEMPT_WINDOW_MS };
pinAttempts.set(key, bucket);
} }
if (bucket.count >= PIN_MAX_ATTEMPTS) { if (bucket.count >= PIN_MAX_ATTEMPTS) {
throw new AppError( throw new AppError("RATE_LIMIT", "Te veel PIN-pogingen, probeer later", 429);
"RATE_LIMITED",
"Te veel PIN-pogingen. Probeer later opnieuw.",
429
);
} }
const ok = pin ? await verifyPassword(String(pin), profile.pinHash) : false;
const ok = pin ? await verifyPassword(profile.pinHash, String(pin).replace(/\D/g, "")) : false;
if (!ok) { if (!ok) {
bucket.count += 1; bucket.count += 1;
pinAttempts.set(key, bucket);
throw new AppError("FORBIDDEN", "Onjuiste PIN", 403); throw new AppError("FORBIDDEN", "Onjuiste PIN", 403);
} }
pinAttempts.delete(key); pinAttempts.delete(key);
@ -297,9 +403,8 @@ export class ViewerProfileService {
await prisma.viewerDevice.update({ await prisma.viewerDevice.update({
where: { id: deviceId }, where: { id: deviceId },
data: { activeProfileId: profile.id }, data: { activeProfileId: profileId, lastSeenAt: new Date() },
}); });
return { profile: publicProfile(profile) }; return { profile: publicProfile(profile) };
} }

View file

@ -483,6 +483,8 @@ export function registerViewerRoutes(
const body = (request.body || {}) as { const body = (request.body || {}) as {
name?: string; name?: string;
avatarId?: string; avatarId?: string;
showAvatar?: boolean;
clearAvatarImage?: boolean;
isKids?: boolean; isKids?: boolean;
pin?: string | null; pin?: string | null;
clearPin?: boolean; clearPin?: boolean;
@ -491,6 +493,39 @@ export function registerViewerRoutes(
return { profile }; 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) => { app.delete("/api/v1/client/profiles/:id", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization); const auth = await viewers.authFromBearer(request.headers.authorization);
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };

View file

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