Add ends-at times, compact red topnav, and custom profile avatars.
Show end times on detail/player, tighten TV chrome, and let profiles toggle or upload a cropped photo via API and mobile/iOS.
This commit is contained in:
parent
94c04e0381
commit
e4aba1950b
28 changed files with 1673 additions and 275 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ class SessionStore(private val context: Context) {
|
|||
private val profileIdKey = stringPreferencesKey("profile_id")
|
||||
private val profileNameKey = stringPreferencesKey("profile_name")
|
||||
private val profileAvatarKey = stringPreferencesKey("profile_avatar")
|
||||
private val profileShowAvatarKey = stringPreferencesKey("profile_show_avatar")
|
||||
private val profileAvatarUrlKey = stringPreferencesKey("profile_avatar_url")
|
||||
private val recentSearchKey = stringPreferencesKey("recent_searches")
|
||||
|
||||
val refreshToken: Flow<String?> = context.dataStore.data.map { it[tokenKey] }
|
||||
|
|
@ -26,6 +28,12 @@ class SessionStore(private val context: Context) {
|
|||
val profileId: Flow<String?> = context.dataStore.data.map { it[profileIdKey] }
|
||||
val profileName: Flow<String?> = context.dataStore.data.map { it[profileNameKey] }
|
||||
val profileAvatarId: Flow<String?> = context.dataStore.data.map { it[profileAvatarKey] }
|
||||
val profileShowAvatar: Flow<Boolean> = context.dataStore.data.map {
|
||||
it[profileShowAvatarKey] != "0"
|
||||
}
|
||||
val profileAvatarUrl: Flow<String?> = context.dataStore.data.map {
|
||||
it[profileAvatarUrlKey]?.takeIf { u -> u.isNotBlank() }
|
||||
}
|
||||
|
||||
val recentSearches: Flow<List<String>> = context.dataStore.data.map { prefs ->
|
||||
val raw = prefs[recentSearchKey].orEmpty()
|
||||
|
|
@ -48,23 +56,40 @@ class SessionStore(private val context: Context) {
|
|||
prefs.remove(profileIdKey)
|
||||
prefs.remove(profileNameKey)
|
||||
prefs.remove(profileAvatarKey)
|
||||
prefs.remove(profileShowAvatarKey)
|
||||
prefs.remove(profileAvatarUrlKey)
|
||||
} else {
|
||||
prefs[tokenKey] = token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setProfile(id: String?, name: String? = null, avatarId: String? = null) {
|
||||
suspend fun setProfile(
|
||||
id: String?,
|
||||
name: String? = null,
|
||||
avatarId: String? = null,
|
||||
showAvatar: Boolean? = null,
|
||||
avatarUrl: String? = null,
|
||||
) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (id.isNullOrBlank()) {
|
||||
prefs.remove(profileIdKey)
|
||||
prefs.remove(profileNameKey)
|
||||
prefs.remove(profileAvatarKey)
|
||||
prefs.remove(profileShowAvatarKey)
|
||||
prefs.remove(profileAvatarUrlKey)
|
||||
} else {
|
||||
prefs[profileIdKey] = id
|
||||
if (!name.isNullOrBlank()) prefs[profileNameKey] = name else prefs.remove(profileNameKey)
|
||||
if (!avatarId.isNullOrBlank()) prefs[profileAvatarKey] = avatarId
|
||||
else prefs.remove(profileAvatarKey)
|
||||
if (showAvatar != null) {
|
||||
prefs[profileShowAvatarKey] = if (showAvatar) "1" else "0"
|
||||
}
|
||||
if (avatarUrl != null) {
|
||||
if (avatarUrl.isBlank()) prefs.remove(profileAvatarUrlKey)
|
||||
else prefs[profileAvatarUrlKey] = avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ fun McNav(
|
|||
var approveError by remember { mutableStateOf<String?>(null) }
|
||||
var approveDoneName by remember { mutableStateOf<String?>(null) }
|
||||
val profileAvatarId by store.profileAvatarId.collectAsState(initial = null)
|
||||
val profileAvatarUrl by store.profileAvatarUrl.collectAsState(initial = null)
|
||||
val profileShowAvatar by store.profileShowAvatar.collectAsState(initial = true)
|
||||
val profileName by store.profileName.collectAsState(initial = null)
|
||||
// Blijft behouden als main uit compositie gaat (bijv. live player)
|
||||
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
|
||||
|
|
@ -133,7 +135,13 @@ fun McNav(
|
|||
if (authed) {
|
||||
val me = runCatching { api.clientMe() }.getOrNull()
|
||||
if (me?.profile != null) {
|
||||
store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId)
|
||||
store.setProfile(
|
||||
me.profile.id,
|
||||
me.profile.name,
|
||||
me.profile.avatarId,
|
||||
me.profile.showAvatar,
|
||||
me.profile.avatarUrl ?: "",
|
||||
)
|
||||
hasProfile = true
|
||||
canManageDevices = me.profile.isOwner
|
||||
} else {
|
||||
|
|
@ -257,6 +265,9 @@ fun McNav(
|
|||
tvFocusNonce = tvFocusNonce,
|
||||
onTvFocusContent = { tvFocusNonce++ },
|
||||
profileAvatarId = profileAvatarId,
|
||||
profileAvatarUrl = api.absoluteUrl(profileAvatarUrl),
|
||||
profileShowAvatar = profileShowAvatar,
|
||||
profileAuthHeader = api.bearerToken()?.let { "Bearer $it" },
|
||||
profileName = profileName,
|
||||
canManageDevices = canManageDevices,
|
||||
onOpenMovie = { nav.navigate("movie/$it") },
|
||||
|
|
@ -440,6 +451,9 @@ private fun MainHub(
|
|||
tvFocusNonce: Int,
|
||||
onTvFocusContent: () -> Unit,
|
||||
profileAvatarId: String?,
|
||||
profileAvatarUrl: String?,
|
||||
profileShowAvatar: Boolean,
|
||||
profileAuthHeader: String?,
|
||||
profileName: String?,
|
||||
canManageDevices: Boolean,
|
||||
onOpenMovie: (String) -> Unit,
|
||||
|
|
@ -636,6 +650,9 @@ private fun MainHub(
|
|||
MainTab.Settings -> SettingsScreen(
|
||||
api = api,
|
||||
profileAvatarId = profileAvatarId,
|
||||
profileAvatarUrl = api.absoluteUrl(profileAvatarUrl),
|
||||
profileShowAvatar = profileShowAvatar,
|
||||
profileAuthHeader = api.bearerToken()?.let { "Bearer $it" },
|
||||
profileName = profileName,
|
||||
canManageDevices = canManageDevices,
|
||||
onBack = {
|
||||
|
|
@ -1238,6 +1255,9 @@ private fun ShelfBlock(
|
|||
private fun SettingsScreen(
|
||||
api: ApiClient,
|
||||
profileAvatarId: String? = null,
|
||||
profileAvatarUrl: String? = null,
|
||||
profileShowAvatar: Boolean = true,
|
||||
profileAuthHeader: String? = null,
|
||||
profileName: String? = null,
|
||||
canManageDevices: Boolean = false,
|
||||
onBack: () -> Unit,
|
||||
|
|
@ -1330,8 +1350,24 @@ private fun SettingsScreen(
|
|||
.background(Color(0xFF2E2E3A)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (profileShowAvatar && !profileAvatarUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(profileAvatarUrl)
|
||||
.apply {
|
||||
if (!profileAuthHeader.isNullOrBlank()) {
|
||||
addHeader("Authorization", profileAuthHeader)
|
||||
}
|
||||
}
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
Text(avatarEmoji(profileAvatarId ?: "fox"), fontSize = 22.sp)
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
profileName?.takeIf { it.isNotBlank() } ?: "Profiel",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
package nl.vonas.mediacluster.tv.ui.profiles
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -14,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
|
|
@ -25,11 +35,13 @@ import androidx.compose.foundation.shape.CircleShape
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -39,23 +51,34 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.data.SessionStore
|
||||
import nl.vonas.mediacluster.tv.data.ViewerProfile
|
||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private enum class ProfilesMode { Pick, Pin, Create, Edit, DeleteConfirm }
|
||||
|
||||
|
|
@ -81,20 +104,65 @@ fun ProfilesScreen(
|
|||
var createKids by remember { mutableStateOf(false) }
|
||||
var createPin by remember { mutableStateOf("") }
|
||||
var clearPin by remember { mutableStateOf(false) }
|
||||
var createShowAvatar by remember { mutableStateOf(true) }
|
||||
var pendingAvatarBase64 by remember { mutableStateOf<String?>(null) }
|
||||
var pendingAvatarPreview by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var clearCustomAvatar by remember { mutableStateOf(false) }
|
||||
var cropSource by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var editingId by remember { mutableStateOf<String?>(null) }
|
||||
val context = LocalContext.current
|
||||
|
||||
fun resetAvatarDraft() {
|
||||
pendingAvatarBase64 = null
|
||||
pendingAvatarPreview = null
|
||||
clearCustomAvatar = false
|
||||
cropSource = null
|
||||
}
|
||||
|
||||
fun openEdit(profile: ViewerProfile) {
|
||||
editingId = profile.id
|
||||
createName = profile.name
|
||||
createAvatar = profile.avatarId
|
||||
createKids = profile.isKids
|
||||
createShowAvatar = profile.showAvatar
|
||||
createPin = ""
|
||||
clearPin = false
|
||||
resetAvatarDraft()
|
||||
pending = profile
|
||||
mode = ProfilesMode.Edit
|
||||
error = null
|
||||
}
|
||||
|
||||
fun persistLocalProfile(saved: ViewerProfile) {
|
||||
scope.launch {
|
||||
val localId = store.profileId.first()
|
||||
if (localId == saved.id || editingId == localId) {
|
||||
store.setProfile(
|
||||
saved.id,
|
||||
saved.name,
|
||||
saved.avatarId,
|
||||
saved.showAvatar,
|
||||
saved.avatarUrl ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val photoPicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickVisualMedia(),
|
||||
) { uri: Uri? ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
val bmp = withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||
BitmapFactory.decodeStream(stream)
|
||||
}
|
||||
}
|
||||
if (bmp != null) cropSource = bmp
|
||||
else error = "Foto laden mislukt"
|
||||
}
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
scope.launch {
|
||||
loading = true
|
||||
|
|
@ -129,7 +197,13 @@ fun ProfilesScreen(
|
|||
error = null
|
||||
runCatching { api.selectProfile(profile.id, pinValue) }
|
||||
.onSuccess { selected ->
|
||||
store.setProfile(selected.id, selected.name, selected.avatarId)
|
||||
store.setProfile(
|
||||
selected.id,
|
||||
selected.name,
|
||||
selected.avatarId,
|
||||
selected.showAvatar,
|
||||
selected.avatarUrl ?: "",
|
||||
)
|
||||
onProfileSelected()
|
||||
}
|
||||
.onFailure {
|
||||
|
|
@ -195,6 +269,8 @@ fun ProfilesScreen(
|
|||
createKids = false
|
||||
createPin = ""
|
||||
clearPin = false
|
||||
createShowAvatar = true
|
||||
resetAvatarDraft()
|
||||
mode = ProfilesMode.Create
|
||||
},
|
||||
)
|
||||
|
|
@ -217,12 +293,29 @@ fun ProfilesScreen(
|
|||
},
|
||||
)
|
||||
|
||||
ProfilesMode.Create, ProfilesMode.Edit -> ProfileFormContent(
|
||||
ProfilesMode.Create, ProfilesMode.Edit -> {
|
||||
ProfileFormContent(
|
||||
title = if (mode == ProfilesMode.Edit) "Profiel bewerken" else "Nieuw profiel",
|
||||
name = createName,
|
||||
onNameChange = { createName = it.take(32) },
|
||||
avatarId = createAvatar,
|
||||
onAvatarChange = { createAvatar = it },
|
||||
showAvatar = createShowAvatar,
|
||||
onShowAvatarToggle = { createShowAvatar = !createShowAvatar },
|
||||
hasCustomAvatar = pending?.hasCustomAvatar == true && !clearCustomAvatar,
|
||||
customAvatarUrl = api.absoluteUrl(pending?.avatarUrl),
|
||||
authHeader = api.bearerToken()?.let { "Bearer $it" },
|
||||
pendingPreview = pendingAvatarPreview,
|
||||
onPickPhoto = {
|
||||
photoPicker.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
||||
)
|
||||
},
|
||||
onClearCustomPhoto = {
|
||||
pendingAvatarBase64 = null
|
||||
pendingAvatarPreview = null
|
||||
clearCustomAvatar = true
|
||||
},
|
||||
isKids = createKids,
|
||||
onKidsToggle = { createKids = !createKids },
|
||||
showKidsToggle = canManage && (mode == ProfilesMode.Create || pending?.isOwner != true),
|
||||
|
|
@ -247,6 +340,7 @@ fun ProfilesScreen(
|
|||
mode = ProfilesMode.Pick
|
||||
editingId = null
|
||||
pending = null
|
||||
resetAvatarDraft()
|
||||
error = null
|
||||
}
|
||||
},
|
||||
|
|
@ -257,28 +351,40 @@ fun ProfilesScreen(
|
|||
error = null
|
||||
val editId = editingId
|
||||
runCatching {
|
||||
if (editId != null) {
|
||||
var saved = if (editId != null) {
|
||||
api.updateProfile(
|
||||
profileId = editId,
|
||||
name = createName.trim(),
|
||||
avatarId = createAvatar,
|
||||
showAvatar = createShowAvatar,
|
||||
clearAvatarImage = clearCustomAvatar && pendingAvatarBase64 == null,
|
||||
isKids = if (canManage && pending?.isOwner != true) createKids else null,
|
||||
pin = createPin.ifBlank { null },
|
||||
clearPin = clearPin,
|
||||
)
|
||||
} else {
|
||||
api.createProfile(
|
||||
var created = api.createProfile(
|
||||
name = createName.trim(),
|
||||
avatarId = createAvatar,
|
||||
isKids = createKids,
|
||||
pin = createPin.ifBlank { null },
|
||||
)
|
||||
if (!createShowAvatar) {
|
||||
created = api.updateProfile(
|
||||
profileId = created.id,
|
||||
showAvatar = false,
|
||||
)
|
||||
}
|
||||
created
|
||||
}
|
||||
val avatarB64 = pendingAvatarBase64
|
||||
if (avatarB64 != null) {
|
||||
saved = api.uploadProfileAvatar(saved.id, avatarB64)
|
||||
}
|
||||
saved
|
||||
}.onSuccess { saved ->
|
||||
val localId = store.profileId.first()
|
||||
if (localId == saved.id || editId == localId) {
|
||||
store.setProfile(saved.id, saved.name, saved.avatarId)
|
||||
}
|
||||
persistLocalProfile(saved)
|
||||
resetAvatarDraft()
|
||||
if (!switcherMode && !canManage) {
|
||||
onProfileSelected()
|
||||
} else {
|
||||
|
|
@ -294,6 +400,23 @@ fun ProfilesScreen(
|
|||
}
|
||||
},
|
||||
)
|
||||
cropSource?.let { source ->
|
||||
SquareCropDialog(
|
||||
bitmap = source,
|
||||
onDismiss = { cropSource = null },
|
||||
onConfirm = { cropped ->
|
||||
cropSource = null
|
||||
clearCustomAvatar = false
|
||||
pendingAvatarPreview = cropped
|
||||
scope.launch {
|
||||
pendingAvatarBase64 = withContext(Dispatchers.IO) {
|
||||
jpegBase64(cropped)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ProfilesMode.DeleteConfirm -> DeleteContent(
|
||||
name = pending?.name ?: "profiel",
|
||||
|
|
@ -577,6 +700,14 @@ private fun ProfileFormContent(
|
|||
onNameChange: (String) -> Unit,
|
||||
avatarId: String,
|
||||
onAvatarChange: (String) -> Unit,
|
||||
showAvatar: Boolean,
|
||||
onShowAvatarToggle: () -> Unit,
|
||||
hasCustomAvatar: Boolean,
|
||||
customAvatarUrl: String?,
|
||||
authHeader: String?,
|
||||
pendingPreview: Bitmap?,
|
||||
onPickPhoto: () -> Unit,
|
||||
onClearCustomPhoto: () -> Unit,
|
||||
isKids: Boolean,
|
||||
onKidsToggle: () -> Unit,
|
||||
showKidsToggle: Boolean,
|
||||
|
|
@ -591,6 +722,7 @@ private fun ProfileFormContent(
|
|||
onCancel: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -600,6 +732,56 @@ private fun ProfileFormContent(
|
|||
) {
|
||||
Text(title, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
Text("Profielfoto", color = Color(0xFFCCCCCC), fontSize = 14.sp, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.size(96.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF2A2A32)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when {
|
||||
pendingPreview != null -> {
|
||||
Image(
|
||||
bitmap = pendingPreview.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
hasCustomAvatar && !customAvatarUrl.isNullOrBlank() -> {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(ctx)
|
||||
.data(customAvatarUrl)
|
||||
.apply {
|
||||
if (!authHeader.isNullOrBlank()) {
|
||||
addHeader("Authorization", authHeader)
|
||||
}
|
||||
}
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
else -> Text(avatarEmoji(avatarId), fontSize = 42.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
McButton(
|
||||
if (showAvatar) "Profielfoto aan/uit: aan" else "Profielfoto aan/uit: uit",
|
||||
onClick = onShowAvatarToggle,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
McButton("Foto kiezen", onClick = onPickPhoto)
|
||||
if (hasCustomAvatar || pendingPreview != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
McButton("Foto verwijderen", onClick = onClearCustomPhoto)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
|
|
@ -681,6 +863,99 @@ private fun ProfileFormContent(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SquareCropDialog(
|
||||
bitmap: Bitmap,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Bitmap) -> Unit,
|
||||
) {
|
||||
val side = minOf(bitmap.width, bitmap.height).toFloat()
|
||||
val maxOffsetX = ((bitmap.width - side) / 2f).coerceAtLeast(0f)
|
||||
val maxOffsetY = ((bitmap.height - side) / 2f).coerceAtLeast(0f)
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||
val previewPx = with(density) { 280.dp.toPx() }
|
||||
val imgScale = previewPx / side
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = Color(0xFF1C1C24),
|
||||
title = {
|
||||
Text("Foto bijsnijden", color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
},
|
||||
text = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"Sleep om bij te snijden (vierkant)",
|
||||
color = Color(0xFFAAAAAA),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.size(280.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.Black)
|
||||
.pointerInput(bitmap) {
|
||||
detectDragGestures { change, drag ->
|
||||
change.consume()
|
||||
offsetX = (offsetX + drag.x / imgScale)
|
||||
.coerceIn(-maxOffsetX, maxOffsetX)
|
||||
offsetY = (offsetY + drag.y / imgScale)
|
||||
.coerceIn(-maxOffsetY, maxOffsetY)
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(
|
||||
width = with(density) { (bitmap.width * imgScale).toDp() },
|
||||
height = with(density) { (bitmap.height * imgScale).toDp() },
|
||||
)
|
||||
.offset {
|
||||
IntOffset(
|
||||
(offsetX * imgScale).roundToInt(),
|
||||
(offsetY * imgScale).roundToInt(),
|
||||
)
|
||||
},
|
||||
contentScale = ContentScale.FillBounds,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
McButton("Gebruik", primary = true, onClick = {
|
||||
onConfirm(cropSquare(bitmap, offsetX, offsetY))
|
||||
})
|
||||
},
|
||||
dismissButton = {
|
||||
McButton("Annuleer", onClick = onDismiss)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun cropSquare(bitmap: Bitmap, offsetX: Float, offsetY: Float): Bitmap {
|
||||
val side = minOf(bitmap.width, bitmap.height)
|
||||
val centerX = bitmap.width / 2f + offsetX
|
||||
val centerY = bitmap.height / 2f + offsetY
|
||||
val left = (centerX - side / 2f).roundToInt().coerceIn(0, bitmap.width - side)
|
||||
val top = (centerY - side / 2f).roundToInt().coerceIn(0, bitmap.height - side)
|
||||
val square = Bitmap.createBitmap(bitmap, left, top, side, side)
|
||||
val outSize = 512.coerceAtMost(side)
|
||||
return if (square.width == outSize) square
|
||||
else Bitmap.createScaledBitmap(square, outSize, outSize, true)
|
||||
}
|
||||
|
||||
private fun jpegBase64(bitmap: Bitmap): String {
|
||||
val baos = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, baos)
|
||||
return Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DeleteContent(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ class SessionStore(private val context: Context) {
|
|||
private val profileIdKey = stringPreferencesKey("profile_id")
|
||||
private val profileNameKey = stringPreferencesKey("profile_name")
|
||||
private val profileAvatarKey = stringPreferencesKey("profile_avatar")
|
||||
private val profileShowAvatarKey = stringPreferencesKey("profile_show_avatar")
|
||||
private val profileAvatarUrlKey = stringPreferencesKey("profile_avatar_url")
|
||||
private val recentSearchKey = stringPreferencesKey("recent_searches")
|
||||
private val homeCacheKey = stringPreferencesKey("home_cache_json")
|
||||
private val tvFavoritesKey = stringPreferencesKey("tv_favorites")
|
||||
|
|
@ -31,6 +33,12 @@ class SessionStore(private val context: Context) {
|
|||
val profileId: Flow<String?> = context.dataStore.data.map { it[profileIdKey] }
|
||||
val profileName: Flow<String?> = context.dataStore.data.map { it[profileNameKey] }
|
||||
val profileAvatarId: Flow<String?> = context.dataStore.data.map { it[profileAvatarKey] }
|
||||
val profileShowAvatar: Flow<Boolean> = context.dataStore.data.map {
|
||||
it[profileShowAvatarKey] != "0"
|
||||
}
|
||||
val profileAvatarUrl: Flow<String?> = context.dataStore.data.map {
|
||||
it[profileAvatarUrlKey]?.takeIf { u -> u.isNotBlank() }
|
||||
}
|
||||
|
||||
val recentSearches: Flow<List<String>> = context.dataStore.data.map { prefs ->
|
||||
val raw = prefs[recentSearchKey].orEmpty()
|
||||
|
|
@ -67,23 +75,40 @@ class SessionStore(private val context: Context) {
|
|||
prefs.remove(profileIdKey)
|
||||
prefs.remove(profileNameKey)
|
||||
prefs.remove(profileAvatarKey)
|
||||
prefs.remove(profileShowAvatarKey)
|
||||
prefs.remove(profileAvatarUrlKey)
|
||||
} else {
|
||||
prefs[tokenKey] = token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setProfile(id: String?, name: String? = null, avatarId: String? = null) {
|
||||
suspend fun setProfile(
|
||||
id: String?,
|
||||
name: String? = null,
|
||||
avatarId: String? = null,
|
||||
showAvatar: Boolean? = null,
|
||||
avatarUrl: String? = null,
|
||||
) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (id.isNullOrBlank()) {
|
||||
prefs.remove(profileIdKey)
|
||||
prefs.remove(profileNameKey)
|
||||
prefs.remove(profileAvatarKey)
|
||||
prefs.remove(profileShowAvatarKey)
|
||||
prefs.remove(profileAvatarUrlKey)
|
||||
} else {
|
||||
prefs[profileIdKey] = id
|
||||
if (!name.isNullOrBlank()) prefs[profileNameKey] = name else prefs.remove(profileNameKey)
|
||||
if (!avatarId.isNullOrBlank()) prefs[profileAvatarKey] = avatarId
|
||||
else prefs.remove(profileAvatarKey)
|
||||
if (showAvatar != null) {
|
||||
prefs[profileShowAvatarKey] = if (showAvatar) "1" else "0"
|
||||
}
|
||||
if (avatarUrl != null) {
|
||||
if (avatarUrl.isBlank()) prefs.remove(profileAvatarUrlKey)
|
||||
else prefs[profileAvatarUrlKey] = avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,12 +34,21 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
|
||||
import nl.vonas.mediacluster.tv.ui.components.formatEndsAt
|
||||
import nl.vonas.mediacluster.tv.ui.components.rememberClockTickMs
|
||||
import nl.vonas.mediacluster.tv.ui.components.remainingMsFromRuntimeMinutes
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
|
@ -245,9 +254,9 @@ private fun DetailActionButton(
|
|||
focusRequester: FocusRequester? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val bg = if (primary) Color.White else Color(0xE6181818)
|
||||
val bgFocus = if (primary) Color(0xFFE8E8E8) else Color.White
|
||||
val fg = if (primary) Color.Black else Color.White
|
||||
val bg = Color(0xE6181818)
|
||||
val bgFocus = Color.White
|
||||
val fg = Color.White
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
|
||||
|
|
@ -260,20 +269,16 @@ private fun DetailActionButton(
|
|||
contentColor = fg,
|
||||
focusedContentColor = Color.Black,
|
||||
),
|
||||
border = if (primary) {
|
||||
ClickableSurfaceDefaults.border()
|
||||
} else {
|
||||
ClickableSurfaceDefaults.border(
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
border = Border(
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x55FFFFFF)),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
),
|
||||
focusedBorder = Border(
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White),
|
||||
border = androidx.compose.foundation.BorderStroke(2.dp, Color.White),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
|
|
@ -286,6 +291,52 @@ private fun DetailActionButton(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ExpandableOverview(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
collapsedLines: Int = 4,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Surface(
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = modifier,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.01f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
focusedContainerColor = Color(0x22FFFFFF),
|
||||
contentColor = McColors.TextDim,
|
||||
focusedContentColor = Color.White,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0x66FFFFFF)),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
text,
|
||||
color = Color.Unspecified,
|
||||
fontSize = 15.sp,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else collapsedLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
lineHeight = 22.sp,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
if (expanded) "OK · inklappen" else "OK · alles lezen",
|
||||
color = Color(0xFFAAAAAA),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MovieDetailScreen(
|
||||
|
|
@ -364,6 +415,10 @@ fun MovieDetailScreen(
|
|||
}
|
||||
|
||||
val selected = versions.firstOrNull { it.id == selectedVersionId } ?: versions.firstOrNull()
|
||||
val nowTick = rememberClockTickMs()
|
||||
val endsAtLabel = remember(runtime, nowTick) {
|
||||
remainingMsFromRuntimeMinutes(runtime)?.let { formatEndsAt(it, nowTick) }
|
||||
}
|
||||
val metaParts = buildList {
|
||||
year?.let { add(it.toString()) }
|
||||
formatRuntime(runtime)?.let(::add)
|
||||
|
|
@ -379,7 +434,7 @@ fun MovieDetailScreen(
|
|||
Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(0.58f)
|
||||
.padding(start = 44.dp, end = 20.dp, top = 24.dp, bottom = 24.dp)
|
||||
.padding(start = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset, top = 24.dp, bottom = 24.dp)
|
||||
.verticalScroll(scroll),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
|
|
@ -387,6 +442,14 @@ fun MovieDetailScreen(
|
|||
Spacer(Modifier.height(4.dp))
|
||||
DetailLogoOrTitle(logoUrl, title)
|
||||
MetaChips(metaParts)
|
||||
if (endsAtLabel != null) {
|
||||
Text(
|
||||
endsAtLabel,
|
||||
color = Color(0xFF9A9AA8),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
if (versions.size > 1) {
|
||||
SelectedQualityLine(selected)
|
||||
}
|
||||
|
|
@ -415,14 +478,7 @@ fun MovieDetailScreen(
|
|||
}
|
||||
}
|
||||
if (overview.isNotBlank()) {
|
||||
Text(
|
||||
overview,
|
||||
color = McColors.TextDim,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
lineHeight = 21.sp,
|
||||
)
|
||||
ExpandableOverview(overview)
|
||||
}
|
||||
if (director.isNotEmpty()) {
|
||||
Text(
|
||||
|
|
@ -539,11 +595,38 @@ fun SeriesDetailScreen(
|
|||
var genres by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var trailerKey by remember { mutableStateOf<String?>(null) }
|
||||
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) }
|
||||
var selectedSeason by remember { mutableIntStateOf(0) }
|
||||
var selectedSeason by rememberSaveable(seriesId) { mutableStateOf<Int?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var onWatchlist by remember { mutableStateOf(false) }
|
||||
var episodeMenu by remember { mutableStateOf<EpisodeRow?>(null) }
|
||||
var menuReady by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val firstEpFocus = remember { FocusRequester() }
|
||||
val menuFocus = remember { FocusRequester() }
|
||||
|
||||
fun applyEpisodeCompleted(episodeId: String, completed: Boolean) {
|
||||
seasons = seasons.map { season ->
|
||||
val eps = season.optJSONArray("episodes") ?: return@map season
|
||||
val copy = JSONObject(season.toString())
|
||||
val newEps = JSONArray()
|
||||
for (i in 0 until eps.length()) {
|
||||
val e = JSONObject(eps.getJSONObject(i).toString())
|
||||
if (e.optString("id") == episodeId) {
|
||||
e.put("completed", completed)
|
||||
if (completed) {
|
||||
e.put("positionMs", 1)
|
||||
e.put("durationMs", 1)
|
||||
} else {
|
||||
e.put("positionMs", 0)
|
||||
e.put("durationMs", 0)
|
||||
}
|
||||
}
|
||||
newEps.put(e)
|
||||
}
|
||||
copy.put("episodes", newEps)
|
||||
copy
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(seriesId) {
|
||||
try {
|
||||
|
|
@ -565,14 +648,18 @@ fun SeriesDetailScreen(
|
|||
for (i in 0 until arr.length()) list += arr.getJSONObject(i)
|
||||
}
|
||||
seasons = list
|
||||
selectedSeason = list.firstOrNull()?.optInt("seasonNumber") ?: 0
|
||||
val current = selectedSeason
|
||||
if (current == null || list.none { it.optInt("seasonNumber") == current }) {
|
||||
selectedSeason = list.firstOrNull()?.optInt("seasonNumber")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
val episodes = remember(seasons, selectedSeason) {
|
||||
val season = seasons.firstOrNull { it.optInt("seasonNumber") == selectedSeason }
|
||||
val seasonNumber = selectedSeason ?: 0
|
||||
val episodes = remember(seasons, seasonNumber) {
|
||||
val season = seasons.firstOrNull { it.optInt("seasonNumber") == seasonNumber }
|
||||
val eps = season?.optJSONArray("episodes") ?: return@remember emptyList()
|
||||
buildList {
|
||||
for (i in 0 until eps.length()) {
|
||||
|
|
@ -598,10 +685,14 @@ fun SeriesDetailScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(episodes) {
|
||||
if (episodes.isNotEmpty()) {
|
||||
LaunchedEffect(episodeMenu) {
|
||||
if (episodeMenu != null) {
|
||||
menuReady = false
|
||||
delay(80)
|
||||
runCatching { firstEpFocus.requestFocus() }
|
||||
menuReady = true
|
||||
runCatching { menuFocus.requestFocus() }
|
||||
} else {
|
||||
menuReady = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -610,12 +701,22 @@ fun SeriesDetailScreen(
|
|||
genres.take(3).forEach(::add)
|
||||
rating?.let { add("★ $it") }
|
||||
}
|
||||
val playEpPreview = remember(episodes) {
|
||||
episodes.firstOrNull { !it.completed && it.positionMs > 30_000L }
|
||||
?: episodes.firstOrNull { !it.completed }
|
||||
?: episodes.firstOrNull()
|
||||
}
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
DetailBackdrop(backdrop)
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(start = 44.dp, end = 44.dp, top = 28.dp, bottom = 40.dp),
|
||||
contentPadding = PaddingValues(
|
||||
start = HomeChrome.EdgeInset,
|
||||
end = HomeChrome.EdgeInset,
|
||||
top = 28.dp,
|
||||
bottom = 40.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
|
|
@ -624,25 +725,15 @@ fun SeriesDetailScreen(
|
|||
DetailLogoOrTitle(logoUrl, title)
|
||||
MetaChips(metaParts)
|
||||
if (overview.isNotBlank()) {
|
||||
Text(
|
||||
ExpandableOverview(
|
||||
overview,
|
||||
color = McColors.TextDim,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
lineHeight = 22.sp,
|
||||
modifier = Modifier.fillMaxWidth(0.72f).padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
if (error != null) Text(error!!, color = McColors.Danger)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val playEp = remember(episodes) {
|
||||
episodes.firstOrNull { !it.completed && it.positionMs > 30_000L }
|
||||
?: episodes.firstOrNull { !it.completed }
|
||||
?: episodes.firstOrNull()
|
||||
}
|
||||
playEp?.let { ep ->
|
||||
playEpPreview?.let { ep ->
|
||||
val resume = !ep.completed && ep.positionMs > 30_000L
|
||||
DetailActionButton(
|
||||
if (resume) "Verder kijken" else "Afspelen",
|
||||
|
|
@ -675,7 +766,7 @@ fun SeriesDetailScreen(
|
|||
val n = s.optInt("seasonNumber")
|
||||
DetailActionButton(
|
||||
"Seizoen $n",
|
||||
primary = n == selectedSeason,
|
||||
primary = n == seasonNumber,
|
||||
) { selectedSeason = n }
|
||||
}
|
||||
}
|
||||
|
|
@ -683,7 +774,7 @@ fun SeriesDetailScreen(
|
|||
Spacer(Modifier.height(4.dp))
|
||||
Text("Afleveringen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
itemsIndexed(episodes) { index, ep ->
|
||||
itemsIndexed(episodes) { _, ep ->
|
||||
EpisodeCard(
|
||||
episode = ep,
|
||||
focusRequester = null,
|
||||
|
|
@ -695,9 +786,80 @@ fun SeriesDetailScreen(
|
|||
if (ep.completed) 0L else ep.positionMs,
|
||||
)
|
||||
},
|
||||
onLongClick = { episodeMenu = ep },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val menuEp = episodeMenu
|
||||
if (menuEp != null) {
|
||||
BackHandler { episodeMenu = null }
|
||||
Dialog(
|
||||
onDismissRequest = { episodeMenu = null },
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xCC000000)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.width(440.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFF1A1A1A))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
"S${menuEp.season}E${menuEp.number} · ${menuEp.title}",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
if (menuEp.completed) {
|
||||
McButton("Markeer als niet gekeken", focusRequester = menuFocus) {
|
||||
if (!menuReady) return@McButton
|
||||
scope.launch {
|
||||
runCatching {
|
||||
api.postProgress(
|
||||
mediaType = "EPISODE",
|
||||
episodeId = menuEp.id,
|
||||
seriesId = seriesId,
|
||||
positionMs = 0,
|
||||
durationMs = 0,
|
||||
completed = false,
|
||||
hiddenFromContinue = true,
|
||||
)
|
||||
applyEpisodeCompleted(menuEp.id, false)
|
||||
}.onFailure { error = it.message }
|
||||
episodeMenu = null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
McButton("Markeer als gezien", focusRequester = menuFocus) {
|
||||
if (!menuReady) return@McButton
|
||||
scope.launch {
|
||||
runCatching {
|
||||
api.markWatched(episodeId = menuEp.id)
|
||||
applyEpisodeCompleted(menuEp.id, true)
|
||||
}.onFailure { error = it.message }
|
||||
episodeMenu = null
|
||||
}
|
||||
}
|
||||
}
|
||||
McButton("Annuleren") { episodeMenu = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -728,11 +890,21 @@ private fun EpisodeCard(
|
|||
episode: EpisodeRow,
|
||||
focusRequester: FocusRequester?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val endsAt = if (focused) {
|
||||
remainingMsFromRuntimeMinutes(episode.runtime, episode.positionMs, episode.completed)
|
||||
?.let { formatEndsAt(it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
|
||||
|
|
@ -817,9 +989,14 @@ private fun EpisodeCard(
|
|||
lineHeight = 18.sp,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
formatRuntime(episode.runtime)?.let {
|
||||
Text(it, color = Color(0xFF888888), fontSize = 12.sp)
|
||||
}
|
||||
if (endsAt != null) {
|
||||
Text(endsAt, color = Color(0xFFFFD54F), fontSize = 12.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@ fun McNav() {
|
|||
var hasProfile by remember { mutableStateOf(false) }
|
||||
var isOwner by remember { mutableStateOf(false) }
|
||||
val profileAvatarId by store.profileAvatarId.collectAsState(initial = null)
|
||||
val profileShowAvatar by store.profileShowAvatar.collectAsState(initial = true)
|
||||
val profileAvatarUrl by store.profileAvatarUrl.collectAsState(initial = null)
|
||||
val profileName by store.profileName.collectAsState(initial = null)
|
||||
// Blijft behouden als main uit compositie gaat (bijv. live player)
|
||||
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
|
||||
|
|
@ -145,7 +147,13 @@ fun McNav() {
|
|||
val me = runCatching { api.clientMe() }.getOrNull()
|
||||
when {
|
||||
me?.profile != null -> {
|
||||
store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId)
|
||||
store.setProfile(
|
||||
me.profile.id,
|
||||
me.profile.name,
|
||||
me.profile.avatarId,
|
||||
me.profile.showAvatar,
|
||||
me.profile.avatarUrl,
|
||||
)
|
||||
hasProfile = true
|
||||
isOwner = me.profile.isOwner
|
||||
}
|
||||
|
|
@ -284,6 +292,8 @@ fun McNav() {
|
|||
tvFocusNonce = tvFocusNonce,
|
||||
onTvFocusContent = { tvFocusNonce++ },
|
||||
profileAvatarId = profileAvatarId,
|
||||
profileShowAvatar = profileShowAvatar,
|
||||
profileAvatarUrl = profileAvatarUrl,
|
||||
profileName = profileName,
|
||||
onOpenMovie = { nav.navigate("movie/$it") },
|
||||
onOpenSeries = { nav.navigate("series/$it") },
|
||||
|
|
@ -442,6 +452,8 @@ private fun MainHub(
|
|||
tvFocusNonce: Int,
|
||||
onTvFocusContent: () -> Unit,
|
||||
profileAvatarId: String?,
|
||||
profileShowAvatar: Boolean = true,
|
||||
profileAvatarUrl: String? = null,
|
||||
profileName: String?,
|
||||
onOpenMovie: (String) -> Unit,
|
||||
onOpenSeries: (String) -> Unit,
|
||||
|
|
@ -457,6 +469,8 @@ private fun MainHub(
|
|||
var navFocused by remember { mutableStateOf(false) }
|
||||
var exitConfirm by remember { mutableStateOf(false) }
|
||||
var homeChromeCompact by remember { mutableStateOf(false) }
|
||||
var topNavFocusToken by remember { mutableIntStateOf(0) }
|
||||
var coldStartNavFocus by rememberSaveable { mutableStateOf(true) }
|
||||
val navFocus = remember { FocusRequester() }
|
||||
val homeContentFocus = remember { FocusRequester() }
|
||||
val exitFocus = remember { FocusRequester() }
|
||||
|
|
@ -487,7 +501,9 @@ private fun MainHub(
|
|||
}
|
||||
|
||||
val visibleTabs = remember(iptvEnabled) {
|
||||
MainTab.entries.filter { it != MainTab.Settings && (it != MainTab.Tv || iptvEnabled) }
|
||||
MainTab.entries.filter {
|
||||
it != MainTab.Settings && it != MainTab.Search && (it != MainTab.Tv || iptvEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
val movieSubs = remember(shelfPrefs) {
|
||||
|
|
@ -532,9 +548,15 @@ private fun MainHub(
|
|||
fun focusTopNav() {
|
||||
navFocused = true
|
||||
exitConfirm = false
|
||||
hubScope.launch {
|
||||
delay(40)
|
||||
runCatching { navFocus.requestFocus() }
|
||||
topNavFocusToken++
|
||||
}
|
||||
|
||||
// App-start: focus op Home in het menu, niet op Afspelen in de hero
|
||||
LaunchedEffect(Unit) {
|
||||
if (coldStartNavFocus) {
|
||||
coldStartNavFocus = false
|
||||
delay(120)
|
||||
focusTopNav()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -554,7 +576,7 @@ private fun MainHub(
|
|||
focusTopNav()
|
||||
}
|
||||
}
|
||||
// Films/Series/…: Back in top-nav → Home (kort uitgeschakeld na tab-wissel tegen race)
|
||||
// Films/Series/…: Back in top-nav → Home
|
||||
var navBackToHomeArmed by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(tab, navFocused) {
|
||||
navBackToHomeArmed = false
|
||||
|
|
@ -577,13 +599,6 @@ private fun MainHub(
|
|||
focusTopNav()
|
||||
}
|
||||
|
||||
LaunchedEffect(navFocused) {
|
||||
if (navFocused) {
|
||||
delay(40)
|
||||
runCatching { navFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(tvFocusNonce) {
|
||||
if (tab == MainTab.Tv) {
|
||||
navFocused = false
|
||||
|
|
@ -617,13 +632,22 @@ private fun MainHub(
|
|||
compact = tab == MainTab.Home && homeChromeCompact,
|
||||
overlay = overlay,
|
||||
profileAvatarEmoji = avatarEmoji(profileAvatarId ?: "fox"),
|
||||
profileAvatarUrl = api.absoluteUrl(profileAvatarUrl),
|
||||
profileShowAvatar = profileShowAvatar,
|
||||
profileAuthHeader = api.bearerToken()?.let { "Bearer $it" },
|
||||
profileLabel = profileName,
|
||||
onProfileClick = onSwitchProfile,
|
||||
onSearchClick = {
|
||||
onTabChange(MainTab.Search)
|
||||
exitConfirm = false
|
||||
homeChromeCompact = false
|
||||
focusTopNav()
|
||||
},
|
||||
onSettingsClick = {
|
||||
onTabChange(MainTab.Settings)
|
||||
exitConfirm = false
|
||||
homeChromeCompact = false
|
||||
navFocused = true
|
||||
focusTopNav()
|
||||
},
|
||||
onSelect = {
|
||||
if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
|
||||
|
|
@ -664,6 +688,7 @@ private fun MainHub(
|
|||
contentDownFocus = if (tab == MainTab.Home) homeContentFocus else null,
|
||||
subItems = subItems,
|
||||
selectedSubId = selectedSubId,
|
||||
focusSelectedToken = topNavFocusToken,
|
||||
onSelectSub = { item ->
|
||||
if (item.id == "__search__") {
|
||||
onTabChange(MainTab.Search)
|
||||
|
|
@ -1034,15 +1059,19 @@ private fun HomeScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Bij elke verschijning van Home (ook na serie/film/player): focus op content, niet op top-nav.
|
||||
// Bij terugkeer van detail: geen jump naar Afspelen/top. Alleen content-focus als we gescrolld waren.
|
||||
LaunchedEffect(Unit) {
|
||||
delay(80)
|
||||
val scrolled = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 40
|
||||
if (scrolled) {
|
||||
onContentFocused()
|
||||
val deadline = System.currentTimeMillis() + 900
|
||||
while (!playFocusReady && System.currentTimeMillis() < deadline) {
|
||||
val deadline = System.currentTimeMillis() + 700
|
||||
while (!shelfFocusReady && System.currentTimeMillis() < deadline) {
|
||||
delay(40)
|
||||
}
|
||||
delay(40)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
runCatching { firstShelfFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
val spotlight = home?.spotlight.orEmpty().ifEmpty {
|
||||
|
|
@ -1186,7 +1215,7 @@ private fun HomeScreen(
|
|||
Spacer(Modifier.height(shelfStart))
|
||||
}
|
||||
if (error != null) {
|
||||
item { Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = 40.dp)) }
|
||||
item { Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset)) }
|
||||
}
|
||||
if (continueItems.isNotEmpty()) {
|
||||
val preview = continueItems.take(5)
|
||||
|
|
@ -1322,7 +1351,7 @@ private fun HomeScreen(
|
|||
if (!menuReady) return@McButton
|
||||
scope.launch {
|
||||
runCatching {
|
||||
if (menuItem.type == "movie") {
|
||||
if (menuItem.type.equals("movie", true)) {
|
||||
api.markWatched(movieId = menuItem.id)
|
||||
} else {
|
||||
api.markWatched(episodeId = menuItem.id)
|
||||
|
|
@ -1332,6 +1361,23 @@ private fun HomeScreen(
|
|||
continueMenu = null
|
||||
}
|
||||
}
|
||||
when {
|
||||
menuItem.type.equals("movie", true) -> {
|
||||
McButton("Ga naar film") {
|
||||
if (!menuReady) return@McButton
|
||||
continueMenu = null
|
||||
onOpenMovie(menuItem.id)
|
||||
}
|
||||
}
|
||||
!menuItem.seriesId.isNullOrBlank() -> {
|
||||
McButton("Ga naar serie") {
|
||||
if (!menuReady) return@McButton
|
||||
val sid = menuItem.seriesId!!
|
||||
continueMenu = null
|
||||
onOpenSeries(sid)
|
||||
}
|
||||
}
|
||||
}
|
||||
McButton("Annuleren") { continueMenu = null }
|
||||
}
|
||||
}
|
||||
|
|
@ -1402,7 +1448,7 @@ private fun SpotlightHero(
|
|||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = HomeChrome.BadgeTop)
|
||||
.padding(start = 48.dp, end = 20.dp, bottom = 36.dp),
|
||||
.padding(start = HomeChrome.EdgeInset, end = HomeChrome.EdgeInset, bottom = 36.dp),
|
||||
verticalArrangement = Arrangement.Top,
|
||||
) {
|
||||
Column(
|
||||
|
|
@ -1517,7 +1563,7 @@ private fun ShelfBlock(
|
|||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 48.dp),
|
||||
.padding(horizontal = HomeChrome.EdgeInset),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
|
|
@ -1553,7 +1599,12 @@ private fun ShelfBlock(
|
|||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
// Extra ruimte zodat focusedScale (1.10) posters niet afknipt
|
||||
contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 28.dp, bottom = 28.dp),
|
||||
contentPadding = PaddingValues(
|
||||
start = HomeChrome.EdgeInset,
|
||||
end = HomeChrome.EdgeInset,
|
||||
top = 28.dp,
|
||||
bottom = 28.dp,
|
||||
),
|
||||
) {
|
||||
itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item ->
|
||||
if (index == 0 && firstItemFocusRequester != null) {
|
||||
|
|
@ -1637,7 +1688,7 @@ private fun SettingsScreen(
|
|||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 48.dp, vertical = 28.dp),
|
||||
.padding(horizontal = HomeChrome.EdgeInset, vertical = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
contentPadding = PaddingValues(bottom = 40.dp),
|
||||
) {
|
||||
|
|
@ -1881,7 +1932,7 @@ private fun WatchlistScreen(
|
|||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF0B0B0F))
|
||||
.padding(horizontal = 40.dp, vertical = 20.dp),
|
||||
.padding(horizontal = HomeChrome.EdgeInset, vertical = 20.dp),
|
||||
) {
|
||||
Text("Watchlist", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { "—" },
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -8,49 +8,63 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.delay
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
||||
/**
|
||||
* Berekende hoogte van de Home overlay-topnav (geen subtabs).
|
||||
*
|
||||
* TopNavBar overlay-row:
|
||||
* - vertical padding: 14.dp + 14.dp
|
||||
* - tab-inhoud: 8.dp + ~21.dp tekst + 4.dp + 3.dp underline + 8.dp ≈ 44.dp
|
||||
* - totaal ≈ 72.dp; TV Surface min. interactive ≈ 48.dp → veilig 76.dp
|
||||
* TopNavBar overlay-row (compact, zonder underline):
|
||||
* - vertical padding: 10.dp + 10.dp
|
||||
* - tab-tekst ~21.dp → veilig 56.dp
|
||||
*/
|
||||
object HomeChrome {
|
||||
val TopNavHeight = 76.dp
|
||||
val TopNavHeight = 56.dp
|
||||
/** Lucht tussen onderkant menu en badge. */
|
||||
val BadgeGap = 12.dp
|
||||
val BadgeTop = TopNavHeight + BadgeGap // 88.dp
|
||||
val BadgeTop = TopNavHeight + BadgeGap
|
||||
/** Horizontale schermmarge (links = rechts). */
|
||||
val EdgeInset = 22.dp
|
||||
}
|
||||
|
||||
enum class MainTab(val label: String) {
|
||||
|
|
@ -87,10 +101,17 @@ fun TopNavBar(
|
|||
/** Omlaag vanuit menu → Home-content (Afspelen / planken). */
|
||||
contentDownFocus: FocusRequester? = null,
|
||||
profileAvatarEmoji: String? = null,
|
||||
profileAvatarUrl: String? = null,
|
||||
profileShowAvatar: Boolean = true,
|
||||
profileAuthHeader: String? = null,
|
||||
profileLabel: String? = null,
|
||||
onProfileClick: (() -> Unit)? = null,
|
||||
/** Tandwiel links van het profiel-icoon (Settings-tab). */
|
||||
/** Zoek-icoon (rechts, naast tandwiel). */
|
||||
onSearchClick: (() -> Unit)? = null,
|
||||
/** Tandwiel links van klok/profiel (Settings-tab). */
|
||||
onSettingsClick: (() -> Unit)? = null,
|
||||
/** Verhoog om focus op de actieve tab te zetten (niet op Home als eerste child). */
|
||||
focusSelectedToken: Int = 0,
|
||||
) {
|
||||
val alpha = when {
|
||||
// Overlay: transparantie zit in de gradient, niet in ekstra fade
|
||||
|
|
@ -98,7 +119,7 @@ fun TopNavBar(
|
|||
compact -> 0.42f
|
||||
else -> 1f
|
||||
}
|
||||
val vPad = if (compact && !overlay) 8.dp else 14.dp
|
||||
val vPad = if (compact && !overlay) 6.dp else 10.dp
|
||||
val downLink = Modifier.then(
|
||||
if (contentDownFocus != null) {
|
||||
Modifier.focusProperties { down = contentDownFocus }
|
||||
|
|
@ -106,7 +127,27 @@ fun TopNavBar(
|
|||
Modifier
|
||||
},
|
||||
)
|
||||
// Semi-transparant of vast: home overlay vs vaste tabs
|
||||
val textTabs = remember(visibleTabs) {
|
||||
visibleTabs.filter { it != MainTab.Search && it != MainTab.Settings }
|
||||
}
|
||||
val tabFocus = remember(textTabs) {
|
||||
textTabs.associateWith { FocusRequester() }
|
||||
}
|
||||
val searchFocus = remember { FocusRequester() }
|
||||
val settingsFocus = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(focusSelectedToken, selected) {
|
||||
if (focusSelectedToken <= 0) return@LaunchedEffect
|
||||
delay(50)
|
||||
when {
|
||||
selected == MainTab.Search && onSearchClick != null ->
|
||||
runCatching { searchFocus.requestFocus() }
|
||||
selected == MainTab.Settings && onSettingsClick != null ->
|
||||
runCatching { settingsFocus.requestFocus() }
|
||||
else -> runCatching { tabFocus[selected]?.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -128,7 +169,7 @@ fun TopNavBar(
|
|||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 40.dp, vertical = vPad),
|
||||
.padding(horizontal = HomeChrome.EdgeInset, vertical = vPad),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
|
|
@ -137,33 +178,42 @@ fun TopNavBar(
|
|||
color = McColors.Brand,
|
||||
fontSize = if (compact && !overlay) 22.sp else 28.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
modifier = Modifier
|
||||
.height(if (compact && !overlay) 32.dp else 36.dp)
|
||||
.padding(end = 12.dp)
|
||||
.wrapContentHeight(Alignment.CenterVertically),
|
||||
)
|
||||
visibleTabs.forEach { tab ->
|
||||
textTabs.forEach { tab ->
|
||||
val active = selected == tab
|
||||
val color by animateColorAsState(
|
||||
when {
|
||||
active -> Color.White
|
||||
active -> McColors.Brand
|
||||
overlay -> Color(0xFFE8E8E8)
|
||||
else -> Color(0xFF808080)
|
||||
},
|
||||
label = "tabColor",
|
||||
)
|
||||
val fr = tabFocus[tab]
|
||||
Surface(
|
||||
onClick = { onSelect(tab) },
|
||||
modifier = downLink,
|
||||
modifier = downLink
|
||||
.height(if (compact && !overlay) 32.dp else 36.dp)
|
||||
.then(if (fr != null) Modifier.focusRequester(fr) else Modifier),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (active) Color(0x33FFFFFF) else Color.Transparent,
|
||||
focusedContainerColor = Color(0x44FFFFFF),
|
||||
containerColor = Color.Transparent,
|
||||
focusedContainerColor = Color(0x33FFFFFF),
|
||||
contentColor = color,
|
||||
focusedContentColor = Color.White,
|
||||
// Actieve tab blijft rood, ook met focus
|
||||
focusedContentColor = if (active) McColors.Brand else Color.White,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
tab.label,
|
||||
|
|
@ -171,69 +221,123 @@ fun TopNavBar(
|
|||
fontSize = 16.sp,
|
||||
fontWeight = if (active) FontWeight.Bold else FontWeight.Medium,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onSearchClick != null) {
|
||||
val searchActive = selected == MainTab.Search
|
||||
Surface(
|
||||
onClick = onSearchClick,
|
||||
modifier = downLink
|
||||
.height(if (compact && !overlay) 32.dp else 36.dp)
|
||||
.focusRequester(searchFocus),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
focusedContainerColor = Color(0x33FFFFFF),
|
||||
contentColor = if (searchActive) McColors.Brand else Color.White,
|
||||
focusedContentColor = if (searchActive) McColors.Brand else Color.White,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(if (active) 28.dp else 0.dp)
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(if (active) McColors.Brand else Color.Transparent),
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = "Zoeken",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (onSettingsClick != null) {
|
||||
val settingsActive = selected == MainTab.Settings
|
||||
Surface(
|
||||
onClick = onSettingsClick,
|
||||
modifier = downLink,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(50)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
||||
modifier = downLink
|
||||
.height(if (compact && !overlay) 32.dp else 36.dp)
|
||||
.focusRequester(settingsFocus),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (settingsActive) Color(0x33FFFFFF) else Color.Transparent,
|
||||
focusedContainerColor = Color(0x55FFFFFF),
|
||||
contentColor = Color.White,
|
||||
focusedContentColor = Color.White,
|
||||
containerColor = Color.Transparent,
|
||||
focusedContainerColor = Color(0x33FFFFFF),
|
||||
contentColor = if (settingsActive) McColors.Brand else Color.White,
|
||||
focusedContentColor = if (settingsActive) McColors.Brand else Color.White,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings,
|
||||
contentDescription = "Instellingen",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onProfileClick != null && profileAvatarEmoji != null) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (onProfileClick != null && (!profileLabel.isNullOrBlank() || profileAvatarEmoji != null)) {
|
||||
Surface(
|
||||
onClick = onProfileClick,
|
||||
modifier = downLink,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(50)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0x33FFFFFF),
|
||||
focusedContainerColor = Color(0x55FFFFFF),
|
||||
containerColor = Color.Transparent,
|
||||
focusedContainerColor = Color(0x22FFFFFF),
|
||||
contentColor = Color.White,
|
||||
focusedContentColor = Color.White,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(1.dp, Color.White),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(profileAvatarEmoji, fontSize = 20.sp)
|
||||
if (profileShowAvatar) {
|
||||
if (!profileAvatarUrl.isNullOrBlank()) {
|
||||
val ctx = LocalContext.current
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(ctx)
|
||||
.data(profileAvatarUrl)
|
||||
.apply {
|
||||
if (!profileAuthHeader.isNullOrBlank()) {
|
||||
addHeader("Authorization", profileAuthHeader)
|
||||
}
|
||||
}
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(22.dp)
|
||||
.clip(RoundedCornerShape(50)),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else if (profileAvatarEmoji != null) {
|
||||
Text(profileAvatarEmoji, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
if (!profileLabel.isNullOrBlank()) {
|
||||
Text(
|
||||
profileLabel,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
|
|
@ -241,11 +345,19 @@ fun TopNavBar(
|
|||
}
|
||||
}
|
||||
}
|
||||
val clockTick = rememberClockTickMs()
|
||||
Text(
|
||||
formatClock(clockTick),
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (subItems.isNotEmpty()) {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 40.dp, vertical = 4.dp),
|
||||
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Surface
|
||||
|
|
@ -48,7 +49,9 @@ import kotlinx.coroutines.launch
|
|||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.data.SessionStore
|
||||
import nl.vonas.mediacluster.tv.data.ViewerProfile
|
||||
import nl.vonas.mediacluster.tv.ui.components.HomeChrome
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
|
||||
private enum class ProfilesMode { Pick, Pin, Create, Edit, DeleteConfirm }
|
||||
|
||||
|
|
@ -73,8 +76,10 @@ fun ProfilesScreen(
|
|||
var createKids by remember { mutableStateOf(false) }
|
||||
var createPin by remember { mutableStateOf("") }
|
||||
var clearPin by remember { mutableStateOf(false) }
|
||||
var createShowAvatar by remember { mutableStateOf(true) }
|
||||
var editingId by remember { mutableStateOf<String?>(null) }
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
val avatarFocus = remember { FocusRequester() }
|
||||
|
||||
fun openEdit(profile: ViewerProfile) {
|
||||
editingId = profile.id
|
||||
|
|
@ -82,6 +87,7 @@ fun ProfilesScreen(
|
|||
createName = profile.name
|
||||
createAvatar = profile.avatarId
|
||||
createKids = profile.isKids
|
||||
createShowAvatar = profile.showAvatar
|
||||
createPin = ""
|
||||
clearPin = false
|
||||
error = null
|
||||
|
|
@ -116,12 +122,25 @@ fun ProfilesScreen(
|
|||
runCatching { firstFocus.requestFocus() }
|
||||
}
|
||||
|
||||
LaunchedEffect(mode) {
|
||||
if (mode == ProfilesMode.Create || mode == ProfilesMode.Edit) {
|
||||
kotlinx.coroutines.delay(80)
|
||||
runCatching { avatarFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
fun select(profile: ViewerProfile, pinValue: String? = null) {
|
||||
scope.launch {
|
||||
error = null
|
||||
runCatching { api.selectProfile(profile.id, pinValue) }
|
||||
.onSuccess { selected ->
|
||||
store.setProfile(selected.id, selected.name, selected.avatarId)
|
||||
store.setProfile(
|
||||
selected.id,
|
||||
selected.name,
|
||||
selected.avatarId,
|
||||
selected.showAvatar,
|
||||
selected.avatarUrl,
|
||||
)
|
||||
onProfileSelected()
|
||||
}
|
||||
.onFailure {
|
||||
|
|
@ -148,7 +167,7 @@ fun ProfilesScreen(
|
|||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 48.dp, vertical = 36.dp),
|
||||
.padding(horizontal = HomeChrome.EdgeInset, vertical = 36.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
|
|
@ -276,6 +295,43 @@ fun ProfilesScreen(
|
|||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Kies een avatar", color = Color(0xFFCCCCCC), fontSize = 14.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(PROFILE_AVATARS) { av ->
|
||||
val selected = createAvatar == av.id
|
||||
Surface(
|
||||
onClick = { createAvatar = av.id },
|
||||
modifier = if (selected) Modifier.focusRequester(avatarFocus) else Modifier,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (selected) McColors.Brand else Color(0xFF2A2A32),
|
||||
focusedContainerColor = McColors.Brand,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.dp, Color.White),
|
||||
shape = CircleShape,
|
||||
),
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(56.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(av.emoji, fontSize = 26.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ActionChip(
|
||||
if (createShowAvatar) "Profielfoto: aan" else "Profielfoto: uit",
|
||||
) {
|
||||
createShowAvatar = !createShowAvatar
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = createName,
|
||||
onValueChange = { createName = it.take(32) },
|
||||
|
|
@ -291,29 +347,6 @@ fun ProfilesScreen(
|
|||
cursorColor = McColors.Brand,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Kies een avatar", color = Color(0xFFCCCCCC), fontSize = 14.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(PROFILE_AVATARS) { av ->
|
||||
Surface(
|
||||
onClick = { createAvatar = av.id },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (createAvatar == av.id) McColors.Brand else Color(0xFF2A2A32),
|
||||
focusedContainerColor = McColors.Brand,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(56.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(av.emoji, fontSize = 26.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (canManage && (!isEdit || pending?.isOwner != true)) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
|
|
@ -344,6 +377,7 @@ fun ProfilesScreen(
|
|||
profileId = editId,
|
||||
name = createName.trim(),
|
||||
avatarId = createAvatar,
|
||||
showAvatar = createShowAvatar,
|
||||
isKids = if (canManage && pending?.isOwner != true) createKids else null,
|
||||
pin = createPin.ifBlank { null },
|
||||
clearPin = clearPin,
|
||||
|
|
@ -359,7 +393,13 @@ fun ProfilesScreen(
|
|||
}.onSuccess { saved ->
|
||||
val localId = store.profileId.first()
|
||||
if (localId == saved.id || editId == localId) {
|
||||
store.setProfile(saved.id, saved.name, saved.avatarId)
|
||||
store.setProfile(
|
||||
saved.id,
|
||||
saved.name,
|
||||
saved.avatarId,
|
||||
saved.showAvatar,
|
||||
saved.avatarUrl,
|
||||
)
|
||||
}
|
||||
if (!switcherMode && !canManage) {
|
||||
onProfileSelected()
|
||||
|
|
@ -466,6 +506,12 @@ private fun ProfileTile(
|
|||
containerColor = Color(0xFF1C1C24),
|
||||
focusedContainerColor = Color(0xFF2A2A36),
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.dp, Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
|
|
@ -527,6 +573,12 @@ private fun AddProfileTile(onClick: () -> Unit) {
|
|||
containerColor = Color(0xFF16161E),
|
||||
focusedContainerColor = Color(0xFF242430),
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.dp, Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
|
|
@ -583,6 +635,12 @@ private fun ActionChip(label: String, primary: Boolean = false, onClick: () -> U
|
|||
contentColor = Color.White,
|
||||
focusedContentColor = Color.White,
|
||||
),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.dp, Color.White),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@
|
|||
<string>_http._tcp</string>
|
||||
<string>_https._tcp</string>
|
||||
</array>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>VMC gebruikt je fotobibliotheek om een profielfoto te kiezen.</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import SwiftUI
|
||||
import PhotosUI
|
||||
import UIKit
|
||||
|
||||
struct SettingsScreen: View {
|
||||
@EnvironmentObject private var app: AppModel
|
||||
|
|
@ -118,8 +120,14 @@ struct EditProfileScreen: View {
|
|||
|
||||
@State private var name = ""
|
||||
@State private var avatarId = "fox"
|
||||
@State private var showAvatar = true
|
||||
@State private var hasCustomAvatar = false
|
||||
@State private var remoteAvatarUrl: String?
|
||||
@State private var pin = ""
|
||||
@State private var clearPin = false
|
||||
@State private var clearCustomAvatar = false
|
||||
@State private var pendingImage: UIImage?
|
||||
@State private var pickerItem: PhotosPickerItem?
|
||||
@State private var message: String?
|
||||
@State private var error: String?
|
||||
@State private var saving = false
|
||||
|
|
@ -128,6 +136,33 @@ struct EditProfileScreen: View {
|
|||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Profielfoto") {
|
||||
HStack {
|
||||
Spacer()
|
||||
profilePhotoPreview
|
||||
.frame(width: 96, height: 96)
|
||||
.clipShape(Circle())
|
||||
Spacer()
|
||||
}
|
||||
Toggle("Profielfoto aan/uit", isOn: $showAvatar)
|
||||
.tint(McColors.accent)
|
||||
PhotosPicker(selection: $pickerItem, matching: .images) {
|
||||
Label("Foto kiezen", systemImage: "photo")
|
||||
}
|
||||
if hasCustomAvatar || pendingImage != nil {
|
||||
Button("Foto verwijderen", role: .destructive) {
|
||||
pendingImage = nil
|
||||
pickerItem = nil
|
||||
clearCustomAvatar = true
|
||||
hasCustomAvatar = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(McColors.bgElevated)
|
||||
.onChange(of: pickerItem) { _, item in
|
||||
Task { await loadPicked(item) }
|
||||
}
|
||||
|
||||
Section("Naam") {
|
||||
TextField("Naam", text: $name)
|
||||
.foregroundStyle(McColors.text)
|
||||
|
|
@ -190,9 +225,54 @@ struct EditProfileScreen: View {
|
|||
.background(McColors.bg.ignoresSafeArea())
|
||||
.navigationTitle("Mijn profiel")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var profilePhotoPreview: some View {
|
||||
if let pendingImage {
|
||||
Image(uiImage: pendingImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else if hasCustomAvatar, let remoteAvatarUrl {
|
||||
AuthenticatedAvatarImage(path: remoteAvatarUrl)
|
||||
} else {
|
||||
Text(ProfileAvatars.emoji(for: avatarId))
|
||||
.font(.system(size: 44))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(McColors.surface)
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
name = app.store.profileName ?? ""
|
||||
avatarId = app.store.profileAvatarId ?? "fox"
|
||||
showAvatar = app.store.profileShowAvatar
|
||||
remoteAvatarUrl = app.store.profileAvatarUrl
|
||||
hasCustomAvatar = !(app.store.profileAvatarUrl ?? "").isEmpty
|
||||
if let profileId {
|
||||
if let payload = try? await app.api.listProfiles(),
|
||||
let mine = payload.profiles.first(where: { $0.id == profileId }) {
|
||||
name = mine.name
|
||||
avatarId = mine.avatarId ?? "fox"
|
||||
showAvatar = mine.showsAvatar
|
||||
hasCustomAvatar = mine.hasCustomPhoto
|
||||
remoteAvatarUrl = mine.avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPicked(_ item: PhotosPickerItem?) async {
|
||||
guard let item else { return }
|
||||
do {
|
||||
if let data = try await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) {
|
||||
pendingImage = image.squared()
|
||||
clearCustomAvatar = false
|
||||
hasCustomAvatar = true
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,14 +282,31 @@ struct EditProfileScreen: View {
|
|||
error = nil
|
||||
message = nil
|
||||
do {
|
||||
let updated = try await app.api.updateProfile(
|
||||
var updated = try await app.api.updateProfile(
|
||||
profileId: profileId,
|
||||
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
avatarId: avatarId,
|
||||
showAvatar: showAvatar,
|
||||
clearAvatarImage: clearCustomAvatar && pendingImage == nil,
|
||||
pin: clearPin ? nil : (pin.isEmpty ? nil : pin),
|
||||
clearPin: clearPin
|
||||
)
|
||||
app.store.setProfile(id: updated.id, name: updated.name, avatarId: updated.avatarId)
|
||||
if let pendingImage,
|
||||
let jpeg = pendingImage.jpegData(compressionQuality: 0.85) {
|
||||
let b64 = jpeg.base64EncodedString()
|
||||
updated = try await app.api.uploadProfileAvatar(profileId: updated.id, imageBase64: b64)
|
||||
}
|
||||
app.store.setProfile(
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
avatarId: updated.avatarId,
|
||||
showAvatar: updated.showsAvatar,
|
||||
avatarUrl: updated.avatarUrl ?? ""
|
||||
)
|
||||
hasCustomAvatar = updated.hasCustomPhoto
|
||||
remoteAvatarUrl = updated.avatarUrl
|
||||
pendingImage = nil
|
||||
clearCustomAvatar = false
|
||||
message = "Opgeslagen"
|
||||
pin = ""
|
||||
clearPin = false
|
||||
|
|
@ -379,14 +476,66 @@ struct AdminEditProfileScreen: View {
|
|||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var avatarId = "fox"
|
||||
@State private var showAvatar = true
|
||||
@State private var hasCustomAvatar = false
|
||||
@State private var remoteAvatarUrl: String?
|
||||
@State private var isKids = false
|
||||
@State private var pin = ""
|
||||
@State private var clearPin = false
|
||||
@State private var clearCustomAvatar = false
|
||||
@State private var pendingImage: UIImage?
|
||||
@State private var pickerItem: PhotosPickerItem?
|
||||
@State private var error: String?
|
||||
@State private var message: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Profielfoto") {
|
||||
HStack {
|
||||
Spacer()
|
||||
Group {
|
||||
if let pendingImage {
|
||||
Image(uiImage: pendingImage).resizable().scaledToFill()
|
||||
} else if hasCustomAvatar, let remoteAvatarUrl {
|
||||
AuthenticatedAvatarImage(path: remoteAvatarUrl)
|
||||
} else {
|
||||
Text(ProfileAvatars.emoji(for: avatarId))
|
||||
.font(.system(size: 40))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(McColors.surface)
|
||||
}
|
||||
}
|
||||
.frame(width: 88, height: 88)
|
||||
.clipShape(Circle())
|
||||
Spacer()
|
||||
}
|
||||
Toggle("Profielfoto aan/uit", isOn: $showAvatar)
|
||||
.tint(McColors.accent)
|
||||
PhotosPicker(selection: $pickerItem, matching: .images) {
|
||||
Label("Foto kiezen", systemImage: "photo")
|
||||
}
|
||||
if hasCustomAvatar || pendingImage != nil {
|
||||
Button("Foto verwijderen", role: .destructive) {
|
||||
pendingImage = nil
|
||||
pickerItem = nil
|
||||
clearCustomAvatar = true
|
||||
hasCustomAvatar = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(McColors.bgElevated)
|
||||
.onChange(of: pickerItem) { _, item in
|
||||
Task {
|
||||
guard let item else { return }
|
||||
if let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) {
|
||||
pendingImage = image.squared()
|
||||
clearCustomAvatar = false
|
||||
hasCustomAvatar = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Naam") {
|
||||
TextField("Naam", text: $name)
|
||||
}
|
||||
|
|
@ -446,22 +595,44 @@ struct AdminEditProfileScreen: View {
|
|||
name = profile.name
|
||||
avatarId = profile.avatarId ?? "fox"
|
||||
isKids = profile.isKids == true
|
||||
showAvatar = profile.showsAvatar
|
||||
hasCustomAvatar = profile.hasCustomPhoto
|
||||
remoteAvatarUrl = profile.avatarUrl
|
||||
}
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
do {
|
||||
let updated = try await app.api.updateProfile(
|
||||
var updated = try await app.api.updateProfile(
|
||||
profileId: profile.id,
|
||||
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
avatarId: avatarId,
|
||||
showAvatar: showAvatar,
|
||||
clearAvatarImage: clearCustomAvatar && pendingImage == nil,
|
||||
isKids: canManage && profile.isOwner != true ? isKids : nil,
|
||||
pin: clearPin ? nil : (pin.isEmpty ? nil : pin),
|
||||
clearPin: clearPin
|
||||
)
|
||||
if updated.id == app.store.profileId {
|
||||
app.store.setProfile(id: updated.id, name: updated.name, avatarId: updated.avatarId)
|
||||
if let pendingImage,
|
||||
let jpeg = pendingImage.jpegData(compressionQuality: 0.85) {
|
||||
updated = try await app.api.uploadProfileAvatar(
|
||||
profileId: updated.id,
|
||||
imageBase64: jpeg.base64EncodedString()
|
||||
)
|
||||
}
|
||||
if updated.id == app.store.profileId {
|
||||
app.store.setProfile(
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
avatarId: updated.avatarId,
|
||||
showAvatar: updated.showsAvatar,
|
||||
avatarUrl: updated.avatarUrl ?? ""
|
||||
)
|
||||
}
|
||||
hasCustomAvatar = updated.hasCustomPhoto
|
||||
remoteAvatarUrl = updated.avatarUrl
|
||||
pendingImage = nil
|
||||
clearCustomAvatar = false
|
||||
message = "Opgeslagen"
|
||||
error = nil
|
||||
} catch {
|
||||
|
|
@ -479,6 +650,61 @@ struct AdminEditProfileScreen: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct AuthenticatedAvatarImage: View {
|
||||
@EnvironmentObject private var app: AppModel
|
||||
let path: String
|
||||
@State private var image: UIImage?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(McColors.surface)
|
||||
}
|
||||
}
|
||||
.task(id: path) { await load() }
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let url = await app.api.absoluteUrl(path) else { return }
|
||||
var req = URLRequest(url: url)
|
||||
if let token = await app.api.bearerToken() {
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
if let (data, _) = try? await URLSession.shared.data(for: req),
|
||||
let ui = UIImage(data: data) {
|
||||
image = ui
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIImage {
|
||||
func squared() -> UIImage {
|
||||
let side = min(size.width, size.height)
|
||||
let origin = CGPoint(x: (size.width - side) / 2, y: (size.height - side) / 2)
|
||||
let crop = CGRect(origin: origin, size: CGSize(width: side, height: side))
|
||||
guard let cg = cgImage?.cropping(to: CGRect(
|
||||
x: crop.origin.x * scale,
|
||||
y: crop.origin.y * scale,
|
||||
width: crop.size.width * scale,
|
||||
height: crop.size.height * scale
|
||||
)) else { return self }
|
||||
let squared = UIImage(cgImage: cg, scale: scale, orientation: imageOrientation)
|
||||
let target: CGFloat = 512
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = 1
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: target, height: target), format: format)
|
||||
return renderer.image { _ in
|
||||
squared.draw(in: CGRect(x: 0, y: 0, width: target, height: target))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ShelfPrefsScreen: View {
|
||||
@EnvironmentObject private var app: AppModel
|
||||
@State private var prefs: [ShelfPref] = []
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { prisma } from "../database/client";
|
||||
import { hashPassword, verifyPassword } from "../security/crypto";
|
||||
import { AppError } from "../security/errors";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/** Vaste avatar-set (client toont emoji/tekening per id). */
|
||||
export const PROFILE_AVATARS = [
|
||||
|
|
@ -21,6 +23,7 @@ export type ProfileAvatarId = (typeof PROFILE_AVATARS)[number]["id"];
|
|||
const MAX_PROFILES = 8;
|
||||
const PIN_ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
|
||||
const PIN_MAX_ATTEMPTS = 5;
|
||||
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** In-memory PIN rate limit: profileId:deviceId → attempts */
|
||||
const pinAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||
|
|
@ -38,18 +41,35 @@ function normalizePin(pin: string | null | undefined): string | null {
|
|||
return digits;
|
||||
}
|
||||
|
||||
export function profileAvatarsDir(): string {
|
||||
const root = process.env.DATA_DIR || path.join(process.cwd(), "data");
|
||||
const dir = path.join(root, "profile-avatars");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function avatarFilePath(profileId: string, ext = "jpg"): string {
|
||||
return path.join(profileAvatarsDir(), `${profileId}.${ext}`);
|
||||
}
|
||||
|
||||
function publicProfile(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarId: string;
|
||||
avatarImage?: string | null;
|
||||
showAvatar?: boolean | null;
|
||||
isOwner: boolean;
|
||||
isKids: boolean;
|
||||
pinHash: string | null;
|
||||
}) {
|
||||
const hasCustomAvatar = !!row.avatarImage;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
avatarId: row.avatarId,
|
||||
showAvatar: row.showAvatar !== false,
|
||||
hasCustomAvatar,
|
||||
avatarUrl: hasCustomAvatar ? `/api/v1/client/profiles/${row.id}/avatar` : null,
|
||||
isOwner: row.isOwner,
|
||||
isKids: row.isKids,
|
||||
hasPin: !!row.pinHash,
|
||||
|
|
@ -94,6 +114,7 @@ export async function ensureOwnerProfile(
|
|||
viewerUserId: viewerId,
|
||||
name: name.slice(0, 32),
|
||||
avatarId: "fox",
|
||||
showAvatar: true,
|
||||
isOwner: true,
|
||||
isKids: false,
|
||||
},
|
||||
|
|
@ -120,6 +141,7 @@ export class ViewerProfileService {
|
|||
input: {
|
||||
name?: string;
|
||||
avatarId?: string;
|
||||
showAvatar?: boolean;
|
||||
isKids?: boolean;
|
||||
pin?: string | null;
|
||||
}
|
||||
|
|
@ -130,25 +152,21 @@ export class ViewerProfileService {
|
|||
throw new AppError("LIMIT", `Maximaal ${MAX_PROFILES} profielen`, 400);
|
||||
}
|
||||
|
||||
const name = (input.name || "").trim().slice(0, 32);
|
||||
if (name.length < 1) throw new AppError("INVALID_REQUEST", "Naam verplicht", 400);
|
||||
|
||||
const name = (input.name?.trim() || `Profiel ${count + 1}`).slice(0, 32);
|
||||
const avatarId = input.avatarId || "fox";
|
||||
if (!isAvatarId(avatarId)) {
|
||||
throw new AppError("INVALID_REQUEST", "Ongeldige avatar", 400);
|
||||
}
|
||||
|
||||
const pin = normalizePin(input.pin);
|
||||
const isKids = input.isKids === true;
|
||||
|
||||
try {
|
||||
const row = await prisma.viewerProfile.create({
|
||||
data: {
|
||||
viewerUserId: viewerId,
|
||||
name,
|
||||
avatarId,
|
||||
showAvatar: input.showAvatar !== false,
|
||||
isOwner: false,
|
||||
isKids,
|
||||
isKids: !!input.isKids,
|
||||
pinHash: pin ? await hashPassword(pin) : null,
|
||||
},
|
||||
});
|
||||
|
|
@ -165,6 +183,8 @@ export class ViewerProfileService {
|
|||
input: {
|
||||
name?: string;
|
||||
avatarId?: string;
|
||||
showAvatar?: boolean;
|
||||
clearAvatarImage?: boolean;
|
||||
isKids?: boolean;
|
||||
pin?: string | null;
|
||||
clearPin?: boolean;
|
||||
|
|
@ -198,6 +218,8 @@ export class ViewerProfileService {
|
|||
const data: {
|
||||
name?: string;
|
||||
avatarId?: string;
|
||||
showAvatar?: boolean;
|
||||
avatarImage?: string | null;
|
||||
isKids?: boolean;
|
||||
pinHash?: string | null;
|
||||
} = {};
|
||||
|
|
@ -213,8 +235,18 @@ export class ViewerProfileService {
|
|||
}
|
||||
data.avatarId = input.avatarId;
|
||||
}
|
||||
if (input.showAvatar !== undefined) {
|
||||
data.showAvatar = !!input.showAvatar;
|
||||
}
|
||||
if (input.clearAvatarImage === true) {
|
||||
data.avatarImage = null;
|
||||
try {
|
||||
fs.unlinkSync(avatarFilePath(profileId));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (input.isKids !== undefined && isOwnerActing) {
|
||||
// Owner-profiel mag geen kids worden
|
||||
if (target.isOwner && input.isKids) {
|
||||
throw new AppError("INVALID_REQUEST", "Hoofdprofiel kan geen kids-profiel zijn", 400);
|
||||
}
|
||||
|
|
@ -238,6 +270,78 @@ export class ViewerProfileService {
|
|||
}
|
||||
}
|
||||
|
||||
async setAvatarImage(
|
||||
viewerId: string,
|
||||
activeProfileId: string | null,
|
||||
profileId: string,
|
||||
imageBase64: string,
|
||||
mimeType?: string
|
||||
) {
|
||||
const target = await prisma.viewerProfile.findFirst({
|
||||
where: { id: profileId, viewerUserId: viewerId },
|
||||
});
|
||||
if (!target) throw new AppError("NOT_FOUND", "Profiel niet gevonden", 404);
|
||||
|
||||
const isSelf = activeProfileId === profileId;
|
||||
const active = activeProfileId
|
||||
? await prisma.viewerProfile.findFirst({
|
||||
where: { id: activeProfileId, viewerUserId: viewerId },
|
||||
})
|
||||
: null;
|
||||
if (!active?.isOwner && !isSelf) {
|
||||
throw new AppError("FORBIDDEN", "Geen rechten om dit profiel te wijzigen", 403);
|
||||
}
|
||||
|
||||
const raw = imageBase64.replace(/^data:image\/\w+;base64,/, "");
|
||||
let buf: Buffer;
|
||||
try {
|
||||
buf = Buffer.from(raw, "base64");
|
||||
} catch {
|
||||
throw new AppError("INVALID_REQUEST", "Ongeldige afbeelding", 400);
|
||||
}
|
||||
if (buf.length < 32 || buf.length > MAX_AVATAR_BYTES) {
|
||||
throw new AppError("INVALID_REQUEST", "Foto moet tussen 1KB en 2MB zijn", 400);
|
||||
}
|
||||
const mime = (mimeType || "image/jpeg").toLowerCase();
|
||||
if (!mime.startsWith("image/")) {
|
||||
throw new AppError("INVALID_REQUEST", "Alleen afbeeldingen toegestaan", 400);
|
||||
}
|
||||
const ext = mime.includes("png") ? "png" : mime.includes("webp") ? "webp" : "jpg";
|
||||
const fileName = `${profileId}.${ext}`;
|
||||
const full = path.join(profileAvatarsDir(), fileName);
|
||||
// Verwijder oude extensies
|
||||
for (const old of ["jpg", "jpeg", "png", "webp"]) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(profileAvatarsDir(), `${profileId}.${old}`));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(full, buf);
|
||||
const row = await prisma.viewerProfile.update({
|
||||
where: { id: profileId },
|
||||
data: { avatarImage: fileName, showAvatar: true },
|
||||
});
|
||||
return publicProfile(row);
|
||||
}
|
||||
|
||||
async readAvatarImage(viewerId: string, profileId: string): Promise<{ buf: Buffer; contentType: string }> {
|
||||
const target = await prisma.viewerProfile.findFirst({
|
||||
where: { id: profileId, viewerUserId: viewerId },
|
||||
});
|
||||
if (!target?.avatarImage) {
|
||||
throw new AppError("NOT_FOUND", "Geen profielfoto", 404);
|
||||
}
|
||||
const full = path.join(profileAvatarsDir(), target.avatarImage);
|
||||
if (!fs.existsSync(full)) {
|
||||
throw new AppError("NOT_FOUND", "Geen profielfoto", 404);
|
||||
}
|
||||
const ext = path.extname(target.avatarImage).toLowerCase();
|
||||
const contentType =
|
||||
ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg";
|
||||
return { buf: fs.readFileSync(full), contentType };
|
||||
}
|
||||
|
||||
async remove(viewerId: string, activeProfileId: string | null, profileId: string) {
|
||||
await assertCanManage(viewerId, activeProfileId);
|
||||
|
||||
|
|
@ -256,6 +360,13 @@ export class ViewerProfileService {
|
|||
where: { activeProfileId: profileId },
|
||||
data: { activeProfileId: null },
|
||||
});
|
||||
for (const old of ["jpg", "jpeg", "png", "webp"]) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(profileAvatarsDir(), `${profileId}.${old}`));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
await prisma.viewerProfile.delete({ where: { id: profileId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
@ -277,19 +388,14 @@ export class ViewerProfileService {
|
|||
let bucket = pinAttempts.get(key);
|
||||
if (!bucket || bucket.resetAt < now) {
|
||||
bucket = { count: 0, resetAt: now + PIN_ATTEMPT_WINDOW_MS };
|
||||
pinAttempts.set(key, bucket);
|
||||
}
|
||||
if (bucket.count >= PIN_MAX_ATTEMPTS) {
|
||||
throw new AppError(
|
||||
"RATE_LIMITED",
|
||||
"Te veel PIN-pogingen. Probeer later opnieuw.",
|
||||
429
|
||||
);
|
||||
throw new AppError("RATE_LIMIT", "Te veel PIN-pogingen, probeer later", 429);
|
||||
}
|
||||
|
||||
const ok = pin ? await verifyPassword(profile.pinHash, String(pin).replace(/\D/g, "")) : false;
|
||||
const ok = pin ? await verifyPassword(String(pin), profile.pinHash) : false;
|
||||
if (!ok) {
|
||||
bucket.count += 1;
|
||||
pinAttempts.set(key, bucket);
|
||||
throw new AppError("FORBIDDEN", "Onjuiste PIN", 403);
|
||||
}
|
||||
pinAttempts.delete(key);
|
||||
|
|
@ -297,9 +403,8 @@ export class ViewerProfileService {
|
|||
|
||||
await prisma.viewerDevice.update({
|
||||
where: { id: deviceId },
|
||||
data: { activeProfileId: profile.id },
|
||||
data: { activeProfileId: profileId, lastSeenAt: new Date() },
|
||||
});
|
||||
|
||||
return { profile: publicProfile(profile) };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ export type AuthedViewer = {
|
|||
id: string;
|
||||
name: string;
|
||||
avatarId: string;
|
||||
showAvatar: boolean;
|
||||
hasCustomAvatar: boolean;
|
||||
avatarUrl: string | null;
|
||||
isOwner: boolean;
|
||||
isKids: boolean;
|
||||
hasPin: boolean;
|
||||
|
|
@ -617,6 +620,11 @@ export class ViewerService {
|
|||
id: profile.id,
|
||||
name: profile.name,
|
||||
avatarId: profile.avatarId,
|
||||
showAvatar: profile.showAvatar !== false,
|
||||
hasCustomAvatar: !!profile.avatarImage,
|
||||
avatarUrl: profile.avatarImage
|
||||
? `/api/v1/client/profiles/${profile.id}/avatar`
|
||||
: null,
|
||||
isOwner: profile.isOwner,
|
||||
isKids: profile.isKids,
|
||||
hasPin: !!profile.pinHash,
|
||||
|
|
|
|||
Loading…
Reference in a new issue