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