diff --git a/apps/android-tv/app-mobile/build.gradle.kts b/apps/android-tv/app-mobile/build.gradle.kts index d481ebc..e7f5adb 100644 --- a/apps/android-tv/app-mobile/build.gradle.kts +++ b/apps/android-tv/app-mobile/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster" minSdk = 24 targetSdk = 36 - versionCode = 19 - versionName = "1.0.18" + versionCode = 20 + versionName = "1.0.19" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 74e92ae..1f9d4e1 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -303,6 +303,8 @@ class ApiClient( profileId: String, name: String? = null, avatarId: String? = null, + showAvatar: Boolean? = null, + clearAvatarImage: Boolean = false, isKids: Boolean? = null, pin: String? = null, clearPin: Boolean = false, @@ -310,6 +312,8 @@ class ApiClient( val body = JSONObject() if (name != null) body.put("name", name) if (avatarId != null) body.put("avatarId", avatarId) + if (showAvatar != null) body.put("showAvatar", showAvatar) + if (clearAvatarImage) body.put("clearAvatarImage", true) if (isKids != null) body.put("isKids", isKids) if (clearPin) body.put("clearPin", true) else if (!pin.isNullOrBlank()) body.put("pin", pin) @@ -319,6 +323,28 @@ class ApiClient( parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() } } + suspend fun uploadProfileAvatar( + profileId: String, + imageBase64: String, + mimeType: String = "image/jpeg", + ): ViewerProfile = withContext(Dispatchers.IO) { + val body = JSONObject() + .put("imageBase64", imageBase64) + .put("mimeType", mimeType) + val req = authed("$baseUrl/api/v1/client/profiles/$profileId/avatar") + .post(body.toString().toRequestBody(jsonMedia)) + .build() + parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() } + } + + fun absoluteUrl(path: String?): String? { + if (path.isNullOrBlank()) return null + if (path.startsWith("http")) return path + return "$baseUrl$path" + } + + fun bearerToken(): String? = token + suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) { val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build() execute(req) @@ -755,6 +781,9 @@ data class ViewerProfile( val isOwner: Boolean, val isKids: Boolean, val hasPin: Boolean, + val showAvatar: Boolean = true, + val hasCustomAvatar: Boolean = false, + val avatarUrl: String? = null, ) data class ProfilesPayload( @@ -863,4 +892,7 @@ private fun JSONObject.toProfile(): ViewerProfile = ViewerProfile( isOwner = optBoolean("isOwner"), isKids = optBoolean("isKids"), hasPin = optBoolean("hasPin"), + showAvatar = if (has("showAvatar")) optBoolean("showAvatar") else true, + hasCustomAvatar = optBoolean("hasCustomAvatar"), + avatarUrl = optString("avatarUrl").ifBlank { null }, ) diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt index 1813fa4..09bf354 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt @@ -17,6 +17,8 @@ class SessionStore(private val context: Context) { private val profileIdKey = stringPreferencesKey("profile_id") private val profileNameKey = stringPreferencesKey("profile_name") private val profileAvatarKey = stringPreferencesKey("profile_avatar") + private val profileShowAvatarKey = stringPreferencesKey("profile_show_avatar") + private val profileAvatarUrlKey = stringPreferencesKey("profile_avatar_url") private val recentSearchKey = stringPreferencesKey("recent_searches") val refreshToken: Flow = context.dataStore.data.map { it[tokenKey] } @@ -26,6 +28,12 @@ class SessionStore(private val context: Context) { val profileId: Flow = context.dataStore.data.map { it[profileIdKey] } val profileName: Flow = context.dataStore.data.map { it[profileNameKey] } val profileAvatarId: Flow = context.dataStore.data.map { it[profileAvatarKey] } + val profileShowAvatar: Flow = context.dataStore.data.map { + it[profileShowAvatarKey] != "0" + } + val profileAvatarUrl: Flow = context.dataStore.data.map { + it[profileAvatarUrlKey]?.takeIf { u -> u.isNotBlank() } + } val recentSearches: Flow> = context.dataStore.data.map { prefs -> val raw = prefs[recentSearchKey].orEmpty() @@ -48,23 +56,40 @@ class SessionStore(private val context: Context) { prefs.remove(profileIdKey) prefs.remove(profileNameKey) prefs.remove(profileAvatarKey) + prefs.remove(profileShowAvatarKey) + prefs.remove(profileAvatarUrlKey) } else { prefs[tokenKey] = token } } } - suspend fun setProfile(id: String?, name: String? = null, avatarId: String? = null) { + suspend fun setProfile( + id: String?, + name: String? = null, + avatarId: String? = null, + showAvatar: Boolean? = null, + avatarUrl: String? = null, + ) { context.dataStore.edit { prefs -> if (id.isNullOrBlank()) { prefs.remove(profileIdKey) prefs.remove(profileNameKey) prefs.remove(profileAvatarKey) + prefs.remove(profileShowAvatarKey) + prefs.remove(profileAvatarUrlKey) } else { prefs[profileIdKey] = id if (!name.isNullOrBlank()) prefs[profileNameKey] = name else prefs.remove(profileNameKey) if (!avatarId.isNullOrBlank()) prefs[profileAvatarKey] = avatarId else prefs.remove(profileAvatarKey) + if (showAvatar != null) { + prefs[profileShowAvatarKey] = if (showAvatar) "1" else "0" + } + if (avatarUrl != null) { + if (avatarUrl.isBlank()) prefs.remove(profileAvatarUrlKey) + else prefs[profileAvatarUrlKey] = avatarUrl + } } } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 3430e90..8ae43a1 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -108,6 +108,8 @@ fun McNav( var approveError by remember { mutableStateOf(null) } var approveDoneName by remember { mutableStateOf(null) } val profileAvatarId by store.profileAvatarId.collectAsState(initial = null) + val profileAvatarUrl by store.profileAvatarUrl.collectAsState(initial = null) + val profileShowAvatar by store.profileShowAvatar.collectAsState(initial = true) val profileName by store.profileName.collectAsState(initial = null) // Blijft behouden als main uit compositie gaat (bijv. live player) var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) } @@ -133,7 +135,13 @@ fun McNav( if (authed) { val me = runCatching { api.clientMe() }.getOrNull() if (me?.profile != null) { - store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId) + store.setProfile( + me.profile.id, + me.profile.name, + me.profile.avatarId, + me.profile.showAvatar, + me.profile.avatarUrl ?: "", + ) hasProfile = true canManageDevices = me.profile.isOwner } else { @@ -257,6 +265,9 @@ fun McNav( tvFocusNonce = tvFocusNonce, onTvFocusContent = { tvFocusNonce++ }, profileAvatarId = profileAvatarId, + profileAvatarUrl = api.absoluteUrl(profileAvatarUrl), + profileShowAvatar = profileShowAvatar, + profileAuthHeader = api.bearerToken()?.let { "Bearer $it" }, profileName = profileName, canManageDevices = canManageDevices, onOpenMovie = { nav.navigate("movie/$it") }, @@ -440,6 +451,9 @@ private fun MainHub( tvFocusNonce: Int, onTvFocusContent: () -> Unit, profileAvatarId: String?, + profileAvatarUrl: String?, + profileShowAvatar: Boolean, + profileAuthHeader: String?, profileName: String?, canManageDevices: Boolean, onOpenMovie: (String) -> Unit, @@ -636,6 +650,9 @@ private fun MainHub( MainTab.Settings -> SettingsScreen( api = api, profileAvatarId = profileAvatarId, + profileAvatarUrl = api.absoluteUrl(profileAvatarUrl), + profileShowAvatar = profileShowAvatar, + profileAuthHeader = api.bearerToken()?.let { "Bearer $it" }, profileName = profileName, canManageDevices = canManageDevices, onBack = { @@ -1238,6 +1255,9 @@ private fun ShelfBlock( private fun SettingsScreen( api: ApiClient, profileAvatarId: String? = null, + profileAvatarUrl: String? = null, + profileShowAvatar: Boolean = true, + profileAuthHeader: String? = null, profileName: String? = null, canManageDevices: Boolean = false, onBack: () -> Unit, @@ -1330,7 +1350,23 @@ private fun SettingsScreen( .background(Color(0xFF2E2E3A)), contentAlignment = Alignment.Center, ) { - Text(avatarEmoji(profileAvatarId ?: "fox"), fontSize = 22.sp) + if (profileShowAvatar && !profileAvatarUrl.isNullOrBlank()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(profileAvatarUrl) + .apply { + if (!profileAuthHeader.isNullOrBlank()) { + addHeader("Authorization", profileAuthHeader) + } + } + .build(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Text(avatarEmoji(profileAvatarId ?: "fox"), fontSize = 22.sp) + } } Column(Modifier.weight(1f)) { Text( diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt index 1a6c39c..b1c0cee 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt @@ -1,9 +1,18 @@ package nl.vonas.mediacluster.tv.ui.profiles +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Base64 +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding @@ -25,11 +35,13 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -39,23 +51,34 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import coil.compose.AsyncImage +import coil.request.ImageRequest +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.SessionStore import nl.vonas.mediacluster.tv.data.ViewerProfile import nl.vonas.mediacluster.tv.ui.components.McButton import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McDimens +import java.io.ByteArrayOutputStream +import kotlin.math.roundToInt private enum class ProfilesMode { Pick, Pin, Create, Edit, DeleteConfirm } @@ -81,20 +104,65 @@ fun ProfilesScreen( var createKids by remember { mutableStateOf(false) } var createPin by remember { mutableStateOf("") } var clearPin by remember { mutableStateOf(false) } + var createShowAvatar by remember { mutableStateOf(true) } + var pendingAvatarBase64 by remember { mutableStateOf(null) } + var pendingAvatarPreview by remember { mutableStateOf(null) } + var clearCustomAvatar by remember { mutableStateOf(false) } + var cropSource by remember { mutableStateOf(null) } var editingId by remember { mutableStateOf(null) } + val context = LocalContext.current + + fun resetAvatarDraft() { + pendingAvatarBase64 = null + pendingAvatarPreview = null + clearCustomAvatar = false + cropSource = null + } fun openEdit(profile: ViewerProfile) { editingId = profile.id createName = profile.name createAvatar = profile.avatarId createKids = profile.isKids + createShowAvatar = profile.showAvatar createPin = "" clearPin = false + resetAvatarDraft() pending = profile mode = ProfilesMode.Edit error = null } + fun persistLocalProfile(saved: ViewerProfile) { + scope.launch { + val localId = store.profileId.first() + if (localId == saved.id || editingId == localId) { + store.setProfile( + saved.id, + saved.name, + saved.avatarId, + saved.showAvatar, + saved.avatarUrl ?: "", + ) + } + } + } + + val photoPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + val bmp = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream) + } + } + if (bmp != null) cropSource = bmp + else error = "Foto laden mislukt" + } + } + fun reload() { scope.launch { loading = true @@ -129,7 +197,13 @@ fun ProfilesScreen( error = null runCatching { api.selectProfile(profile.id, pinValue) } .onSuccess { selected -> - store.setProfile(selected.id, selected.name, selected.avatarId) + store.setProfile( + selected.id, + selected.name, + selected.avatarId, + selected.showAvatar, + selected.avatarUrl ?: "", + ) onProfileSelected() } .onFailure { @@ -195,6 +269,8 @@ fun ProfilesScreen( createKids = false createPin = "" clearPin = false + createShowAvatar = true + resetAvatarDraft() mode = ProfilesMode.Create }, ) @@ -217,83 +293,130 @@ fun ProfilesScreen( }, ) - ProfilesMode.Create, ProfilesMode.Edit -> ProfileFormContent( - title = if (mode == ProfilesMode.Edit) "Profiel bewerken" else "Nieuw profiel", - name = createName, - onNameChange = { createName = it.take(32) }, - avatarId = createAvatar, - onAvatarChange = { createAvatar = it }, - isKids = createKids, - onKidsToggle = { createKids = !createKids }, - showKidsToggle = canManage && (mode == ProfilesMode.Create || pending?.isOwner != true), - pin = createPin, - onPinChange = { - createPin = it.filter(Char::isDigit).take(4) - if (it.isNotBlank()) clearPin = false - }, - hasExistingPin = pending?.hasPin == true && mode == ProfilesMode.Edit, - clearPin = clearPin, - onClearPinToggle = { - clearPin = !clearPin - if (clearPin) createPin = "" - }, - saveLabel = if (mode == ProfilesMode.Edit) "Opslaan" else "Aanmaken", - busy = busy, - error = error, - onCancel = { - if (!switcherMode && !canManage) { - onProfileSelected() - } else { - mode = ProfilesMode.Pick - editingId = null - pending = null - error = null - } - }, - onSave = { - if (busy) return@ProfileFormContent - scope.launch { - busy = true - error = null - val editId = editingId - runCatching { - if (editId != null) { - api.updateProfile( - profileId = editId, - name = createName.trim(), - avatarId = createAvatar, - isKids = if (canManage && pending?.isOwner != true) createKids else null, - pin = createPin.ifBlank { null }, - clearPin = clearPin, - ) - } else { - api.createProfile( - name = createName.trim(), - avatarId = createAvatar, - isKids = createKids, - pin = createPin.ifBlank { null }, - ) - } - }.onSuccess { saved -> - val localId = store.profileId.first() - if (localId == saved.id || editId == localId) { - store.setProfile(saved.id, saved.name, saved.avatarId) - } - if (!switcherMode && !canManage) { - onProfileSelected() - } else { - mode = ProfilesMode.Pick - editingId = null - pending = null - reload() - } - }.onFailure { - error = it.message ?: "Opslaan mislukt" + ProfilesMode.Create, ProfilesMode.Edit -> { + ProfileFormContent( + title = if (mode == ProfilesMode.Edit) "Profiel bewerken" else "Nieuw profiel", + name = createName, + onNameChange = { createName = it.take(32) }, + avatarId = createAvatar, + onAvatarChange = { createAvatar = it }, + showAvatar = createShowAvatar, + onShowAvatarToggle = { createShowAvatar = !createShowAvatar }, + hasCustomAvatar = pending?.hasCustomAvatar == true && !clearCustomAvatar, + customAvatarUrl = api.absoluteUrl(pending?.avatarUrl), + authHeader = api.bearerToken()?.let { "Bearer $it" }, + pendingPreview = pendingAvatarPreview, + onPickPhoto = { + photoPicker.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, + onClearCustomPhoto = { + pendingAvatarBase64 = null + pendingAvatarPreview = null + clearCustomAvatar = true + }, + isKids = createKids, + onKidsToggle = { createKids = !createKids }, + showKidsToggle = canManage && (mode == ProfilesMode.Create || pending?.isOwner != true), + pin = createPin, + onPinChange = { + createPin = it.filter(Char::isDigit).take(4) + if (it.isNotBlank()) clearPin = false + }, + hasExistingPin = pending?.hasPin == true && mode == ProfilesMode.Edit, + clearPin = clearPin, + onClearPinToggle = { + clearPin = !clearPin + if (clearPin) createPin = "" + }, + saveLabel = if (mode == ProfilesMode.Edit) "Opslaan" else "Aanmaken", + busy = busy, + error = error, + onCancel = { + if (!switcherMode && !canManage) { + onProfileSelected() + } else { + mode = ProfilesMode.Pick + editingId = null + pending = null + resetAvatarDraft() + error = null } - busy = false - } - }, - ) + }, + onSave = { + if (busy) return@ProfileFormContent + scope.launch { + busy = true + error = null + val editId = editingId + runCatching { + var saved = if (editId != null) { + api.updateProfile( + profileId = editId, + name = createName.trim(), + avatarId = createAvatar, + showAvatar = createShowAvatar, + clearAvatarImage = clearCustomAvatar && pendingAvatarBase64 == null, + isKids = if (canManage && pending?.isOwner != true) createKids else null, + pin = createPin.ifBlank { null }, + clearPin = clearPin, + ) + } else { + var created = api.createProfile( + name = createName.trim(), + avatarId = createAvatar, + isKids = createKids, + pin = createPin.ifBlank { null }, + ) + if (!createShowAvatar) { + created = api.updateProfile( + profileId = created.id, + showAvatar = false, + ) + } + created + } + val avatarB64 = pendingAvatarBase64 + if (avatarB64 != null) { + saved = api.uploadProfileAvatar(saved.id, avatarB64) + } + saved + }.onSuccess { saved -> + persistLocalProfile(saved) + resetAvatarDraft() + if (!switcherMode && !canManage) { + onProfileSelected() + } else { + mode = ProfilesMode.Pick + editingId = null + pending = null + reload() + } + }.onFailure { + error = it.message ?: "Opslaan mislukt" + } + busy = false + } + }, + ) + cropSource?.let { source -> + SquareCropDialog( + bitmap = source, + onDismiss = { cropSource = null }, + onConfirm = { cropped -> + cropSource = null + clearCustomAvatar = false + pendingAvatarPreview = cropped + scope.launch { + pendingAvatarBase64 = withContext(Dispatchers.IO) { + jpegBase64(cropped) + } + } + }, + ) + } + } ProfilesMode.DeleteConfirm -> DeleteContent( name = pending?.name ?: "profiel", @@ -577,6 +700,14 @@ private fun ProfileFormContent( onNameChange: (String) -> Unit, avatarId: String, onAvatarChange: (String) -> Unit, + showAvatar: Boolean, + onShowAvatarToggle: () -> Unit, + hasCustomAvatar: Boolean, + customAvatarUrl: String?, + authHeader: String?, + pendingPreview: Bitmap?, + onPickPhoto: () -> Unit, + onClearCustomPhoto: () -> Unit, isKids: Boolean, onKidsToggle: () -> Unit, showKidsToggle: Boolean, @@ -591,6 +722,7 @@ private fun ProfileFormContent( onCancel: () -> Unit, onSave: () -> Unit, ) { + val ctx = LocalContext.current Column( Modifier .fillMaxSize() @@ -600,6 +732,56 @@ private fun ProfileFormContent( ) { Text(title, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(20.dp)) + + Text("Profielfoto", color = Color(0xFFCCCCCC), fontSize = 14.sp, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(10.dp)) + Box( + Modifier + .size(96.dp) + .clip(CircleShape) + .background(Color(0xFF2A2A32)), + contentAlignment = Alignment.Center, + ) { + when { + pendingPreview != null -> { + Image( + bitmap = pendingPreview.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + hasCustomAvatar && !customAvatarUrl.isNullOrBlank() -> { + AsyncImage( + model = ImageRequest.Builder(ctx) + .data(customAvatarUrl) + .apply { + if (!authHeader.isNullOrBlank()) { + addHeader("Authorization", authHeader) + } + } + .build(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + else -> Text(avatarEmoji(avatarId), fontSize = 42.sp) + } + } + Spacer(Modifier.height(12.dp)) + McButton( + if (showAvatar) "Profielfoto aan/uit: aan" else "Profielfoto aan/uit: uit", + onClick = onShowAvatarToggle, + ) + Spacer(Modifier.height(8.dp)) + McButton("Foto kiezen", onClick = onPickPhoto) + if (hasCustomAvatar || pendingPreview != null) { + Spacer(Modifier.height(8.dp)) + McButton("Foto verwijderen", onClick = onClearCustomPhoto) + } + + Spacer(Modifier.height(16.dp)) OutlinedTextField( value = name, onValueChange = onNameChange, @@ -681,6 +863,99 @@ private fun ProfileFormContent( } } +@Composable +private fun SquareCropDialog( + bitmap: Bitmap, + onDismiss: () -> Unit, + onConfirm: (Bitmap) -> Unit, +) { + val side = minOf(bitmap.width, bitmap.height).toFloat() + val maxOffsetX = ((bitmap.width - side) / 2f).coerceAtLeast(0f) + val maxOffsetY = ((bitmap.height - side) / 2f).coerceAtLeast(0f) + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + val density = androidx.compose.ui.platform.LocalDensity.current + val previewPx = with(density) { 280.dp.toPx() } + val imgScale = previewPx / side + + AlertDialog( + onDismissRequest = onDismiss, + containerColor = Color(0xFF1C1C24), + title = { + Text("Foto bijsnijden", color = Color.White, fontWeight = FontWeight.SemiBold) + }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "Sleep om bij te snijden (vierkant)", + color = Color(0xFFAAAAAA), + fontSize = 13.sp, + ) + Spacer(Modifier.height(12.dp)) + Box( + Modifier + .size(280.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black) + .pointerInput(bitmap) { + detectDragGestures { change, drag -> + change.consume() + offsetX = (offsetX + drag.x / imgScale) + .coerceIn(-maxOffsetX, maxOffsetX) + offsetY = (offsetY + drag.y / imgScale) + .coerceIn(-maxOffsetY, maxOffsetY) + } + }, + contentAlignment = Alignment.Center, + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .size( + width = with(density) { (bitmap.width * imgScale).toDp() }, + height = with(density) { (bitmap.height * imgScale).toDp() }, + ) + .offset { + IntOffset( + (offsetX * imgScale).roundToInt(), + (offsetY * imgScale).roundToInt(), + ) + }, + contentScale = ContentScale.FillBounds, + ) + } + } + }, + confirmButton = { + McButton("Gebruik", primary = true, onClick = { + onConfirm(cropSquare(bitmap, offsetX, offsetY)) + }) + }, + dismissButton = { + McButton("Annuleer", onClick = onDismiss) + }, + ) +} + +private fun cropSquare(bitmap: Bitmap, offsetX: Float, offsetY: Float): Bitmap { + val side = minOf(bitmap.width, bitmap.height) + val centerX = bitmap.width / 2f + offsetX + val centerY = bitmap.height / 2f + offsetY + val left = (centerX - side / 2f).roundToInt().coerceIn(0, bitmap.width - side) + val top = (centerY - side / 2f).roundToInt().coerceIn(0, bitmap.height - side) + val square = Bitmap.createBitmap(bitmap, left, top, side, side) + val outSize = 512.coerceAtMost(side) + return if (square.width == outSize) square + else Bitmap.createScaledBitmap(square, outSize, outSize, true) +} + +private fun jpegBase64(bitmap: Bitmap): String { + val baos = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 85, baos) + return Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP) +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun DeleteContent( diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 72cb70e..9530b87 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 36 - versionCode = 91 - versionName = "0.14.28" + versionCode = 105 + versionName = "0.14.42" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 4cf8197..00733c4 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -251,6 +251,8 @@ class ApiClient( seriesId: String? = null, positionMs: Long, durationMs: Long, + completed: Boolean? = null, + hiddenFromContinue: Boolean? = null, ) = withContext(Dispatchers.IO) { val o = JSONObject() .put("mediaType", mediaType) @@ -259,6 +261,8 @@ class ApiClient( if (movieId != null) o.put("movieId", movieId) if (episodeId != null) o.put("episodeId", episodeId) if (seriesId != null) o.put("seriesId", seriesId) + if (completed != null) o.put("completed", completed) + if (hiddenFromContinue != null) o.put("hiddenFromContinue", hiddenFromContinue) val req = authed("$baseUrl/api/v1/client/progress") .post(o.toString().toRequestBody(jsonMedia)) .build() @@ -368,6 +372,8 @@ class ApiClient( profileId: String, name: String? = null, avatarId: String? = null, + showAvatar: Boolean? = null, + clearAvatarImage: Boolean = false, isKids: Boolean? = null, pin: String? = null, clearPin: Boolean = false, @@ -375,6 +381,8 @@ class ApiClient( val body = JSONObject() if (name != null) body.put("name", name) if (avatarId != null) body.put("avatarId", avatarId) + if (showAvatar != null) body.put("showAvatar", showAvatar) + if (clearAvatarImage) body.put("clearAvatarImage", true) if (isKids != null) body.put("isKids", isKids) if (clearPin) body.put("clearPin", true) else if (!pin.isNullOrBlank()) body.put("pin", pin) @@ -384,6 +392,28 @@ class ApiClient( parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() } } + suspend fun uploadProfileAvatar( + profileId: String, + imageBase64: String, + mimeType: String = "image/jpeg", + ): ViewerProfile = withContext(Dispatchers.IO) { + val body = JSONObject() + .put("imageBase64", imageBase64) + .put("mimeType", mimeType) + val req = authed("$baseUrl/api/v1/client/profiles/$profileId/avatar") + .post(body.toString().toRequestBody(jsonMedia)) + .build() + parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() } + } + + fun absoluteUrl(path: String?): String? { + if (path.isNullOrBlank()) return null + if (path.startsWith("http")) return path + return "$baseUrl$path" + } + + fun bearerToken(): String? = token + suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) { val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build() execute(req) @@ -739,6 +769,9 @@ data class ViewerProfile( val isOwner: Boolean, val isKids: Boolean, val hasPin: Boolean, + val showAvatar: Boolean = true, + val hasCustomAvatar: Boolean = false, + val avatarUrl: String? = null, ) data class ProfilesPayload( @@ -849,4 +882,7 @@ private fun JSONObject.toProfile(): ViewerProfile = ViewerProfile( isOwner = optBoolean("isOwner"), isKids = optBoolean("isKids"), hasPin = optBoolean("hasPin"), + showAvatar = if (has("showAvatar")) optBoolean("showAvatar") else true, + hasCustomAvatar = optBoolean("hasCustomAvatar"), + avatarUrl = optString("avatarUrl").ifBlank { null }, ) diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt index 4ef78a2..4807ce0 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt @@ -18,6 +18,8 @@ class SessionStore(private val context: Context) { private val profileIdKey = stringPreferencesKey("profile_id") private val profileNameKey = stringPreferencesKey("profile_name") private val profileAvatarKey = stringPreferencesKey("profile_avatar") + private val profileShowAvatarKey = stringPreferencesKey("profile_show_avatar") + private val profileAvatarUrlKey = stringPreferencesKey("profile_avatar_url") private val recentSearchKey = stringPreferencesKey("recent_searches") private val homeCacheKey = stringPreferencesKey("home_cache_json") private val tvFavoritesKey = stringPreferencesKey("tv_favorites") @@ -31,6 +33,12 @@ class SessionStore(private val context: Context) { val profileId: Flow = context.dataStore.data.map { it[profileIdKey] } val profileName: Flow = context.dataStore.data.map { it[profileNameKey] } val profileAvatarId: Flow = context.dataStore.data.map { it[profileAvatarKey] } + val profileShowAvatar: Flow = context.dataStore.data.map { + it[profileShowAvatarKey] != "0" + } + val profileAvatarUrl: Flow = context.dataStore.data.map { + it[profileAvatarUrlKey]?.takeIf { u -> u.isNotBlank() } + } val recentSearches: Flow> = context.dataStore.data.map { prefs -> val raw = prefs[recentSearchKey].orEmpty() @@ -67,23 +75,40 @@ class SessionStore(private val context: Context) { prefs.remove(profileIdKey) prefs.remove(profileNameKey) prefs.remove(profileAvatarKey) + prefs.remove(profileShowAvatarKey) + prefs.remove(profileAvatarUrlKey) } else { prefs[tokenKey] = token } } } - suspend fun setProfile(id: String?, name: String? = null, avatarId: String? = null) { + suspend fun setProfile( + id: String?, + name: String? = null, + avatarId: String? = null, + showAvatar: Boolean? = null, + avatarUrl: String? = null, + ) { context.dataStore.edit { prefs -> if (id.isNullOrBlank()) { prefs.remove(profileIdKey) prefs.remove(profileNameKey) prefs.remove(profileAvatarKey) + prefs.remove(profileShowAvatarKey) + prefs.remove(profileAvatarUrlKey) } else { prefs[profileIdKey] = id if (!name.isNullOrBlank()) prefs[profileNameKey] = name else prefs.remove(profileNameKey) if (!avatarId.isNullOrBlank()) prefs[profileAvatarKey] = avatarId else prefs.remove(profileAvatarKey) + if (showAvatar != null) { + prefs[profileShowAvatarKey] = if (showAvatar) "1" else "0" + } + if (avatarUrl != null) { + if (avatarUrl.isBlank()) prefs.remove(profileAvatarUrlKey) + else prefs[profileAvatarUrlKey] = avatarUrl + } } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt index 3c0258a..4c5e1a9 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt @@ -370,6 +370,45 @@ fun BrowseScreen( continueMenu = null } } + McButton("Markeer als gezien") { + if (!menuReady) return@McButton + scope.launch { + runCatching { + if (menuItem.type.equals("movie", true)) { + api.markWatched(movieId = menuItem.id) + } else { + api.markWatched(episodeId = menuItem.id) + } + items = items.filterNot { it.id == menuItem.id && it.progressId == menuItem.progressId } + total = (total - 1).coerceAtLeast(0) + }.onFailure { error = it.message } + continueMenu = null + } + } + when { + menuItem.type.equals("movie", true) -> { + McButton("Ga naar film") { + if (!menuReady) return@McButton + continueMenu = null + onOpenMovie(menuItem.id) + } + } + !menuItem.seriesId.isNullOrBlank() -> { + McButton("Ga naar serie") { + if (!menuReady) return@McButton + val sid = menuItem.seriesId!! + continueMenu = null + onOpenSeries(sid) + } + } + menuItem.type.equals("series", true) -> { + McButton("Ga naar serie") { + if (!menuReady) return@McButton + continueMenu = null + onOpenSeries(menuItem.id) + } + } + } McButton("Annuleren") { continueMenu = null } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt index 1becf97..5cca077 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt @@ -34,12 +34,21 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import nl.vonas.mediacluster.tv.ui.components.McButton +import nl.vonas.mediacluster.tv.ui.components.HomeChrome +import nl.vonas.mediacluster.tv.ui.components.formatEndsAt +import nl.vonas.mediacluster.tv.ui.components.rememberClockTickMs +import nl.vonas.mediacluster.tv.ui.components.remainingMsFromRuntimeMinutes import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale @@ -245,9 +254,9 @@ private fun DetailActionButton( focusRequester: FocusRequester? = null, onClick: () -> Unit, ) { - val bg = if (primary) Color.White else Color(0xE6181818) - val bgFocus = if (primary) Color(0xFFE8E8E8) else Color.White - val fg = if (primary) Color.Black else Color.White + val bg = Color(0xE6181818) + val bgFocus = Color.White + val fg = Color.White Surface( onClick = onClick, modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier, @@ -260,20 +269,16 @@ private fun DetailActionButton( contentColor = fg, focusedContentColor = Color.Black, ), - border = if (primary) { - ClickableSurfaceDefaults.border() - } else { - ClickableSurfaceDefaults.border( - border = Border( - border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)), - shape = RoundedCornerShape(4.dp), - ), - focusedBorder = Border( - border = androidx.compose.foundation.BorderStroke(1.dp, Color.White), - shape = RoundedCornerShape(4.dp), - ), - ) - }, + border = ClickableSurfaceDefaults.border( + border = Border( + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)), + shape = RoundedCornerShape(4.dp), + ), + focusedBorder = Border( + border = androidx.compose.foundation.BorderStroke(2.dp, Color.White), + shape = RoundedCornerShape(4.dp), + ), + ), ) { Text( text = label, @@ -286,6 +291,52 @@ private fun DetailActionButton( } } +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun ExpandableOverview( + text: String, + modifier: Modifier = Modifier, + collapsedLines: Int = 4, +) { + var expanded by remember { mutableStateOf(false) } + Surface( + onClick = { expanded = !expanded }, + modifier = modifier, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.01f), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color(0x22FFFFFF), + contentColor = McColors.TextDim, + focusedContentColor = Color.White, + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x66FFFFFF)), + shape = RoundedCornerShape(6.dp), + ), + ), + ) { + Column(Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) { + Text( + text, + color = Color.Unspecified, + fontSize = 15.sp, + maxLines = if (expanded) Int.MAX_VALUE else collapsedLines, + overflow = TextOverflow.Ellipsis, + lineHeight = 22.sp, + ) + Spacer(Modifier.height(4.dp)) + Text( + if (expanded) "OK · inklappen" else "OK · alles lezen", + color = Color(0xFFAAAAAA), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } + } +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun MovieDetailScreen( @@ -364,6 +415,10 @@ fun MovieDetailScreen( } val selected = versions.firstOrNull { it.id == selectedVersionId } ?: versions.firstOrNull() + val nowTick = rememberClockTickMs() + val endsAtLabel = remember(runtime, nowTick) { + remainingMsFromRuntimeMinutes(runtime)?.let { formatEndsAt(it, nowTick) } + } val metaParts = buildList { year?.let { add(it.toString()) } formatRuntime(runtime)?.let(::add) @@ -379,7 +434,7 @@ fun MovieDetailScreen( Modifier .fillMaxHeight() .fillMaxWidth(0.58f) - .padding(start = 44.dp, end = 20.dp, top = 24.dp, bottom = 24.dp) + .padding(start = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset, top = 24.dp, bottom = 24.dp) .verticalScroll(scroll), verticalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -387,6 +442,14 @@ fun MovieDetailScreen( Spacer(Modifier.height(4.dp)) DetailLogoOrTitle(logoUrl, title) MetaChips(metaParts) + if (endsAtLabel != null) { + Text( + endsAtLabel, + color = Color(0xFF9A9AA8), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + } if (versions.size > 1) { SelectedQualityLine(selected) } @@ -415,14 +478,7 @@ fun MovieDetailScreen( } } if (overview.isNotBlank()) { - Text( - overview, - color = McColors.TextDim, - fontSize = 15.sp, - maxLines = 4, - overflow = TextOverflow.Ellipsis, - lineHeight = 21.sp, - ) + ExpandableOverview(overview) } if (director.isNotEmpty()) { Text( @@ -539,11 +595,38 @@ fun SeriesDetailScreen( var genres by remember { mutableStateOf>(emptyList()) } var trailerKey by remember { mutableStateOf(null) } var seasons by remember { mutableStateOf>(emptyList()) } - var selectedSeason by remember { mutableIntStateOf(0) } + var selectedSeason by rememberSaveable(seriesId) { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var onWatchlist by remember { mutableStateOf(false) } + var episodeMenu by remember { mutableStateOf(null) } + var menuReady by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val firstEpFocus = remember { FocusRequester() } + val menuFocus = remember { FocusRequester() } + + fun applyEpisodeCompleted(episodeId: String, completed: Boolean) { + seasons = seasons.map { season -> + val eps = season.optJSONArray("episodes") ?: return@map season + val copy = JSONObject(season.toString()) + val newEps = JSONArray() + for (i in 0 until eps.length()) { + val e = JSONObject(eps.getJSONObject(i).toString()) + if (e.optString("id") == episodeId) { + e.put("completed", completed) + if (completed) { + e.put("positionMs", 1) + e.put("durationMs", 1) + } else { + e.put("positionMs", 0) + e.put("durationMs", 0) + } + } + newEps.put(e) + } + copy.put("episodes", newEps) + copy + } + } LaunchedEffect(seriesId) { try { @@ -565,14 +648,18 @@ fun SeriesDetailScreen( for (i in 0 until arr.length()) list += arr.getJSONObject(i) } seasons = list - selectedSeason = list.firstOrNull()?.optInt("seasonNumber") ?: 0 + val current = selectedSeason + if (current == null || list.none { it.optInt("seasonNumber") == current }) { + selectedSeason = list.firstOrNull()?.optInt("seasonNumber") + } } catch (e: Exception) { error = e.message } } - val episodes = remember(seasons, selectedSeason) { - val season = seasons.firstOrNull { it.optInt("seasonNumber") == selectedSeason } + val seasonNumber = selectedSeason ?: 0 + val episodes = remember(seasons, seasonNumber) { + val season = seasons.firstOrNull { it.optInt("seasonNumber") == seasonNumber } val eps = season?.optJSONArray("episodes") ?: return@remember emptyList() buildList { for (i in 0 until eps.length()) { @@ -598,10 +685,14 @@ fun SeriesDetailScreen( } } - LaunchedEffect(episodes) { - if (episodes.isNotEmpty()) { + LaunchedEffect(episodeMenu) { + if (episodeMenu != null) { + menuReady = false delay(80) - runCatching { firstEpFocus.requestFocus() } + menuReady = true + runCatching { menuFocus.requestFocus() } + } else { + menuReady = false } } @@ -610,12 +701,22 @@ fun SeriesDetailScreen( genres.take(3).forEach(::add) rating?.let { add("★ $it") } } + val playEpPreview = remember(episodes) { + episodes.firstOrNull { !it.completed && it.positionMs > 30_000L } + ?: episodes.firstOrNull { !it.completed } + ?: episodes.firstOrNull() + } BackHandler(onBack = onBack) Box(Modifier.fillMaxSize()) { DetailBackdrop(backdrop) LazyColumn( - contentPadding = PaddingValues(start = 44.dp, end = 44.dp, top = 28.dp, bottom = 40.dp), + contentPadding = PaddingValues( + start = HomeChrome.EdgeInset, + end = HomeChrome.EdgeInset, + top = 28.dp, + bottom = 40.dp, + ), verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { @@ -624,25 +725,15 @@ fun SeriesDetailScreen( DetailLogoOrTitle(logoUrl, title) MetaChips(metaParts) if (overview.isNotBlank()) { - Text( + ExpandableOverview( overview, - color = McColors.TextDim, - fontSize = 15.sp, - maxLines = 4, - overflow = TextOverflow.Ellipsis, - lineHeight = 22.sp, modifier = Modifier.fillMaxWidth(0.72f).padding(top = 4.dp), ) } if (error != null) Text(error!!, color = McColors.Danger) Spacer(Modifier.height(6.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - val playEp = remember(episodes) { - episodes.firstOrNull { !it.completed && it.positionMs > 30_000L } - ?: episodes.firstOrNull { !it.completed } - ?: episodes.firstOrNull() - } - playEp?.let { ep -> + playEpPreview?.let { ep -> val resume = !ep.completed && ep.positionMs > 30_000L DetailActionButton( if (resume) "Verder kijken" else "Afspelen", @@ -675,7 +766,7 @@ fun SeriesDetailScreen( val n = s.optInt("seasonNumber") DetailActionButton( "Seizoen $n", - primary = n == selectedSeason, + primary = n == seasonNumber, ) { selectedSeason = n } } } @@ -683,7 +774,7 @@ fun SeriesDetailScreen( Spacer(Modifier.height(4.dp)) Text("Afleveringen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) } - itemsIndexed(episodes) { index, ep -> + itemsIndexed(episodes) { _, ep -> EpisodeCard( episode = ep, focusRequester = null, @@ -695,9 +786,80 @@ fun SeriesDetailScreen( if (ep.completed) 0L else ep.positionMs, ) }, + onLongClick = { episodeMenu = ep }, ) } } + + val menuEp = episodeMenu + if (menuEp != null) { + BackHandler { episodeMenu = null } + Dialog( + onDismissRequest = { episodeMenu = null }, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + ) { + Box( + Modifier + .fillMaxSize() + .background(Color(0xCC000000)), + contentAlignment = Alignment.Center, + ) { + Column( + Modifier + .width(440.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFF1A1A1A)) + .padding(28.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "S${menuEp.season}E${menuEp.number} · ${menuEp.title}", + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + ) + Spacer(Modifier.height(4.dp)) + if (menuEp.completed) { + McButton("Markeer als niet gekeken", focusRequester = menuFocus) { + if (!menuReady) return@McButton + scope.launch { + runCatching { + api.postProgress( + mediaType = "EPISODE", + episodeId = menuEp.id, + seriesId = seriesId, + positionMs = 0, + durationMs = 0, + completed = false, + hiddenFromContinue = true, + ) + applyEpisodeCompleted(menuEp.id, false) + }.onFailure { error = it.message } + episodeMenu = null + } + } + } else { + McButton("Markeer als gezien", focusRequester = menuFocus) { + if (!menuReady) return@McButton + scope.launch { + runCatching { + api.markWatched(episodeId = menuEp.id) + applyEpisodeCompleted(menuEp.id, true) + }.onFailure { error = it.message } + episodeMenu = null + } + } + } + McButton("Annuleren") { episodeMenu = null } + } + } + } + } } } @@ -728,11 +890,21 @@ private fun EpisodeCard( episode: EpisodeRow, focusRequester: FocusRequester?, onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, ) { + var focused by remember { mutableStateOf(false) } + val endsAt = if (focused) { + remainingMsFromRuntimeMinutes(episode.runtime, episode.positionMs, episode.completed) + ?.let { formatEndsAt(it) } + } else { + null + } Surface( onClick = onClick, + onLongClick = onLongClick, modifier = Modifier .fillMaxWidth() + .onFocusChanged { focused = it.isFocused } .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f), @@ -817,8 +989,13 @@ private fun EpisodeCard( lineHeight = 18.sp, ) } - formatRuntime(episode.runtime)?.let { - Text(it, color = Color(0xFF888888), fontSize = 12.sp) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + formatRuntime(episode.runtime)?.let { + Text(it, color = Color(0xFF888888), fontSize = 12.sp) + } + if (endsAt != null) { + Text(endsAt, color = Color(0xFFFFD54F), fontSize = 12.sp, fontWeight = FontWeight.Medium) + } } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 42b425c..add6ba7 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -119,6 +119,8 @@ fun McNav() { var hasProfile by remember { mutableStateOf(false) } var isOwner by remember { mutableStateOf(false) } val profileAvatarId by store.profileAvatarId.collectAsState(initial = null) + val profileShowAvatar by store.profileShowAvatar.collectAsState(initial = true) + val profileAvatarUrl by store.profileAvatarUrl.collectAsState(initial = null) val profileName by store.profileName.collectAsState(initial = null) // Blijft behouden als main uit compositie gaat (bijv. live player) var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) } @@ -145,7 +147,13 @@ fun McNav() { val me = runCatching { api.clientMe() }.getOrNull() when { me?.profile != null -> { - store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId) + store.setProfile( + me.profile.id, + me.profile.name, + me.profile.avatarId, + me.profile.showAvatar, + me.profile.avatarUrl, + ) hasProfile = true isOwner = me.profile.isOwner } @@ -284,6 +292,8 @@ fun McNav() { tvFocusNonce = tvFocusNonce, onTvFocusContent = { tvFocusNonce++ }, profileAvatarId = profileAvatarId, + profileShowAvatar = profileShowAvatar, + profileAvatarUrl = profileAvatarUrl, profileName = profileName, onOpenMovie = { nav.navigate("movie/$it") }, onOpenSeries = { nav.navigate("series/$it") }, @@ -442,6 +452,8 @@ private fun MainHub( tvFocusNonce: Int, onTvFocusContent: () -> Unit, profileAvatarId: String?, + profileShowAvatar: Boolean = true, + profileAvatarUrl: String? = null, profileName: String?, onOpenMovie: (String) -> Unit, onOpenSeries: (String) -> Unit, @@ -457,6 +469,8 @@ private fun MainHub( var navFocused by remember { mutableStateOf(false) } var exitConfirm by remember { mutableStateOf(false) } var homeChromeCompact by remember { mutableStateOf(false) } + var topNavFocusToken by remember { mutableIntStateOf(0) } + var coldStartNavFocus by rememberSaveable { mutableStateOf(true) } val navFocus = remember { FocusRequester() } val homeContentFocus = remember { FocusRequester() } val exitFocus = remember { FocusRequester() } @@ -487,7 +501,9 @@ private fun MainHub( } val visibleTabs = remember(iptvEnabled) { - MainTab.entries.filter { it != MainTab.Settings && (it != MainTab.Tv || iptvEnabled) } + MainTab.entries.filter { + it != MainTab.Settings && it != MainTab.Search && (it != MainTab.Tv || iptvEnabled) + } } val movieSubs = remember(shelfPrefs) { @@ -532,9 +548,15 @@ private fun MainHub( fun focusTopNav() { navFocused = true exitConfirm = false - hubScope.launch { - delay(40) - runCatching { navFocus.requestFocus() } + topNavFocusToken++ + } + + // App-start: focus op Home in het menu, niet op Afspelen in de hero + LaunchedEffect(Unit) { + if (coldStartNavFocus) { + coldStartNavFocus = false + delay(120) + focusTopNav() } } @@ -554,7 +576,7 @@ private fun MainHub( focusTopNav() } } - // Films/Series/…: Back in top-nav → Home (kort uitgeschakeld na tab-wissel tegen race) + // Films/Series/…: Back in top-nav → Home var navBackToHomeArmed by remember { mutableStateOf(false) } LaunchedEffect(tab, navFocused) { navBackToHomeArmed = false @@ -577,13 +599,6 @@ private fun MainHub( focusTopNav() } - LaunchedEffect(navFocused) { - if (navFocused) { - delay(40) - runCatching { navFocus.requestFocus() } - } - } - LaunchedEffect(tvFocusNonce) { if (tab == MainTab.Tv) { navFocused = false @@ -617,13 +632,22 @@ private fun MainHub( compact = tab == MainTab.Home && homeChromeCompact, overlay = overlay, profileAvatarEmoji = avatarEmoji(profileAvatarId ?: "fox"), + profileAvatarUrl = api.absoluteUrl(profileAvatarUrl), + profileShowAvatar = profileShowAvatar, + profileAuthHeader = api.bearerToken()?.let { "Bearer $it" }, profileLabel = profileName, onProfileClick = onSwitchProfile, + onSearchClick = { + onTabChange(MainTab.Search) + exitConfirm = false + homeChromeCompact = false + focusTopNav() + }, onSettingsClick = { onTabChange(MainTab.Settings) exitConfirm = false homeChromeCompact = false - navFocused = true + focusTopNav() }, onSelect = { if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar @@ -664,6 +688,7 @@ private fun MainHub( contentDownFocus = if (tab == MainTab.Home) homeContentFocus else null, subItems = subItems, selectedSubId = selectedSubId, + focusSelectedToken = topNavFocusToken, onSelectSub = { item -> if (item.id == "__search__") { onTabChange(MainTab.Search) @@ -1034,15 +1059,19 @@ private fun HomeScreen( } } - // Bij elke verschijning van Home (ook na serie/film/player): focus op content, niet op top-nav. + // Bij terugkeer van detail: geen jump naar Afspelen/top. Alleen content-focus als we gescrolld waren. LaunchedEffect(Unit) { - onContentFocused() - val deadline = System.currentTimeMillis() + 900 - while (!playFocusReady && System.currentTimeMillis() < deadline) { + delay(80) + val scrolled = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 40 + if (scrolled) { + onContentFocused() + val deadline = System.currentTimeMillis() + 700 + while (!shelfFocusReady && System.currentTimeMillis() < deadline) { + delay(40) + } delay(40) + runCatching { firstShelfFocus.requestFocus() } } - delay(40) - runCatching { contentFocusRequester.requestFocus() } } val spotlight = home?.spotlight.orEmpty().ifEmpty { @@ -1186,7 +1215,7 @@ private fun HomeScreen( Spacer(Modifier.height(shelfStart)) } if (error != null) { - item { Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = 40.dp)) } + item { Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset)) } } if (continueItems.isNotEmpty()) { val preview = continueItems.take(5) @@ -1322,7 +1351,7 @@ private fun HomeScreen( if (!menuReady) return@McButton scope.launch { runCatching { - if (menuItem.type == "movie") { + if (menuItem.type.equals("movie", true)) { api.markWatched(movieId = menuItem.id) } else { api.markWatched(episodeId = menuItem.id) @@ -1332,6 +1361,23 @@ private fun HomeScreen( continueMenu = null } } + when { + menuItem.type.equals("movie", true) -> { + McButton("Ga naar film") { + if (!menuReady) return@McButton + continueMenu = null + onOpenMovie(menuItem.id) + } + } + !menuItem.seriesId.isNullOrBlank() -> { + McButton("Ga naar serie") { + if (!menuReady) return@McButton + val sid = menuItem.seriesId!! + continueMenu = null + onOpenSeries(sid) + } + } + } McButton("Annuleren") { continueMenu = null } } } @@ -1402,7 +1448,7 @@ private fun SpotlightHero( Modifier .fillMaxSize() .padding(top = HomeChrome.BadgeTop) - .padding(start = 48.dp, end = 20.dp, bottom = 36.dp), + .padding(start = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset, bottom = 36.dp), verticalArrangement = Arrangement.Top, ) { Column( @@ -1517,7 +1563,7 @@ private fun ShelfBlock( Row( Modifier .fillMaxWidth() - .padding(horizontal = 48.dp), + .padding(horizontal = HomeChrome.EdgeInset), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -1553,7 +1599,12 @@ private fun ShelfBlock( LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), // Extra ruimte zodat focusedScale (1.10) posters niet afknipt - contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 28.dp, bottom = 28.dp), + contentPadding = PaddingValues( + start = HomeChrome.EdgeInset, + end = HomeChrome.EdgeInset, + top = 28.dp, + bottom = 28.dp, + ), ) { itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item -> if (index == 0 && firstItemFocusRequester != null) { @@ -1637,7 +1688,7 @@ private fun SettingsScreen( LazyColumn( Modifier .fillMaxSize() - .padding(horizontal = 48.dp, vertical = 28.dp), + .padding(horizontal = HomeChrome.EdgeInset, vertical = 28.dp), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 40.dp), ) { @@ -1881,7 +1932,7 @@ private fun WatchlistScreen( Modifier .fillMaxSize() .background(Color(0xFF0B0B0F)) - .padding(horizontal = 40.dp, vertical = 20.dp), + .padding(horizontal = HomeChrome.EdgeInset, vertical = 20.dp), ) { Text("Watchlist", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(6.dp)) diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index 55d7a92..462b230 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -77,6 +77,8 @@ import nl.vonas.mediacluster.tv.playback.ensureAudioSelected import nl.vonas.mediacluster.tv.playback.listTracks import nl.vonas.mediacluster.tv.playback.selectTrack import nl.vonas.mediacluster.tv.ui.components.McButton +import nl.vonas.mediacluster.tv.ui.components.formatEndsAt +import nl.vonas.mediacluster.tv.ui.components.remainingMsFromDuration import nl.vonas.mediacluster.tv.ui.theme.McColors import android.util.Base64 import java.io.File @@ -587,6 +589,11 @@ fun PlayerScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { Text(formatTime(positionMs), color = Color(0xFFB3B3B3), fontSize = 13.sp) + val endsAtBar = remainingMsFromDuration(durationMs, positionMs) + ?.let { formatEndsAt(it) } + if (endsAtBar != null) { + Text(endsAtBar, color = Color(0xFFFFD54F), fontSize = 13.sp) + } Text(formatTime(durationMs), color = Color(0xFFB3B3B3), fontSize = 13.sp) } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt index f9b1c95..ddf0170 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt @@ -57,9 +57,10 @@ fun McButton( onFocused: (() -> Unit)? = null, onClick: () -> Unit, ) { - val bg = if (primary) Color.White else Color(0x33FFFFFF) - val bgFocus = if (primary) Color.White else Color.White - val fg = if (primary) Color.Black else Color.White + // Primary: gedempt tot focus; focused = helder wit (duidelijk verschil met buurknoppen) + val bg = if (primary) Color(0x66FFFFFF) else Color(0x33FFFFFF) + val bgFocus = Color.White + val fg = Color.White val fgFocus = Color.Black Surface( onClick = onClick, @@ -89,6 +90,19 @@ fun McButton( contentColor = fg, focusedContentColor = fgFocus, ), + border = ClickableSurfaceDefaults.border( + border = Border( + border = androidx.compose.foundation.BorderStroke( + width = if (primary) 1.dp else 0.dp, + color = if (primary) Color(0x88FFFFFF) else Color.Transparent, + ), + shape = RoundedCornerShape(4.dp), + ), + focusedBorder = Border( + border = androidx.compose.foundation.BorderStroke(2.dp, Color.White), + shape = RoundedCornerShape(4.dp), + ), + ), ) { Text( text = label.ifBlank { "—" }, diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Polish.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Polish.kt index e289702..692bb7f 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Polish.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Polish.kt @@ -85,13 +85,13 @@ fun SkeletonShelfRow( Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { SkeletonBox( Modifier - .padding(horizontal = 48.dp) + .padding(horizontal = HomeChrome.EdgeInset) .width(titleWidth) .height(18.dp), shape = RoundedCornerShape(3.dp), ) LazyRow( - contentPadding = PaddingValues(horizontal = 48.dp, vertical = 12.dp), + contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { items(posterCount) { @@ -108,7 +108,7 @@ fun SkeletonBillboard(height: Dp = 480.dp) { Column( Modifier .align(Alignment.BottomStart) - .padding(start = 48.dp, bottom = 36.dp), + .padding(start = HomeChrome.EdgeInset, bottom = 36.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { SkeletonBox(Modifier.width(48.dp).height(12.dp)) @@ -147,7 +147,7 @@ fun EmptyState( Column( modifier .fillMaxWidth() - .padding(horizontal = 48.dp, vertical = 40.dp), + .padding(horizontal = HomeChrome.EdgeInset, vertical = 40.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp), ) { diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TimeFormat.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TimeFormat.kt new file mode 100644 index 0000000..ca6d5bc --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TimeFormat.kt @@ -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 } +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt index 98280fe..43d2696 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt @@ -8,49 +8,63 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Surface import androidx.tv.material3.Text +import coil.compose.AsyncImage +import coil.request.ImageRequest +import kotlinx.coroutines.delay import nl.vonas.mediacluster.tv.ui.theme.McColors +import androidx.compose.foundation.BorderStroke +import androidx.compose.ui.layout.ContentScale /** * Berekende hoogte van de Home overlay-topnav (geen subtabs). * - * TopNavBar overlay-row: - * - vertical padding: 14.dp + 14.dp - * - tab-inhoud: 8.dp + ~21.dp tekst + 4.dp + 3.dp underline + 8.dp ≈ 44.dp - * - totaal ≈ 72.dp; TV Surface min. interactive ≈ 48.dp → veilig 76.dp + * TopNavBar overlay-row (compact, zonder underline): + * - vertical padding: 10.dp + 10.dp + * - tab-tekst ~21.dp → veilig 56.dp */ object HomeChrome { - val TopNavHeight = 76.dp + val TopNavHeight = 56.dp /** Lucht tussen onderkant menu en badge. */ val BadgeGap = 12.dp - val BadgeTop = TopNavHeight + BadgeGap // 88.dp + val BadgeTop = TopNavHeight + BadgeGap + /** Horizontale schermmarge (links = rechts). */ + val EdgeInset = 22.dp } enum class MainTab(val label: String) { @@ -87,10 +101,17 @@ fun TopNavBar( /** Omlaag vanuit menu → Home-content (Afspelen / planken). */ contentDownFocus: FocusRequester? = null, profileAvatarEmoji: String? = null, + profileAvatarUrl: String? = null, + profileShowAvatar: Boolean = true, + profileAuthHeader: String? = null, profileLabel: String? = null, onProfileClick: (() -> Unit)? = null, - /** Tandwiel links van het profiel-icoon (Settings-tab). */ + /** Zoek-icoon (rechts, naast tandwiel). */ + onSearchClick: (() -> Unit)? = null, + /** Tandwiel links van klok/profiel (Settings-tab). */ onSettingsClick: (() -> Unit)? = null, + /** Verhoog om focus op de actieve tab te zetten (niet op Home als eerste child). */ + focusSelectedToken: Int = 0, ) { val alpha = when { // Overlay: transparantie zit in de gradient, niet in ekstra fade @@ -98,7 +119,7 @@ fun TopNavBar( compact -> 0.42f else -> 1f } - val vPad = if (compact && !overlay) 8.dp else 14.dp + val vPad = if (compact && !overlay) 6.dp else 10.dp val downLink = Modifier.then( if (contentDownFocus != null) { Modifier.focusProperties { down = contentDownFocus } @@ -106,8 +127,28 @@ fun TopNavBar( Modifier }, ) - // Semi-transparant of vast: home overlay vs vaste tabs - Column( + val textTabs = remember(visibleTabs) { + visibleTabs.filter { it != MainTab.Search && it != MainTab.Settings } + } + val tabFocus = remember(textTabs) { + textTabs.associateWith { FocusRequester() } + } + val searchFocus = remember { FocusRequester() } + val settingsFocus = remember { FocusRequester() } + + LaunchedEffect(focusSelectedToken, selected) { + if (focusSelectedToken <= 0) return@LaunchedEffect + delay(50) + when { + selected == MainTab.Search && onSearchClick != null -> + runCatching { searchFocus.requestFocus() } + selected == MainTab.Settings && onSettingsClick != null -> + runCatching { settingsFocus.requestFocus() } + else -> runCatching { tabFocus[selected]?.requestFocus() } + } + } + + Column( modifier .fillMaxWidth() .then( @@ -128,7 +169,7 @@ fun TopNavBar( Row( Modifier .fillMaxWidth() - .padding(horizontal = 40.dp, vertical = vPad), + .padding(horizontal = HomeChrome.EdgeInset, vertical = vPad), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -137,33 +178,42 @@ fun TopNavBar( color = McColors.Brand, fontSize = if (compact && !overlay) 22.sp else 28.sp, fontWeight = FontWeight.Black, - modifier = Modifier.padding(end = 16.dp), + modifier = Modifier + .height(if (compact && !overlay) 32.dp else 36.dp) + .padding(end = 12.dp) + .wrapContentHeight(Alignment.CenterVertically), ) - visibleTabs.forEach { tab -> + textTabs.forEach { tab -> val active = selected == tab val color by animateColorAsState( when { - active -> Color.White + active -> McColors.Brand overlay -> Color(0xFFE8E8E8) else -> Color(0xFF808080) }, label = "tabColor", ) + val fr = tabFocus[tab] Surface( onClick = { onSelect(tab) }, - modifier = downLink, + modifier = downLink + .height(if (compact && !overlay) 32.dp else 36.dp) + .then(if (fr != null) Modifier.focusRequester(fr) else Modifier), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), colors = ClickableSurfaceDefaults.colors( - containerColor = if (active) Color(0x33FFFFFF) else Color.Transparent, - focusedContainerColor = Color(0x44FFFFFF), + containerColor = Color.Transparent, + focusedContainerColor = Color(0x33FFFFFF), contentColor = color, - focusedContentColor = Color.White, + // Actieve tab blijft rood, ook met focus + focusedContentColor = if (active) McColors.Brand else Color.White, ), ) { - Column( - Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, + Box( + Modifier + .fillMaxHeight() + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center, ) { Text( tab.label, @@ -171,69 +221,123 @@ fun TopNavBar( fontSize = 16.sp, fontWeight = if (active) FontWeight.Bold else FontWeight.Medium, ) - Spacer(Modifier.height(4.dp)) - Box( - Modifier - .width(if (active) 28.dp else 0.dp) - .height(3.dp) - .clip(RoundedCornerShape(2.dp)) - .background(if (active) McColors.Brand else Color.Transparent), + } + } + } + if (onSearchClick != null) { + val searchActive = selected == MainTab.Search + Surface( + onClick = onSearchClick, + modifier = downLink + .height(if (compact && !overlay) 32.dp else 36.dp) + .focusRequester(searchFocus), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color(0x33FFFFFF), + contentColor = if (searchActive) McColors.Brand else Color.White, + focusedContentColor = if (searchActive) McColors.Brand else Color.White, + ), + ) { + Box( + Modifier + .fillMaxHeight() + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Zoeken", + modifier = Modifier.size(16.dp), + tint = Color.Unspecified, ) } } } - Spacer(Modifier.weight(1f)) if (onSettingsClick != null) { val settingsActive = selected == MainTab.Settings Surface( onClick = onSettingsClick, - modifier = downLink, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(50)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), + modifier = downLink + .height(if (compact && !overlay) 32.dp else 36.dp) + .focusRequester(settingsFocus), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), colors = ClickableSurfaceDefaults.colors( - containerColor = if (settingsActive) Color(0x33FFFFFF) else Color.Transparent, - focusedContainerColor = Color(0x55FFFFFF), - contentColor = Color.White, - focusedContentColor = Color.White, + containerColor = Color.Transparent, + focusedContainerColor = Color(0x33FFFFFF), + contentColor = if (settingsActive) McColors.Brand else Color.White, + focusedContentColor = if (settingsActive) McColors.Brand else Color.White, ), ) { Box( - Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + Modifier + .fillMaxHeight() + .padding(horizontal = 8.dp), contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Filled.Settings, contentDescription = "Instellingen", - modifier = Modifier.size(22.dp), - tint = Color.White, + modifier = Modifier.size(16.dp), + tint = Color.Unspecified, ) } } } - if (onProfileClick != null && profileAvatarEmoji != null) { + Spacer(Modifier.weight(1f)) + if (onProfileClick != null && (!profileLabel.isNullOrBlank() || profileAvatarEmoji != null)) { Surface( onClick = onProfileClick, modifier = downLink, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(50)), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), colors = ClickableSurfaceDefaults.colors( - containerColor = Color(0x33FFFFFF), - focusedContainerColor = Color(0x55FFFFFF), + containerColor = Color.Transparent, + focusedContainerColor = Color(0x22FFFFFF), contentColor = Color.White, focusedContentColor = Color.White, ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(1.dp, Color.White), + shape = RoundedCornerShape(6.dp), + ), + ), ) { Row( - Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + Modifier.padding(horizontal = 6.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - Text(profileAvatarEmoji, fontSize = 20.sp) + if (profileShowAvatar) { + if (!profileAvatarUrl.isNullOrBlank()) { + val ctx = LocalContext.current + AsyncImage( + model = ImageRequest.Builder(ctx) + .data(profileAvatarUrl) + .apply { + if (!profileAuthHeader.isNullOrBlank()) { + addHeader("Authorization", profileAuthHeader) + } + } + .build(), + contentDescription = null, + modifier = Modifier + .size(22.dp) + .clip(RoundedCornerShape(50)), + contentScale = ContentScale.Crop, + ) + } else if (profileAvatarEmoji != null) { + Text(profileAvatarEmoji, fontSize = 15.sp) + } + } if (!profileLabel.isNullOrBlank()) { Text( profileLabel, color = Color.White, - fontSize = 14.sp, + fontSize = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1, ) @@ -241,11 +345,19 @@ fun TopNavBar( } } } + val clockTick = rememberClockTickMs() + Text( + formatClock(clockTick), + color = Color.White, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(start = 6.dp), + ) } if (subItems.isNotEmpty()) { LazyRow( - contentPadding = PaddingValues(horizontal = 40.dp, vertical = 4.dp), + contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), ) { diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt index 2c566a1..9e26662 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Surface @@ -48,7 +49,9 @@ import kotlinx.coroutines.launch import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.SessionStore import nl.vonas.mediacluster.tv.data.ViewerProfile +import nl.vonas.mediacluster.tv.ui.components.HomeChrome import nl.vonas.mediacluster.tv.ui.theme.McColors +import androidx.compose.foundation.BorderStroke private enum class ProfilesMode { Pick, Pin, Create, Edit, DeleteConfirm } @@ -73,8 +76,10 @@ fun ProfilesScreen( var createKids by remember { mutableStateOf(false) } var createPin by remember { mutableStateOf("") } var clearPin by remember { mutableStateOf(false) } + var createShowAvatar by remember { mutableStateOf(true) } var editingId by remember { mutableStateOf(null) } val firstFocus = remember { FocusRequester() } + val avatarFocus = remember { FocusRequester() } fun openEdit(profile: ViewerProfile) { editingId = profile.id @@ -82,6 +87,7 @@ fun ProfilesScreen( createName = profile.name createAvatar = profile.avatarId createKids = profile.isKids + createShowAvatar = profile.showAvatar createPin = "" clearPin = false error = null @@ -116,12 +122,25 @@ fun ProfilesScreen( runCatching { firstFocus.requestFocus() } } + LaunchedEffect(mode) { + if (mode == ProfilesMode.Create || mode == ProfilesMode.Edit) { + kotlinx.coroutines.delay(80) + runCatching { avatarFocus.requestFocus() } + } + } + fun select(profile: ViewerProfile, pinValue: String? = null) { scope.launch { error = null runCatching { api.selectProfile(profile.id, pinValue) } .onSuccess { selected -> - store.setProfile(selected.id, selected.name, selected.avatarId) + store.setProfile( + selected.id, + selected.name, + selected.avatarId, + selected.showAvatar, + selected.avatarUrl, + ) onProfileSelected() } .onFailure { @@ -148,7 +167,7 @@ fun ProfilesScreen( Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(horizontal = 48.dp, vertical = 36.dp), + .padding(horizontal = HomeChrome.EdgeInset, vertical = 36.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( @@ -276,6 +295,43 @@ fun ProfilesScreen( fontWeight = FontWeight.SemiBold, ) Spacer(Modifier.height(12.dp)) + Text("Kies een avatar", color = Color(0xFFCCCCCC), fontSize = 14.sp) + Spacer(Modifier.height(8.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + items(PROFILE_AVATARS) { av -> + val selected = createAvatar == av.id + Surface( + onClick = { createAvatar = av.id }, + modifier = if (selected) Modifier.focusRequester(avatarFocus) else Modifier, + shape = ClickableSurfaceDefaults.shape(shape = CircleShape), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f), + colors = ClickableSurfaceDefaults.colors( + containerColor = if (selected) McColors.Brand else Color(0xFF2A2A32), + focusedContainerColor = McColors.Brand, + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, Color.White), + shape = CircleShape, + ), + ), + ) { + Box( + Modifier.size(56.dp), + contentAlignment = Alignment.Center, + ) { + Text(av.emoji, fontSize = 26.sp) + } + } + } + } + Spacer(Modifier.height(12.dp)) + ActionChip( + if (createShowAvatar) "Profielfoto: aan" else "Profielfoto: uit", + ) { + createShowAvatar = !createShowAvatar + } + Spacer(Modifier.height(12.dp)) OutlinedTextField( value = createName, onValueChange = { createName = it.take(32) }, @@ -291,29 +347,6 @@ fun ProfilesScreen( cursorColor = McColors.Brand, ), ) - Spacer(Modifier.height(12.dp)) - Text("Kies een avatar", color = Color(0xFFCCCCCC), fontSize = 14.sp) - Spacer(Modifier.height(8.dp)) - LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - items(PROFILE_AVATARS) { av -> - Surface( - onClick = { createAvatar = av.id }, - shape = ClickableSurfaceDefaults.shape(shape = CircleShape), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f), - colors = ClickableSurfaceDefaults.colors( - containerColor = if (createAvatar == av.id) McColors.Brand else Color(0xFF2A2A32), - focusedContainerColor = McColors.Brand, - ), - ) { - Box( - Modifier.size(56.dp), - contentAlignment = Alignment.Center, - ) { - Text(av.emoji, fontSize = 26.sp) - } - } - } - } if (canManage && (!isEdit || pending?.isOwner != true)) { Spacer(Modifier.height(14.dp)) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { @@ -344,6 +377,7 @@ fun ProfilesScreen( profileId = editId, name = createName.trim(), avatarId = createAvatar, + showAvatar = createShowAvatar, isKids = if (canManage && pending?.isOwner != true) createKids else null, pin = createPin.ifBlank { null }, clearPin = clearPin, @@ -359,7 +393,13 @@ fun ProfilesScreen( }.onSuccess { saved -> val localId = store.profileId.first() if (localId == saved.id || editId == localId) { - store.setProfile(saved.id, saved.name, saved.avatarId) + store.setProfile( + saved.id, + saved.name, + saved.avatarId, + saved.showAvatar, + saved.avatarUrl, + ) } if (!switcherMode && !canManage) { onProfileSelected() @@ -466,6 +506,12 @@ private fun ProfileTile( containerColor = Color(0xFF1C1C24), focusedContainerColor = Color(0xFF2A2A36), ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, Color.White), + shape = RoundedCornerShape(14.dp), + ), + ), ) { Column( Modifier @@ -527,6 +573,12 @@ private fun AddProfileTile(onClick: () -> Unit) { containerColor = Color(0xFF16161E), focusedContainerColor = Color(0xFF242430), ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, Color.White), + shape = RoundedCornerShape(14.dp), + ), + ), ) { Column( Modifier @@ -583,6 +635,12 @@ private fun ActionChip(label: String, primary: Boolean = false, onClick: () -> U contentColor = Color.White, focusedContentColor = Color.White, ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, Color.White), + shape = RoundedCornerShape(8.dp), + ), + ), ) { Text( label, diff --git a/apps/ios/VMC/Data/ApiClient.swift b/apps/ios/VMC/Data/ApiClient.swift index 71d5124..75a3114 100644 --- a/apps/ios/VMC/Data/ApiClient.swift +++ b/apps/ios/VMC/Data/ApiClient.swift @@ -169,6 +169,8 @@ actor ApiClient { profileId: String, name: String? = nil, avatarId: String? = nil, + showAvatar: Bool? = nil, + clearAvatarImage: Bool = false, isKids: Bool? = nil, pin: String? = nil, clearPin: Bool = false @@ -176,6 +178,8 @@ actor ApiClient { var body: [String: Any] = [:] if let name { body["name"] = name } if let avatarId { body["avatarId"] = avatarId } + if let showAvatar { body["showAvatar"] = showAvatar } + if clearAvatarImage { body["clearAvatarImage"] = true } if let isKids { body["isKids"] = isKids } if clearPin { body["clearPin"] = true @@ -189,6 +193,24 @@ actor ApiClient { return wrap.profile } + func uploadProfileAvatar(profileId: String, imageBase64: String, mimeType: String = "image/jpeg") async throws -> ViewerProfile { + let encoded = profileId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? profileId + struct Wrap: Decodable { let profile: ViewerProfile } + let wrap: Wrap = try await post("/api/v1/client/profiles/\(encoded)/avatar", json: [ + "imageBase64": imageBase64, + "mimeType": mimeType, + ]) + return wrap.profile + } + + func absoluteUrl(_ path: String?) -> URL? { + guard let path, !path.isEmpty else { return nil } + if path.hasPrefix("http") { return URL(string: path) } + return URL(string: baseUrl + path) + } + + func bearerToken() -> String? { token } + func deleteProfile(profileId: String) async throws { let encoded = profileId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? profileId let _: Empty = try await requestEmpty("/api/v1/client/profiles/\(encoded)", method: "DELETE") diff --git a/apps/ios/VMC/Data/AppModel.swift b/apps/ios/VMC/Data/AppModel.swift index 46f7d44..ab0a350 100644 --- a/apps/ios/VMC/Data/AppModel.swift +++ b/apps/ios/VMC/Data/AppModel.swift @@ -82,7 +82,13 @@ final class AppModel: ObservableObject { let me = try await api.clientMe() iptvEnabled = me.iptvEnabled if let profile = me.profile { - store.setProfile(id: profile.id, name: profile.name, avatarId: profile.avatarId) + store.setProfile( + id: profile.id, + name: profile.name, + avatarId: profile.avatarId, + showAvatar: profile.showsAvatar, + avatarUrl: profile.avatarUrl ?? "" + ) favorites.bind(profileId: profile.id) await favorites.refresh(using: api) if let payload = try? await api.listProfiles() { @@ -117,7 +123,13 @@ final class AppModel: ObservableObject { } func didSelectProfile(_ profile: ViewerProfile) { - store.setProfile(id: profile.id, name: profile.name, avatarId: profile.avatarId) + store.setProfile( + id: profile.id, + name: profile.name, + avatarId: profile.avatarId, + showAvatar: profile.showsAvatar, + avatarUrl: profile.avatarUrl ?? "" + ) favorites.bind(profileId: profile.id) Task { await favorites.refresh(using: api) diff --git a/apps/ios/VMC/Data/Models.swift b/apps/ios/VMC/Data/Models.swift index 1715c9c..72b6bdd 100644 --- a/apps/ios/VMC/Data/Models.swift +++ b/apps/ios/VMC/Data/Models.swift @@ -72,6 +72,12 @@ struct ViewerProfile: Decodable, Identifiable, Hashable { let isOwner: Bool? let isKids: Bool? let hasPin: Bool? + let showAvatar: Bool? + let hasCustomAvatar: Bool? + let avatarUrl: String? + + var showsAvatar: Bool { showAvatar ?? true } + var hasCustomPhoto: Bool { hasCustomAvatar == true } } struct ProfilesPayload: Decodable { diff --git a/apps/ios/VMC/Data/SessionStore.swift b/apps/ios/VMC/Data/SessionStore.swift index c1b9533..42fe26a 100644 --- a/apps/ios/VMC/Data/SessionStore.swift +++ b/apps/ios/VMC/Data/SessionStore.swift @@ -9,6 +9,8 @@ final class SessionStore: ObservableObject { @Published var profileId: String? @Published var profileName: String? @Published var profileAvatarId: String? + @Published var profileShowAvatar: Bool = true + @Published var profileAvatarUrl: String? private let defaults = UserDefaults.standard private enum Key { @@ -17,6 +19,8 @@ final class SessionStore: ObservableObject { static let profileId = "vmc.profile_id" static let profileName = "vmc.profile_name" static let profileAvatar = "vmc.profile_avatar" + static let profileShowAvatar = "vmc.profile_show_avatar" + static let profileAvatarUrl = "vmc.profile_avatar_url" } init() { @@ -25,6 +29,9 @@ final class SessionStore: ObservableObject { profileId = defaults.string(forKey: Key.profileId) profileName = defaults.string(forKey: Key.profileName) profileAvatarId = defaults.string(forKey: Key.profileAvatar) + let showRaw = defaults.string(forKey: Key.profileShowAvatar) + profileShowAvatar = showRaw != "0" + profileAvatarUrl = defaults.string(forKey: Key.profileAvatarUrl) } var isAuthed: Bool { !(refreshToken ?? "").isEmpty } @@ -47,7 +54,13 @@ final class SessionStore: ObservableObject { defaults.set(apiBase, forKey: Key.api) } - func setProfile(id: String?, name: String?, avatarId: String?) { + func setProfile( + id: String?, + name: String?, + avatarId: String?, + showAvatar: Bool? = nil, + avatarUrl: String? = nil + ) { profileId = id profileName = name profileAvatarId = avatarId @@ -57,12 +70,29 @@ final class SessionStore: ObservableObject { else { defaults.removeObject(forKey: Key.profileName) } if let avatarId, !avatarId.isEmpty { defaults.set(avatarId, forKey: Key.profileAvatar) } else { defaults.removeObject(forKey: Key.profileAvatar) } + if let showAvatar { + profileShowAvatar = showAvatar + defaults.set(showAvatar ? "1" : "0", forKey: Key.profileShowAvatar) + } + if let avatarUrl { + if avatarUrl.isEmpty { + profileAvatarUrl = nil + defaults.removeObject(forKey: Key.profileAvatarUrl) + } else { + profileAvatarUrl = avatarUrl + defaults.set(avatarUrl, forKey: Key.profileAvatarUrl) + } + } } else { defaults.removeObject(forKey: Key.profileId) defaults.removeObject(forKey: Key.profileName) defaults.removeObject(forKey: Key.profileAvatar) + defaults.removeObject(forKey: Key.profileShowAvatar) + defaults.removeObject(forKey: Key.profileAvatarUrl) profileName = nil profileAvatarId = nil + profileShowAvatar = true + profileAvatarUrl = nil } } } diff --git a/apps/ios/VMC/Info.plist b/apps/ios/VMC/Info.plist index 4cd50a4..f5970c1 100644 --- a/apps/ios/VMC/Info.plist +++ b/apps/ios/VMC/Info.plist @@ -49,6 +49,8 @@ _http._tcp _https._tcp + NSPhotoLibraryUsageDescription + VMC gebruikt je fotobibliotheek om een profielfoto te kiezen. UILaunchScreen UIColorName diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index ffac041..8b05eff 100644 --- a/apps/ios/VMC/UI/SettingsScreen.swift +++ b/apps/ios/VMC/UI/SettingsScreen.swift @@ -1,4 +1,6 @@ import SwiftUI +import PhotosUI +import UIKit struct SettingsScreen: View { @EnvironmentObject private var app: AppModel @@ -118,8 +120,14 @@ struct EditProfileScreen: View { @State private var name = "" @State private var avatarId = "fox" + @State private var showAvatar = true + @State private var hasCustomAvatar = false + @State private var remoteAvatarUrl: String? @State private var pin = "" @State private var clearPin = false + @State private var clearCustomAvatar = false + @State private var pendingImage: UIImage? + @State private var pickerItem: PhotosPickerItem? @State private var message: String? @State private var error: String? @State private var saving = false @@ -128,6 +136,33 @@ struct EditProfileScreen: View { var body: some View { Form { + Section("Profielfoto") { + HStack { + Spacer() + profilePhotoPreview + .frame(width: 96, height: 96) + .clipShape(Circle()) + Spacer() + } + Toggle("Profielfoto aan/uit", isOn: $showAvatar) + .tint(McColors.accent) + PhotosPicker(selection: $pickerItem, matching: .images) { + Label("Foto kiezen", systemImage: "photo") + } + if hasCustomAvatar || pendingImage != nil { + Button("Foto verwijderen", role: .destructive) { + pendingImage = nil + pickerItem = nil + clearCustomAvatar = true + hasCustomAvatar = false + } + } + } + .listRowBackground(McColors.bgElevated) + .onChange(of: pickerItem) { _, item in + Task { await loadPicked(item) } + } + Section("Naam") { TextField("Naam", text: $name) .foregroundStyle(McColors.text) @@ -190,9 +225,54 @@ struct EditProfileScreen: View { .background(McColors.bg.ignoresSafeArea()) .navigationTitle("Mijn profiel") .navigationBarTitleDisplayMode(.inline) - .task { - name = app.store.profileName ?? "" - avatarId = app.store.profileAvatarId ?? "fox" + .task { await load() } + } + + @ViewBuilder + private var profilePhotoPreview: some View { + if let pendingImage { + Image(uiImage: pendingImage) + .resizable() + .scaledToFill() + } else if hasCustomAvatar, let remoteAvatarUrl { + AuthenticatedAvatarImage(path: remoteAvatarUrl) + } else { + Text(ProfileAvatars.emoji(for: avatarId)) + .font(.system(size: 44)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(McColors.surface) + } + } + + private func load() async { + name = app.store.profileName ?? "" + avatarId = app.store.profileAvatarId ?? "fox" + showAvatar = app.store.profileShowAvatar + remoteAvatarUrl = app.store.profileAvatarUrl + hasCustomAvatar = !(app.store.profileAvatarUrl ?? "").isEmpty + if let profileId { + if let payload = try? await app.api.listProfiles(), + let mine = payload.profiles.first(where: { $0.id == profileId }) { + name = mine.name + avatarId = mine.avatarId ?? "fox" + showAvatar = mine.showsAvatar + hasCustomAvatar = mine.hasCustomPhoto + remoteAvatarUrl = mine.avatarUrl + } + } + } + + private func loadPicked(_ item: PhotosPickerItem?) async { + guard let item else { return } + do { + if let data = try await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + pendingImage = image.squared() + clearCustomAvatar = false + hasCustomAvatar = true + } + } catch { + self.error = error.localizedDescription } } @@ -202,14 +282,31 @@ struct EditProfileScreen: View { error = nil message = nil do { - let updated = try await app.api.updateProfile( + var updated = try await app.api.updateProfile( profileId: profileId, name: name.trimmingCharacters(in: .whitespacesAndNewlines), avatarId: avatarId, + showAvatar: showAvatar, + clearAvatarImage: clearCustomAvatar && pendingImage == nil, pin: clearPin ? nil : (pin.isEmpty ? nil : pin), clearPin: clearPin ) - app.store.setProfile(id: updated.id, name: updated.name, avatarId: updated.avatarId) + if let pendingImage, + let jpeg = pendingImage.jpegData(compressionQuality: 0.85) { + let b64 = jpeg.base64EncodedString() + updated = try await app.api.uploadProfileAvatar(profileId: updated.id, imageBase64: b64) + } + app.store.setProfile( + id: updated.id, + name: updated.name, + avatarId: updated.avatarId, + showAvatar: updated.showsAvatar, + avatarUrl: updated.avatarUrl ?? "" + ) + hasCustomAvatar = updated.hasCustomPhoto + remoteAvatarUrl = updated.avatarUrl + pendingImage = nil + clearCustomAvatar = false message = "Opgeslagen" pin = "" clearPin = false @@ -379,14 +476,66 @@ struct AdminEditProfileScreen: View { @Environment(\.dismiss) private var dismiss @State private var name = "" @State private var avatarId = "fox" + @State private var showAvatar = true + @State private var hasCustomAvatar = false + @State private var remoteAvatarUrl: String? @State private var isKids = false @State private var pin = "" @State private var clearPin = false + @State private var clearCustomAvatar = false + @State private var pendingImage: UIImage? + @State private var pickerItem: PhotosPickerItem? @State private var error: String? @State private var message: String? var body: some View { Form { + Section("Profielfoto") { + HStack { + Spacer() + Group { + if let pendingImage { + Image(uiImage: pendingImage).resizable().scaledToFill() + } else if hasCustomAvatar, let remoteAvatarUrl { + AuthenticatedAvatarImage(path: remoteAvatarUrl) + } else { + Text(ProfileAvatars.emoji(for: avatarId)) + .font(.system(size: 40)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(McColors.surface) + } + } + .frame(width: 88, height: 88) + .clipShape(Circle()) + Spacer() + } + Toggle("Profielfoto aan/uit", isOn: $showAvatar) + .tint(McColors.accent) + PhotosPicker(selection: $pickerItem, matching: .images) { + Label("Foto kiezen", systemImage: "photo") + } + if hasCustomAvatar || pendingImage != nil { + Button("Foto verwijderen", role: .destructive) { + pendingImage = nil + pickerItem = nil + clearCustomAvatar = true + hasCustomAvatar = false + } + } + } + .listRowBackground(McColors.bgElevated) + .onChange(of: pickerItem) { _, item in + Task { + guard let item else { return } + if let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + pendingImage = image.squared() + clearCustomAvatar = false + hasCustomAvatar = true + } + } + } + Section("Naam") { TextField("Naam", text: $name) } @@ -446,22 +595,44 @@ struct AdminEditProfileScreen: View { name = profile.name avatarId = profile.avatarId ?? "fox" isKids = profile.isKids == true + showAvatar = profile.showsAvatar + hasCustomAvatar = profile.hasCustomPhoto + remoteAvatarUrl = profile.avatarUrl } } private func save() async { do { - let updated = try await app.api.updateProfile( + var updated = try await app.api.updateProfile( profileId: profile.id, name: name.trimmingCharacters(in: .whitespacesAndNewlines), avatarId: avatarId, + showAvatar: showAvatar, + clearAvatarImage: clearCustomAvatar && pendingImage == nil, isKids: canManage && profile.isOwner != true ? isKids : nil, pin: clearPin ? nil : (pin.isEmpty ? nil : pin), clearPin: clearPin ) - if updated.id == app.store.profileId { - app.store.setProfile(id: updated.id, name: updated.name, avatarId: updated.avatarId) + if let pendingImage, + let jpeg = pendingImage.jpegData(compressionQuality: 0.85) { + updated = try await app.api.uploadProfileAvatar( + profileId: updated.id, + imageBase64: jpeg.base64EncodedString() + ) } + if updated.id == app.store.profileId { + app.store.setProfile( + id: updated.id, + name: updated.name, + avatarId: updated.avatarId, + showAvatar: updated.showsAvatar, + avatarUrl: updated.avatarUrl ?? "" + ) + } + hasCustomAvatar = updated.hasCustomPhoto + remoteAvatarUrl = updated.avatarUrl + pendingImage = nil + clearCustomAvatar = false message = "Opgeslagen" error = nil } catch { @@ -479,6 +650,61 @@ struct AdminEditProfileScreen: View { } } +struct AuthenticatedAvatarImage: View { + @EnvironmentObject private var app: AppModel + let path: String + @State private var image: UIImage? + + var body: some View { + Group { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(McColors.surface) + } + } + .task(id: path) { await load() } + } + + private func load() async { + guard let url = await app.api.absoluteUrl(path) else { return } + var req = URLRequest(url: url) + if let token = await app.api.bearerToken() { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + if let (data, _) = try? await URLSession.shared.data(for: req), + let ui = UIImage(data: data) { + image = ui + } + } +} + +private extension UIImage { + func squared() -> UIImage { + let side = min(size.width, size.height) + let origin = CGPoint(x: (size.width - side) / 2, y: (size.height - side) / 2) + let crop = CGRect(origin: origin, size: CGSize(width: side, height: side)) + guard let cg = cgImage?.cropping(to: CGRect( + x: crop.origin.x * scale, + y: crop.origin.y * scale, + width: crop.size.width * scale, + height: crop.size.height * scale + )) else { return self } + let squared = UIImage(cgImage: cg, scale: scale, orientation: imageOrientation) + let target: CGFloat = 512 + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: target, height: target), format: format) + return renderer.image { _ in + squared.draw(in: CGRect(x: 0, y: 0, width: target, height: target)) + } + } +} + struct ShelfPrefsScreen: View { @EnvironmentObject private var app: AppModel @State private var prefs: [ShelfPref] = [] diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index aee6a04..71e034f 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -117,6 +117,10 @@ model ViewerProfile { viewerUserId String @map("viewer_user_id") name String avatarId String @default("fox") @map("avatar_id") + /** Eigen geüploade foto (relatief pad onder data/profile-avatars). */ + avatarImage String? @map("avatar_image") + /** Toon avatar/foto in UI (uit = alleen naam). */ + showAvatar Boolean @default(true) @map("show_avatar") isOwner Boolean @default(false) @map("is_owner") isKids Boolean @default(false) @map("is_kids") pinHash String? @map("pin_hash") diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index 8e903a8..584dab9 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -562,6 +562,17 @@ async function main() { console.warn("Could not ensure viewer client tables:", err); } + try { + await prisma.$executeRawUnsafe( + `ALTER TABLE "viewer_profiles" ADD COLUMN IF NOT EXISTS "avatar_image" TEXT` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE "viewer_profiles" ADD COLUMN IF NOT EXISTS "show_avatar" BOOLEAN NOT NULL DEFAULT true` + ); + } catch (err) { + console.warn("Could not ensure viewer_profiles avatar columns:", err); + } + // Repair drifted episode.series_id so series.episodes and seasons.episodes stay aligned. try { await prisma.$executeRawUnsafe(` diff --git a/apps/master-api/src/viewer/profiles.ts b/apps/master-api/src/viewer/profiles.ts index f27b433..bfc2e71 100644 --- a/apps/master-api/src/viewer/profiles.ts +++ b/apps/master-api/src/viewer/profiles.ts @@ -1,6 +1,8 @@ import { prisma } from "../database/client"; import { hashPassword, verifyPassword } from "../security/crypto"; import { AppError } from "../security/errors"; +import fs from "node:fs"; +import path from "node:path"; /** Vaste avatar-set (client toont emoji/tekening per id). */ export const PROFILE_AVATARS = [ @@ -21,6 +23,7 @@ export type ProfileAvatarId = (typeof PROFILE_AVATARS)[number]["id"]; const MAX_PROFILES = 8; const PIN_ATTEMPT_WINDOW_MS = 15 * 60 * 1000; const PIN_MAX_ATTEMPTS = 5; +const MAX_AVATAR_BYTES = 2 * 1024 * 1024; /** In-memory PIN rate limit: profileId:deviceId → attempts */ const pinAttempts = new Map(); @@ -38,18 +41,35 @@ function normalizePin(pin: string | null | undefined): string | null { return digits; } +export function profileAvatarsDir(): string { + const root = process.env.DATA_DIR || path.join(process.cwd(), "data"); + const dir = path.join(root, "profile-avatars"); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function avatarFilePath(profileId: string, ext = "jpg"): string { + return path.join(profileAvatarsDir(), `${profileId}.${ext}`); +} + function publicProfile(row: { id: string; name: string; avatarId: string; + avatarImage?: string | null; + showAvatar?: boolean | null; isOwner: boolean; isKids: boolean; pinHash: string | null; }) { + const hasCustomAvatar = !!row.avatarImage; return { id: row.id, name: row.name, avatarId: row.avatarId, + showAvatar: row.showAvatar !== false, + hasCustomAvatar, + avatarUrl: hasCustomAvatar ? `/api/v1/client/profiles/${row.id}/avatar` : null, isOwner: row.isOwner, isKids: row.isKids, hasPin: !!row.pinHash, @@ -94,6 +114,7 @@ export async function ensureOwnerProfile( viewerUserId: viewerId, name: name.slice(0, 32), avatarId: "fox", + showAvatar: true, isOwner: true, isKids: false, }, @@ -120,6 +141,7 @@ export class ViewerProfileService { input: { name?: string; avatarId?: string; + showAvatar?: boolean; isKids?: boolean; pin?: string | null; } @@ -130,25 +152,21 @@ export class ViewerProfileService { throw new AppError("LIMIT", `Maximaal ${MAX_PROFILES} profielen`, 400); } - const name = (input.name || "").trim().slice(0, 32); - if (name.length < 1) throw new AppError("INVALID_REQUEST", "Naam verplicht", 400); - + const name = (input.name?.trim() || `Profiel ${count + 1}`).slice(0, 32); const avatarId = input.avatarId || "fox"; if (!isAvatarId(avatarId)) { throw new AppError("INVALID_REQUEST", "Ongeldige avatar", 400); } - const pin = normalizePin(input.pin); - const isKids = input.isKids === true; - try { const row = await prisma.viewerProfile.create({ data: { viewerUserId: viewerId, name, avatarId, + showAvatar: input.showAvatar !== false, isOwner: false, - isKids, + isKids: !!input.isKids, pinHash: pin ? await hashPassword(pin) : null, }, }); @@ -165,6 +183,8 @@ export class ViewerProfileService { input: { name?: string; avatarId?: string; + showAvatar?: boolean; + clearAvatarImage?: boolean; isKids?: boolean; pin?: string | null; clearPin?: boolean; @@ -198,6 +218,8 @@ export class ViewerProfileService { const data: { name?: string; avatarId?: string; + showAvatar?: boolean; + avatarImage?: string | null; isKids?: boolean; pinHash?: string | null; } = {}; @@ -213,8 +235,18 @@ export class ViewerProfileService { } data.avatarId = input.avatarId; } + if (input.showAvatar !== undefined) { + data.showAvatar = !!input.showAvatar; + } + if (input.clearAvatarImage === true) { + data.avatarImage = null; + try { + fs.unlinkSync(avatarFilePath(profileId)); + } catch { + /* ignore */ + } + } if (input.isKids !== undefined && isOwnerActing) { - // Owner-profiel mag geen kids worden if (target.isOwner && input.isKids) { throw new AppError("INVALID_REQUEST", "Hoofdprofiel kan geen kids-profiel zijn", 400); } @@ -238,6 +270,78 @@ export class ViewerProfileService { } } + async setAvatarImage( + viewerId: string, + activeProfileId: string | null, + profileId: string, + imageBase64: string, + mimeType?: string + ) { + const target = await prisma.viewerProfile.findFirst({ + where: { id: profileId, viewerUserId: viewerId }, + }); + if (!target) throw new AppError("NOT_FOUND", "Profiel niet gevonden", 404); + + const isSelf = activeProfileId === profileId; + const active = activeProfileId + ? await prisma.viewerProfile.findFirst({ + where: { id: activeProfileId, viewerUserId: viewerId }, + }) + : null; + if (!active?.isOwner && !isSelf) { + throw new AppError("FORBIDDEN", "Geen rechten om dit profiel te wijzigen", 403); + } + + const raw = imageBase64.replace(/^data:image\/\w+;base64,/, ""); + let buf: Buffer; + try { + buf = Buffer.from(raw, "base64"); + } catch { + throw new AppError("INVALID_REQUEST", "Ongeldige afbeelding", 400); + } + if (buf.length < 32 || buf.length > MAX_AVATAR_BYTES) { + throw new AppError("INVALID_REQUEST", "Foto moet tussen 1KB en 2MB zijn", 400); + } + const mime = (mimeType || "image/jpeg").toLowerCase(); + if (!mime.startsWith("image/")) { + throw new AppError("INVALID_REQUEST", "Alleen afbeeldingen toegestaan", 400); + } + const ext = mime.includes("png") ? "png" : mime.includes("webp") ? "webp" : "jpg"; + const fileName = `${profileId}.${ext}`; + const full = path.join(profileAvatarsDir(), fileName); + // Verwijder oude extensies + for (const old of ["jpg", "jpeg", "png", "webp"]) { + try { + fs.unlinkSync(path.join(profileAvatarsDir(), `${profileId}.${old}`)); + } catch { + /* ignore */ + } + } + fs.writeFileSync(full, buf); + const row = await prisma.viewerProfile.update({ + where: { id: profileId }, + data: { avatarImage: fileName, showAvatar: true }, + }); + return publicProfile(row); + } + + async readAvatarImage(viewerId: string, profileId: string): Promise<{ buf: Buffer; contentType: string }> { + const target = await prisma.viewerProfile.findFirst({ + where: { id: profileId, viewerUserId: viewerId }, + }); + if (!target?.avatarImage) { + throw new AppError("NOT_FOUND", "Geen profielfoto", 404); + } + const full = path.join(profileAvatarsDir(), target.avatarImage); + if (!fs.existsSync(full)) { + throw new AppError("NOT_FOUND", "Geen profielfoto", 404); + } + const ext = path.extname(target.avatarImage).toLowerCase(); + const contentType = + ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg"; + return { buf: fs.readFileSync(full), contentType }; + } + async remove(viewerId: string, activeProfileId: string | null, profileId: string) { await assertCanManage(viewerId, activeProfileId); @@ -256,6 +360,13 @@ export class ViewerProfileService { where: { activeProfileId: profileId }, data: { activeProfileId: null }, }); + for (const old of ["jpg", "jpeg", "png", "webp"]) { + try { + fs.unlinkSync(path.join(profileAvatarsDir(), `${profileId}.${old}`)); + } catch { + /* ignore */ + } + } await prisma.viewerProfile.delete({ where: { id: profileId } }); return { ok: true }; } @@ -277,19 +388,14 @@ export class ViewerProfileService { let bucket = pinAttempts.get(key); if (!bucket || bucket.resetAt < now) { bucket = { count: 0, resetAt: now + PIN_ATTEMPT_WINDOW_MS }; - pinAttempts.set(key, bucket); } if (bucket.count >= PIN_MAX_ATTEMPTS) { - throw new AppError( - "RATE_LIMITED", - "Te veel PIN-pogingen. Probeer later opnieuw.", - 429 - ); + throw new AppError("RATE_LIMIT", "Te veel PIN-pogingen, probeer later", 429); } - - const ok = pin ? await verifyPassword(profile.pinHash, String(pin).replace(/\D/g, "")) : false; + const ok = pin ? await verifyPassword(String(pin), profile.pinHash) : false; if (!ok) { bucket.count += 1; + pinAttempts.set(key, bucket); throw new AppError("FORBIDDEN", "Onjuiste PIN", 403); } pinAttempts.delete(key); @@ -297,9 +403,8 @@ export class ViewerProfileService { await prisma.viewerDevice.update({ where: { id: deviceId }, - data: { activeProfileId: profile.id }, + data: { activeProfileId: profileId, lastSeenAt: new Date() }, }); - return { profile: publicProfile(profile) }; } diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index ae042d3..c64d5c1 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -483,6 +483,8 @@ export function registerViewerRoutes( const body = (request.body || {}) as { name?: string; avatarId?: string; + showAvatar?: boolean; + clearAvatarImage?: boolean; isKids?: boolean; pin?: string | null; clearPin?: boolean; @@ -491,6 +493,39 @@ export function registerViewerRoutes( return { profile }; }); + app.post( + "/api/v1/client/profiles/:id/avatar", + { + config: { rateLimit: { max: 20, timeWindow: "1 minute" } }, + }, + async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const { id } = request.params as { id: string }; + const body = (request.body || {}) as { + imageBase64?: string; + mimeType?: string; + }; + if (!body.imageBase64) { + throw new AppError("INVALID_REQUEST", "imageBase64 verplicht", 400); + } + const profile = await profiles.setAvatarImage( + auth.viewerId, + auth.profileId, + id, + body.imageBase64, + body.mimeType + ); + return { profile }; + } + ); + + app.get("/api/v1/client/profiles/:id/avatar", async (request, reply) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const { id } = request.params as { id: string }; + const { buf, contentType } = await profiles.readAvatarImage(auth.viewerId, id); + return reply.type(contentType).send(buf); + }); + app.delete("/api/v1/client/profiles/:id", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { id } = request.params as { id: string }; diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index f38d09b..bd04cd2 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -43,6 +43,9 @@ export type AuthedViewer = { id: string; name: string; avatarId: string; + showAvatar: boolean; + hasCustomAvatar: boolean; + avatarUrl: string | null; isOwner: boolean; isKids: boolean; hasPin: boolean; @@ -612,11 +615,16 @@ export class ViewerService { email: device.viewer.email, name: device.viewer.name, profileId: profile?.id ?? null, - profile: profile + profile: profile ? { id: profile.id, name: profile.name, avatarId: profile.avatarId, + showAvatar: profile.showAvatar !== false, + hasCustomAvatar: !!profile.avatarImage, + avatarUrl: profile.avatarImage + ? `/api/v1/client/profiles/${profile.id}/avatar` + : null, isOwner: profile.isOwner, isKids: profile.isKids, hasPin: !!profile.pinHash,