Add Netflix-style viewer profiles with kids filter on master, TV, and mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-06 03:01:00 +02:00
parent 4cd4347daf
commit f58a088156
42 changed files with 4940 additions and 745 deletions

View file

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

View file

@ -5,6 +5,7 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.view.WindowCompat
import nl.vonas.mediacluster.tv.ui.McNav
import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme
@ -13,6 +14,10 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
volumeControlStream = AudioManager.STREAM_MUSIC
enableEdgeToEdge()
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = false
isAppearanceLightNavigationBars = false
}
setContent {
MediaClusterTheme {
McNav()

View file

@ -196,6 +196,60 @@ class ApiClient(
Unit
}
suspend fun listProfiles(): ProfilesPayload = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/profiles").get().build()
parseObject(execute(req)) { o ->
ProfilesPayload(
profiles = o.optJSONArray("profiles").toProfiles(),
activeProfileId = o.optString("activeProfileId").takeIf { it.isNotBlank() && it != "null" },
canManage = o.optBoolean("canManage", false),
)
}
}
suspend fun selectProfile(profileId: String, pin: String? = null): ViewerProfile =
withContext(Dispatchers.IO) {
val body = JSONObject()
if (!pin.isNullOrBlank()) body.put("pin", pin)
val req = authed("$baseUrl/api/v1/client/profiles/$profileId/select")
.post(body.toString().toRequestBody(jsonMedia))
.build()
parseObject(execute(req)) { o ->
o.getJSONObject("profile").toProfile()
}
}
suspend fun clearProfile() = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/profiles/clear")
.post("{}".toRequestBody(jsonMedia))
.build()
execute(req)
Unit
}
suspend fun createProfile(
name: String,
avatarId: String,
isKids: Boolean,
pin: String?,
): ViewerProfile = withContext(Dispatchers.IO) {
val body = JSONObject()
.put("name", name)
.put("avatarId", avatarId)
.put("isKids", isKids)
if (!pin.isNullOrBlank()) body.put("pin", pin)
val req = authed("$baseUrl/api/v1/client/profiles")
.post(body.toString().toRequestBody(jsonMedia))
.build()
parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() }
}
suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build()
execute(req)
Unit
}
suspend fun searchSubtitles(mediaFileId: String, languages: String = "nl,en"): SubtitleSearchResult =
withContext(Dispatchers.IO) {
val id = java.net.URLEncoder.encode(mediaFileId, "UTF-8")
@ -263,6 +317,7 @@ class ApiClient(
parseObject(execute(req)) { o ->
ClientMe(
iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true,
profile = o.optJSONObject("profile")?.toProfile(),
)
}
}
@ -286,6 +341,15 @@ class ApiClient(
}
}
private fun JSONObject.optClean(key: String): String? {
if (!has(key) || isNull(key)) return null
val raw = optString(key).trim()
if (raw.isEmpty() || raw.equals("null", ignoreCase = true) || raw.equals("undefined", ignoreCase = true)) {
return null
}
return raw
}
suspend fun iptvChannels(categoryId: String? = null, includeEpg: Boolean = false): List<IptvChannel> = withContext(Dispatchers.IO) {
val params = buildList {
categoryId?.let { add("categoryId=${java.net.URLEncoder.encode(it, "UTF-8")}") }
@ -302,10 +366,10 @@ class ApiClient(
IptvChannel(
streamId = c.getInt("streamId"),
name = c.getString("name"),
logoUrl = c.optString("logoUrl").ifBlank { null },
logoUrl = c.optClean("logoUrl"),
number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") },
categoryId = c.optString("categoryId").ifBlank { null },
nowTitle = c.optString("nowTitle").ifBlank { null },
categoryId = c.optClean("categoryId"),
nowTitle = c.optClean("nowTitle"),
)
)
}
@ -341,25 +405,47 @@ class ApiClient(
}
}
/** Vul ontbrekende now-titels aan (server-batch of parallel per zender). */
suspend fun fetchMissingNowTitles(channels: List<IptvChannel>): Map<Int, String> {
/** Vul ontbrekende now-titels aan; [onBatch] voor live UI-updates. */
suspend fun fetchMissingNowTitles(
channels: List<IptvChannel>,
onBatch: ((Map<Int, String>) -> Unit)? = null,
): Map<Int, String> {
fun clean(title: String?) = title?.takeIf {
it.isNotBlank() && !it.equals("null", ignoreCase = true)
}
val result = linkedMapOf<Int, String>()
channels.forEach { ch ->
ch.nowTitle?.takeIf { it.isNotBlank() }?.let { result[ch.streamId] = it }
clean(ch.nowTitle)?.let { result[ch.streamId] = it }
}
if (result.isNotEmpty()) {
withContext(Dispatchers.Main.immediate) {
onBatch?.invoke(result.toMap())
}
}
val missing = channels.filter { !result.containsKey(it.streamId) }
if (missing.isEmpty()) return result
coroutineScope {
missing.chunked(10).forEach { batch ->
missing.chunked(12).forEach { batch ->
val chunk = linkedMapOf<Int, String>()
batch.map { ch ->
async(Dispatchers.IO) {
runCatching {
iptvEpg(ch.streamId).now?.title?.takeIf { it.isNotBlank() }
clean(iptvEpg(ch.streamId).now?.title)
}.getOrNull()?.let { ch.streamId to it }
}
}.awaitAll().forEach { pair ->
pair?.let { (id, title) -> result[id] = title }
pair?.let { (id, title) ->
result[id] = title
chunk[id] = title
}
}
if (chunk.isNotEmpty()) {
withContext(Dispatchers.Main.immediate) {
onBatch?.invoke(chunk)
}
}
}
}
@ -367,11 +453,11 @@ class ApiClient(
}
private fun JSONObject.toEpgProgram(): EpgProgram? {
val title = optString("title").ifBlank { return null }
val title = optClean("title") ?: return null
return EpgProgram(
title = title,
start = optString("start").ifBlank { null },
end = optString("end").ifBlank { null },
start = optClean("start"),
end = optClean("end"),
)
}
@ -500,6 +586,22 @@ data class ShelfPref(
data class ClientMe(
val iptvEnabled: Boolean,
val profile: ViewerProfile? = null,
)
data class ViewerProfile(
val id: String,
val name: String,
val avatarId: String,
val isOwner: Boolean,
val isKids: Boolean,
val hasPin: Boolean,
)
data class ProfilesPayload(
val profiles: List<ViewerProfile>,
val activeProfileId: String?,
val canManage: Boolean,
)
data class IptvCategory(
@ -585,3 +687,21 @@ private fun JSONArray?.toShelves(): List<ShelfRow> {
}
}
}
private fun JSONArray?.toProfiles(): List<ViewerProfile> {
if (this == null) return emptyList()
return buildList {
for (i in 0 until length()) {
add(getJSONObject(i).toProfile())
}
}
}
private fun JSONObject.toProfile(): ViewerProfile = ViewerProfile(
id = getString("id"),
name = optString("name"),
avatarId = optString("avatarId").ifBlank { "fox" },
isOwner = optBoolean("isOwner"),
isKids = optBoolean("isKids"),
hasPin = optBoolean("hasPin"),
)

View file

@ -7,25 +7,93 @@ import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import nl.vonas.mediacluster.BuildConfig
import org.json.JSONArray
private val Context.dataStore by preferencesDataStore("mc_mobile")
class SessionStore(private val context: Context) {
private val tokenKey = stringPreferencesKey("refresh_token")
private val apiKey = stringPreferencesKey("api_base")
private val profileIdKey = stringPreferencesKey("profile_id")
private val profileNameKey = stringPreferencesKey("profile_name")
private val profileAvatarKey = stringPreferencesKey("profile_avatar")
private val recentSearchKey = stringPreferencesKey("recent_searches")
val refreshToken: Flow<String?> = context.dataStore.data.map { it[tokenKey] }
val apiBase: Flow<String> = context.dataStore.data.map {
it[apiKey] ?: BuildConfig.DEFAULT_API_BASE
}
val profileId: Flow<String?> = context.dataStore.data.map { it[profileIdKey] }
val profileName: Flow<String?> = context.dataStore.data.map { it[profileNameKey] }
val profileAvatarId: Flow<String?> = context.dataStore.data.map { it[profileAvatarKey] }
val recentSearches: Flow<List<String>> = context.dataStore.data.map { prefs ->
val raw = prefs[recentSearchKey].orEmpty()
if (raw.isBlank()) emptyList()
else runCatching {
val arr = JSONArray(raw)
buildList {
for (i in 0 until arr.length()) {
val q = arr.optString(i).trim()
if (q.isNotBlank()) add(q)
}
}
}.getOrDefault(emptyList())
}
suspend fun setToken(token: String?) {
context.dataStore.edit { prefs ->
if (token.isNullOrBlank()) prefs.remove(tokenKey) else prefs[tokenKey] = token
if (token.isNullOrBlank()) {
prefs.remove(tokenKey)
prefs.remove(profileIdKey)
prefs.remove(profileNameKey)
prefs.remove(profileAvatarKey)
} else {
prefs[tokenKey] = token
}
}
}
suspend fun setProfile(id: String?, name: String? = null, avatarId: String? = null) {
context.dataStore.edit { prefs ->
if (id.isNullOrBlank()) {
prefs.remove(profileIdKey)
prefs.remove(profileNameKey)
prefs.remove(profileAvatarKey)
} else {
prefs[profileIdKey] = id
if (!name.isNullOrBlank()) prefs[profileNameKey] = name else prefs.remove(profileNameKey)
if (!avatarId.isNullOrBlank()) prefs[profileAvatarKey] = avatarId
else prefs.remove(profileAvatarKey)
}
}
}
suspend fun setApiBase(url: String) {
context.dataStore.edit { it[apiKey] = url.trim().trimEnd('/') }
}
suspend fun addRecentSearch(query: String) {
val q = query.trim()
if (q.length < 2) return
context.dataStore.edit { prefs ->
val existing = prefs[recentSearchKey].orEmpty().let { raw ->
if (raw.isBlank()) mutableListOf()
else runCatching {
val arr = JSONArray(raw)
MutableList(arr.length()) { i -> arr.optString(i) }
.filter { it.isNotBlank() }
.toMutableList()
}.getOrElse { mutableListOf() }
}
existing.removeAll { it.equals(q, ignoreCase = true) }
existing.add(0, q)
while (existing.size > 8) existing.removeAt(existing.lastIndex)
prefs[recentSearchKey] = JSONArray(existing).toString()
}
}
suspend fun clearRecentSearches() {
context.dataStore.edit { it.remove(recentSearchKey) }
}
}

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -35,8 +36,10 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.MediaItem
import nl.vonas.mediacluster.tv.ui.components.EmptyState
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.components.PosterCard
import nl.vonas.mediacluster.tv.ui.components.SkeletonBox
import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
@ -147,7 +150,11 @@ fun BrowseScreen(
fontWeight = FontWeight.Bold,
)
Text(
if (total > 0) "$total titels" else if (loading) "Laden…" else "Geen resultaten",
when {
total > 0 -> "$total titels"
loading -> "Laden…"
else -> "Geen titels"
},
color = McColors.TextDim,
fontSize = McDimens.meta,
)
@ -159,7 +166,33 @@ fun BrowseScreen(
Spacer(Modifier.height(12.dp))
if (error != null) {
Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
Spacer(Modifier.height(8.dp))
}
when {
loading && items.isEmpty() -> {
Column(verticalArrangement = Arrangement.spacedBy(McDimens.gridGap)) {
repeat(3) {
Row(horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap)) {
repeat(4) {
SkeletonBox(
Modifier
.weight(1f)
.aspectRatio(2f / 3f),
)
}
}
}
}
}
!loading && items.isEmpty() && error == null -> {
EmptyState(
title = "Geen titels gevonden",
subtitle = "Deze collectie is leeg of nog niet gesynchroniseerd.",
actionLabel = "Opnieuw proberen",
onAction = { scope.launch { load(reset = true) } },
)
}
else -> {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = minCell),
state = gridState,
@ -198,3 +231,5 @@ fun BrowseScreen(
}
}
}
}
}

View file

@ -368,39 +368,11 @@ fun MovieDetailScreen(
) {
DetailLogoOrTitle(logoUrl, title)
MetaChips(metaParts)
if (versions.size > 1) {
SelectedQualityLine(selected)
}
}
if (director.isNotEmpty()) {
Text(
"Regie: ${director.take(2).joinToString(", ")}",
color = Color(0xFF9A9A9A),
fontSize = McDimens.meta,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (overview.isNotBlank()) {
Text(
overview,
color = McColors.TextDim,
fontSize = McDimens.bodySmall,
maxLines = 6,
overflow = TextOverflow.Ellipsis,
lineHeight = 18.sp,
)
}
if (cast.isNotEmpty()) {
Text(
"Met: ${cast.take(4).joinToString(", ")}",
color = Color(0xFF8A8A8A),
fontSize = McDimens.meta,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
@ -416,13 +388,46 @@ fun MovieDetailScreen(
openYoutubeTrailer(context, trailerKey!!)
}
}
DetailActionButton("Als gekeken") {
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DetailActionButton("Markeer als gezien") {
scope.launch {
runCatching { api.markWatched(movieId = movieId) }
onBack()
}
}
}
if (overview.isNotBlank()) {
Text(
overview,
color = McColors.TextDim,
fontSize = McDimens.bodySmall,
maxLines = 6,
overflow = TextOverflow.Ellipsis,
lineHeight = 18.sp,
)
}
if (director.isNotEmpty()) {
Text(
"Regie · ${director.take(2).joinToString(", ")}",
color = Color(0xFF9A9A9A),
fontSize = McDimens.meta,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (cast.isNotEmpty()) {
Text(
"Met · ${cast.take(4).joinToString(", ")}",
color = Color(0xFF8A8A8A),
fontSize = McDimens.meta,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
if (versions.size > 1) {
Text(
"Kwaliteit",
@ -447,6 +452,7 @@ fun MovieDetailScreen(
Spacer(Modifier.height(24.dp))
}
}
StatusBarGlassOverlay(Modifier.align(Alignment.TopCenter))
}
}
@ -569,18 +575,6 @@ fun SeriesDetailScreen(
Spacer(Modifier.height(6.dp))
DetailLogoOrTitle(logoUrl, title)
MetaChips(metaParts)
if (overview.isNotBlank()) {
Text(
overview,
color = McColors.TextDim,
fontSize = McDimens.bodySmall,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
lineHeight = 18.sp,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
)
}
if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
episodes.firstOrNull()?.let { ep ->
@ -592,6 +586,18 @@ fun SeriesDetailScreen(
DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) }
}
}
if (overview.isNotBlank()) {
Text(
overview,
color = McColors.TextDim,
fontSize = McDimens.bodySmall,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
lineHeight = 18.sp,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
}
if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
if (seasons.size > 1) {
Spacer(Modifier.height(6.dp))
Text("Seizoenen", color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.SemiBold)
@ -619,6 +625,7 @@ fun SeriesDetailScreen(
)
}
}
StatusBarGlassOverlay(Modifier.align(Alignment.TopCenter))
}
}

View file

@ -100,6 +100,7 @@ fun LiveTvPlayerScreen(
val player = remember { buildLiveTvPlayer(context) }
val playerFocus = remember { FocusRequester() }
val menuScope = rememberCoroutineScope()
RememberPlayerImmersiveMode()
var currentStreamId by remember { mutableIntStateOf(streamId) }
var loading by remember { mutableStateOf(true) }

View file

@ -1,9 +1,15 @@
package nl.vonas.mediacluster.tv.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.tween
import android.app.Activity
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -15,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@ -25,6 +32,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
@ -33,6 +41,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.DisposableEffect
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -55,20 +64,28 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import coil.imageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.BuildConfig
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.HomePayload
import nl.vonas.mediacluster.tv.data.MediaItem
import nl.vonas.mediacluster.tv.data.PollResult
import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.data.ShelfPref
import nl.vonas.mediacluster.tv.ui.components.EmptyState
import nl.vonas.mediacluster.tv.ui.components.HomeSkeleton
import nl.vonas.mediacluster.tv.ui.components.MainTab
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.components.PosterCard
import nl.vonas.mediacluster.tv.ui.components.SectionLabel
import nl.vonas.mediacluster.tv.ui.components.SubNavItem
import nl.vonas.mediacluster.tv.ui.components.TopNavBar
import nl.vonas.mediacluster.tv.ui.profiles.ProfilesScreen
import nl.vonas.mediacluster.tv.ui.profiles.avatarEmoji
import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
@ -81,6 +98,9 @@ fun McNav() {
val scope = rememberCoroutineScope()
var ready by remember { mutableStateOf(false) }
var authed by remember { mutableStateOf(false) }
var hasProfile by remember { mutableStateOf(false) }
val profileAvatarId by store.profileAvatarId.collectAsState(initial = null)
val profileName by store.profileName.collectAsState(initial = null)
// Blijft behouden als main uit compositie gaat (bijv. live player)
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) }
@ -95,6 +115,18 @@ fun McNav() {
val token = store.refreshToken.first()
api.configure(base, token)
authed = !token.isNullOrBlank()
if (authed) {
val me = runCatching { api.clientMe() }.getOrNull()
if (me?.profile != null) {
store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId)
hasProfile = true
} else {
store.setProfile(null)
hasProfile = false
}
} else {
hasProfile = false
}
ready = true
}
@ -103,11 +135,35 @@ fun McNav() {
return
}
NavHost(navController = nav, startDestination = if (authed) "main" else "link") {
NavHost(
navController = nav,
startDestination = when {
!authed -> "link"
!hasProfile -> "profiles"
else -> "main"
},
enterTransition = { McTransitions.enter },
exitTransition = { McTransitions.exit },
popEnterTransition = { McTransitions.popEnter },
popExitTransition = { McTransitions.popExit },
) {
composable("link") {
LinkScreen(api, store) {
authed = true
nav.navigate("main") { popUpTo("link") { inclusive = true } }
hasProfile = false
nav.navigate("profiles") { popUpTo("link") { inclusive = true } }
}
}
composable("profiles") {
ProfilesScreen(api = api, store = store, switcherMode = true) {
hasProfile = true
nav.navigate("main") { popUpTo("profiles") { inclusive = true } }
}
}
composable("profiles_manage") {
ProfilesScreen(api = api, store = store, switcherMode = false) {
hasProfile = true
nav.popBackStack()
}
}
composable("main") {
@ -121,6 +177,8 @@ fun McNav() {
onTvLastStreamChange = { tvLastStreamId = it },
tvFocusNonce = tvFocusNonce,
onTvFocusContent = { tvFocusNonce++ },
profileAvatarId = profileAvatarId,
profileName = profileName,
onOpenMovie = { nav.navigate("movie/$it") },
onOpenSeries = { nav.navigate("series/$it") },
onBrowse = { catalogId, _title ->
@ -137,11 +195,25 @@ fun McNav() {
if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name
nav.navigate("live/$streamId")
},
onSwitchProfile = {
scope.launch {
runCatching { api.clearProfile() }
store.setProfile(null)
hasProfile = false
nav.navigate("profiles") {
popUpTo("main") { inclusive = true }
}
}
},
onManageProfiles = {
nav.navigate("profiles_manage")
},
onLogout = {
scope.launch {
store.setToken(null)
api.configure(store.apiBase.first(), null)
authed = false
hasProfile = false
nav.navigate("link") { popUpTo(0) { inclusive = true } }
}
},
@ -246,11 +318,15 @@ private fun MainHub(
onTvLastStreamChange: (Int?) -> Unit,
tvFocusNonce: Int,
onTvFocusContent: () -> Unit,
profileAvatarId: String?,
profileName: String?,
onOpenMovie: (String) -> Unit,
onOpenSeries: (String) -> Unit,
onBrowse: (catalogId: String, title: String) -> Unit,
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
onPlayLive: (Int) -> Unit,
onSwitchProfile: () -> Unit,
onManageProfiles: () -> Unit,
onLogout: () -> Unit,
) {
var iptvEnabled by remember { mutableStateOf(false) }
@ -370,7 +446,14 @@ private fun MainHub(
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
Column(Modifier.fillMaxSize()) {
Box(Modifier.weight(1f).fillMaxWidth()) {
when (tab) {
AnimatedContent(
targetState = tab,
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(160))
},
label = "hubTab",
) { currentTab ->
when (currentTab) {
MainTab.Home -> HomeScreen(
api = api,
listState = homeListState,
@ -419,10 +502,46 @@ private fun MainHub(
onTabChange(MainTab.Home)
focusTopNav()
},
onSwitchProfile = onSwitchProfile,
onManageProfiles = onManageProfiles,
onLogout = onLogout,
)
}
}
StatusBarGlassOverlay(Modifier.align(Alignment.TopCenter))
// Profiel-avatar rechtsboven → terug naar switcher
Row(
Modifier
.align(Alignment.TopEnd)
.statusBarsPadding()
.padding(top = 8.dp, end = 12.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color(0xCC1A1A22))
.clickable(onClick = onSwitchProfile)
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
Modifier
.size(28.dp)
.clip(CircleShape)
.background(Color(0xFF2E2E3A)),
contentAlignment = Alignment.Center,
) {
Text(avatarEmoji(profileAvatarId ?: "fox"), fontSize = 16.sp)
}
if (!profileName.isNullOrBlank()) {
Text(
profileName,
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
}
}
Box(Modifier.focusRequester(navFocus)) {
TopNavBar(
selected = tab,
@ -611,20 +730,37 @@ private fun HomeScreen(
) {
var home by remember { mutableStateOf<HomePayload?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
var menuReady by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val menuFocus = remember { FocusRequester() }
val context = LocalContext.current
suspend fun reloadHome() {
home = api.home()
// Prefetch posters voor snellere shelves
val urls = buildList {
home?.spotlight?.forEach { item ->
item.backdropUrl?.let(::add)
item.posterUrl?.let(::add)
}
home?.continueWatching?.take(8)?.forEach { it.posterUrl?.let(::add) }
home?.shelves?.flatMap { it.items }?.take(36)?.forEach { it.posterUrl?.let(::add) }
}.distinct().take(48)
val loader = context.imageLoader
urls.forEach { url ->
loader.enqueue(ImageRequest.Builder(context).data(url).build())
}
}
LaunchedEffect(Unit) {
try {
reloadHome()
} catch (e: Exception) {
error = e.message
error = e.message ?: "Kon Home niet laden"
} finally {
loading = false
}
}
@ -642,13 +778,17 @@ private fun HomeScreen(
val spotlight = home?.spotlight.orEmpty().ifEmpty {
home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty()
}
val continueItems = home?.continueWatching.orEmpty()
Box(Modifier.fillMaxSize()) {
if (loading && home == null) {
HomeSkeleton()
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
contentPadding = PaddingValues(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(McDimens.shelfBlockGap),
) {
item {
SpotlightBillboard(
@ -684,7 +824,6 @@ private fun HomeScreen(
)
}
}
val continueItems = home?.continueWatching.orEmpty()
if (continueItems.isNotEmpty()) {
val preview = continueItems.take(5)
item {
@ -731,6 +870,15 @@ private fun HomeScreen(
},
)
}
if (home != null && home!!.shelves.isEmpty() && continueItems.isEmpty() && error == null) {
item {
EmptyState(
title = "Nog geen titels",
subtitle = "Zodra er content beschikbaar is, zie je die hier.",
)
}
}
}
}
val menuItem = continueMenu
@ -755,7 +903,7 @@ private fun HomeScreen(
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = McDimens.bodySmall)
}
Spacer(Modifier.height(4.dp))
McButton("Markeren als gezien") {
McButton("Markeer als gezien") {
if (!menuReady) return@McButton
scope.launch {
runCatching {
@ -769,7 +917,7 @@ private fun HomeScreen(
continueMenu = null
}
}
McButton("Verbergen") {
McButton("Verberg uit lijst") {
if (!menuReady) return@McButton
scope.launch {
val pid = menuItem.progressId
@ -810,9 +958,17 @@ private fun SpotlightBillboard(
}
Box(Modifier.fillMaxWidth().height(billboardH)) {
AnimatedContent(
targetState = current,
transitionSpec = { fadeIn(tween(500)) togetherWith fadeOut(tween(500)) },
label = "spotlight",
modifier = Modifier.fillMaxSize(),
contentKey = { "${it.type}:${it.id}" },
) { slide ->
Box(Modifier.fillMaxSize()) {
AsyncImage(
model = current.backdropUrl ?: current.posterUrl,
contentDescription = current.title,
model = slide.backdropUrl ?: slide.posterUrl,
contentDescription = slide.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
@ -823,6 +979,8 @@ private fun SpotlightBillboard(
),
),
)
}
}
Column(
Modifier
@ -859,7 +1017,7 @@ private fun SpotlightBillboard(
fontWeight = FontWeight.Black,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 30.sp,
lineHeight = 32.sp,
)
val meta = listOfNotNull(
current.year?.toString(),
@ -871,7 +1029,7 @@ private fun SpotlightBillboard(
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (current.id.isNotBlank()) {
McButton("▶ Afspelen", primary = true, compact = true, onClick = { onPlay(current) })
McButton("Afspelen", primary = true, compact = true, onClick = { onPlay(current) })
McButton("Meer info", compact = true, onClick = { onOpen(current) })
}
}
@ -905,7 +1063,17 @@ private fun ShelfBlock(
val posterH = McDimens.posterHeight(posterW)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = McDimens.screenPad),
Modifier
.fillMaxWidth()
.padding(horizontal = McDimens.screenPad)
.then(
if (onSeeAll != null) {
Modifier.clickable(onClick = onSeeAll)
} else {
Modifier
},
)
.padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@ -919,7 +1087,13 @@ private fun ShelfBlock(
overflow = TextOverflow.Ellipsis,
)
if (onSeeAll != null) {
McButton("Alles", compact = true) { onSeeAll() }
Text(
"›",
color = Color(0xFF888888),
fontSize = 22.sp,
fontWeight = FontWeight.Light,
modifier = Modifier.padding(start = 8.dp),
)
}
}
LazyRow(
@ -944,6 +1118,8 @@ private fun ShelfBlock(
private fun SettingsScreen(
api: ApiClient,
onBack: () -> Unit,
onSwitchProfile: () -> Unit = {},
onManageProfiles: () -> Unit = {},
onLogout: () -> Unit = {},
) {
var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
@ -973,16 +1149,106 @@ private fun SettingsScreen(
Modifier
.fillMaxSize()
.background(McColors.Bg)
.statusBarsPadding()
.padding(horizontal = McDimens.screenPad, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
.statusBarsPadding(),
contentPadding = PaddingValues(
start = McDimens.screenPad,
end = McDimens.screenPad,
top = 12.dp,
bottom = 28.dp,
),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Text("Instellingen", color = Color.White, fontSize = McDimens.titleScreen, fontWeight = FontWeight.Bold)
Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = McDimens.bodySmall)
if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
Text(
"Instellingen",
color = Color.White,
fontSize = McDimens.titleScreen,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(16.dp))
SectionLabel("Account")
Spacer(Modifier.height(8.dp))
McButton("Uitloggen", compact = true, onClick = onLogout)
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(McColors.BgElevated)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
"Vonas Media Cluster",
color = Color.White,
fontSize = McDimens.body,
fontWeight = FontWeight.SemiBold,
)
Text(
"Versie ${BuildConfig.VERSION_NAME}",
color = McColors.TextDim,
fontSize = McDimens.meta,
)
}
Spacer(Modifier.height(8.dp))
Box(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(McColors.BgElevated)
.clickable(onClick = onSwitchProfile)
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Text(
"Profiel wisselen",
color = Color.White,
fontSize = McDimens.body,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(Modifier.height(8.dp))
Box(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(McColors.BgElevated)
.clickable(onClick = onManageProfiles)
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Text(
"Profielen beheren",
color = Color.White,
fontSize = McDimens.body,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(Modifier.height(8.dp))
Box(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(McColors.BgElevated)
.clickable(onClick = onLogout)
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Text(
"Uitloggen",
color = McColors.Danger,
fontSize = McDimens.body,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(Modifier.height(20.dp))
SectionLabel("Home-planken")
Spacer(Modifier.height(4.dp))
Text(
"Kies welke rijen op Home staan en in welke volgorde.",
color = McColors.TextDim,
fontSize = McDimens.bodySmall,
lineHeight = 18.sp,
)
if (error != null) {
Spacer(Modifier.height(8.dp))
Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
}
Spacer(Modifier.height(4.dp))
}
itemsIndexed(prefs) { index, p ->
@ -991,13 +1257,32 @@ private fun SettingsScreen(
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(McColors.BgElevated)
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(p.name, color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
McButton("↑", compact = true) {
Column(Modifier.weight(1f)) {
Text(
p.name,
color = Color.White,
fontSize = McDimens.body,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
if (p.visible) "Zichtbaar op Home" else "Verborgen",
color = if (p.visible) Color(0xFF888888) else Color(0xFF555555),
fontSize = McDimens.caption,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SettingsIconChip(
label = "↑",
enabled = index > 0,
onClick = {
if (index > 0) {
val next = prefs.toMutableList()
val tmp = next[index - 1]
@ -1005,8 +1290,12 @@ private fun SettingsScreen(
next[index] = tmp
persist(next)
}
}
McButton("↓", compact = true) {
},
)
SettingsIconChip(
label = "↓",
enabled = index < prefs.lastIndex,
onClick = {
if (index < prefs.lastIndex) {
val next = prefs.toMutableList()
val tmp = next[index + 1]
@ -1014,7 +1303,9 @@ private fun SettingsScreen(
next[index] = tmp
persist(next)
}
}
},
)
Spacer(Modifier.width(6.dp))
Switch(
checked = p.visible,
onCheckedChange = { checked ->
@ -1026,6 +1317,39 @@ private fun SettingsScreen(
}
}
}
item {
Spacer(Modifier.height(12.dp))
Text(
"Vonas Media Cluster · ${BuildConfig.VERSION_NAME}",
color = Color(0xFF555555),
fontSize = McDimens.caption,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun SettingsIconChip(
label: String,
enabled: Boolean,
onClick: () -> Unit,
) {
Box(
Modifier
.clip(RoundedCornerShape(6.dp))
.background(if (enabled) Color(0x22FFFFFF) else Color(0x0AFFFFFF))
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 10.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Text(
label,
color = if (enabled) Color.White else Color(0xFF444444),
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
)
}
}

View file

@ -0,0 +1,28 @@
package nl.vonas.mediacluster.tv.ui
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.navigation.NavBackStackEntry
object McTransitions {
private const val Duration = 240
val enter =
fadeIn(tween(Duration)) + slideInHorizontally(tween(Duration)) { it / 16 }
val exit = fadeOut(tween(180))
val popEnter = fadeIn(tween(Duration))
val popExit =
fadeOut(tween(180)) + slideOutHorizontally(tween(Duration)) { it / 16 }
fun AnimatedContentTransitionScope<NavBackStackEntry>.defaultEnter() = enter
fun AnimatedContentTransitionScope<NavBackStackEntry>.defaultExit() = exit
fun AnimatedContentTransitionScope<NavBackStackEntry>.defaultPopEnter() = popEnter
fun AnimatedContentTransitionScope<NavBackStackEntry>.defaultPopExit() = popExit
}

View file

@ -1,12 +1,12 @@
package nl.vonas.mediacluster.tv.ui
import android.util.Base64
import android.view.ViewGroup.LayoutParams
import android.widget.FrameLayout
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import android.util.Base64
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
@ -27,6 +27,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@ -95,6 +97,7 @@ fun PlayerScreen(
val controlsFocus = remember { FocusRequester() }
val catcherFocus = remember { FocusRequester() }
val scope = rememberCoroutineScope()
RememberPlayerImmersiveMode()
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
@ -387,7 +390,7 @@ fun PlayerScreen(
}
if (loading && error == null) {
Text("Laden…", color = Color(0xFFB0B8C8), fontSize = 18.sp, modifier = Modifier.align(Alignment.Center))
Text("Bezig met laden…", color = Color(0xFFB0B8C8), fontSize = 16.sp, modifier = Modifier.align(Alignment.Center))
}
if (error != null) {
Column(
@ -459,24 +462,31 @@ fun PlayerScreen(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(horizontal = 48.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
.padding(horizontal = 24.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.fillMaxWidth()
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(Color(0x44FFFFFF)),
) {
val pct = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
Box(
Modifier
.fillMaxWidth(pct)
.height(3.dp)
.background(McColors.Brand),
)
val progress = if (durationMs > 0) {
(positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
Slider(
value = progress,
onValueChange = { v ->
if (durationMs > 0) {
val seek = (v * durationMs).toLong()
player.seekTo(seek)
positionMs = seek
showControls()
}
},
modifier = Modifier.fillMaxWidth().height(28.dp),
colors = SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = McColors.Brand,
inactiveTrackColor = Color(0x44FFFFFF),
),
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,

View file

@ -2,32 +2,41 @@ package nl.vonas.mediacluster.tv.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@ -39,7 +48,11 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.MediaItem
import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.ui.components.EmptyState
import nl.vonas.mediacluster.tv.ui.components.PosterCard
import nl.vonas.mediacluster.tv.ui.components.SectionLabel
import nl.vonas.mediacluster.tv.ui.components.SkeletonBox
import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
@ -51,6 +64,9 @@ fun SearchScreen(
onOpenSeries: (String) -> Unit,
onAtTopBack: (() -> Unit)? = null,
) {
val context = LocalContext.current
val store = remember { SessionStore(context) }
val recent by store.recentSearches.collectAsState(initial = emptyList())
var query by remember { mutableStateOf("") }
var results by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
var busy by remember { mutableStateOf(false) }
@ -73,21 +89,25 @@ fun SearchScreen(
}
}
fun scheduleSearch(next: String) {
fun runSearch(next: String, saveRecent: Boolean = true) {
query = next
searchJob?.cancel()
searchJob = scope.launch {
delay(250)
delay(220)
if (next.isBlank()) {
results = emptyList()
busy = false
return@launch
}
busy = true
error = null
try {
results = api.search(next)
if (saveRecent && next.trim().length >= 2) {
store.addRecentSearch(next)
}
} catch (e: Exception) {
error = e.message
error = e.message ?: "Zoeken mislukt"
} finally {
busy = false
}
@ -111,15 +131,20 @@ fun SearchScreen(
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = query,
onValueChange = { scheduleSearch(it) },
onValueChange = { runSearch(it) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = {
androidx.compose.material3.Text("Typ een titel…", color = McColors.TextDim)
androidx.compose.material3.Text("Zoek films of series…", color = McColors.TextDim)
},
shape = RoundedCornerShape(10.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
keyboardActions = KeyboardActions(
onSearch = {
focusManager.clearFocus()
if (query.isNotBlank()) runSearch(query, saveRecent = true)
},
),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
@ -131,12 +156,81 @@ fun SearchScreen(
),
)
Spacer(Modifier.height(12.dp))
when {
error != null -> Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
busy -> Text("Zoeken…", color = McColors.TextDim, fontSize = McDimens.bodySmall)
query.isNotBlank() && results.isEmpty() ->
Text("Geen resultaten", color = McColors.TextDim, fontSize = McDimens.bodySmall)
query.isBlank() -> {
if (recent.isNotEmpty()) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
SectionLabel("Recent")
Text(
"Wissen",
color = Color(0xFF888888),
fontSize = McDimens.caption,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable { scope.launch { store.clearRecentSearches() } }
.padding(horizontal = 6.dp, vertical = 4.dp),
)
}
Spacer(Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(recent) { q ->
Text(
q,
color = Color.White,
fontSize = McDimens.bodySmall,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(McColors.BgElevated)
.clickable { runSearch(q) }
.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
} else {
EmptyState(
title = "Zoek in je bibliotheek",
subtitle = "Typ een titel om films en series te vinden.",
)
}
}
error != null -> Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
busy && results.isEmpty() -> {
Column(verticalArrangement = Arrangement.spacedBy(McDimens.gridGap)) {
repeat(3) {
Row(horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap)) {
repeat(4) {
SkeletonBox(
Modifier
.weight(1f)
.aspectRatio(2f / 3f),
)
}
}
}
}
}
!busy && results.isEmpty() -> EmptyState(
title = "Geen resultaten",
subtitle = "Probeer een andere zoekterm.",
)
else -> {
val movies = results.count { it.type == "movie" }
val series = results.size - movies
Text(
buildString {
if (movies > 0) append("$movies film${if (movies == 1) "" else "s"}")
if (movies > 0 && series > 0) append(" · ")
if (series > 0) append("$series serie${if (series == 1) "" else "s"}")
},
color = McColors.TextDim,
fontSize = McDimens.meta,
)
Spacer(Modifier.height(8.dp))
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = minCell),
state = gridState,
@ -160,3 +254,5 @@ fun SearchScreen(
}
}
}
}
}

View file

@ -0,0 +1,81 @@
package nl.vonas.mediacluster.tv.ui
import android.app.Activity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
/** Lichte status-/navigatie-iconen op donkere app-achtergrond. */
@Composable
fun ApplyDarkSystemBars() {
val view = LocalView.current
if (view.isInEditMode) return
SideEffect {
val window = (view.context as? Activity)?.window ?: return@SideEffect
val controller = WindowCompat.getInsetsController(window, view)
controller.isAppearanceLightStatusBars = false
controller.isAppearanceLightNavigationBars = false
}
}
/**
* Vaste glass/scrim achter de statusbalk zodat scrollende content
* niet onder klok/iconen doorloopt.
*/
@Composable
fun StatusBarGlassOverlay(modifier: Modifier = Modifier) {
Column(modifier.fillMaxWidth()) {
Box(
Modifier
.fillMaxWidth()
.windowInsetsTopHeight(WindowInsets.statusBars)
.background(Color(0xF20B0B0B)),
)
Spacer(
Modifier
.fillMaxWidth()
.height(10.dp)
.background(
Brush.verticalGradient(
listOf(Color(0xE60B0B0B), Color.Transparent),
),
),
)
}
}
/** Verberg status- en navigatiebalk tijdens fullscreen playback. */
@Composable
fun RememberPlayerImmersiveMode() {
val view = LocalView.current
if (view.isInEditMode) return
DisposableEffect(Unit) {
val window = (view.context as? Activity)?.window
val controller = window?.let { WindowCompat.getInsetsController(it, view) }
controller?.hide(WindowInsetsCompat.Type.systemBars())
controller?.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
onDispose {
controller?.show(WindowInsetsCompat.Type.systemBars())
controller?.isAppearanceLightStatusBars = false
controller?.isAppearanceLightNavigationBars = false
}
}
}

View file

@ -53,7 +53,7 @@ import nl.vonas.mediacluster.tv.data.EpgProgram
import nl.vonas.mediacluster.tv.data.IptvCategory
import nl.vonas.mediacluster.tv.data.IptvChannel
import nl.vonas.mediacluster.tv.data.IptvEpg
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.components.EmptyState
import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
import java.time.Instant
@ -134,12 +134,19 @@ fun TvScreen(
epgNowTitles.clear()
try {
val loaded = api.iptvChannels(catId, includeEpg = true)
val titles = api.fetchMissingNowTitles(loaded)
channels = loaded
titles.forEach { (id, title) -> epgNowTitles[id] = title }
loaded.forEach { ch ->
ch.nowTitle?.takeIf { it.isNotBlank() }?.let { epgNowTitles[ch.streamId] = it }
}
val focusId = lastStreamId?.takeIf { id -> loaded.any { it.streamId == id } }
?: loaded.firstOrNull()?.streamId
focusId?.let { loadEpg(it) }
// EPG voor hele categorie op de achtergrond (niet blokkeren op eerste paint)
scope.launch {
api.fetchMissingNowTitles(loaded) { batch ->
batch.forEach { (id, title) -> epgNowTitles[id] = title }
}
}
} catch (e: Exception) {
error = e.message
channels = emptyList()
@ -225,7 +232,7 @@ fun TvScreen(
Spacer(Modifier.height(10.dp))
Text(
"Tik om te selecteren · nogmaals om te kijken",
"Tik nogmaals om te kijken",
color = Color(0xFF777777),
fontSize = McDimens.caption,
)
@ -239,7 +246,10 @@ fun TvScreen(
}
channels.isEmpty() -> {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("Geen zenders", color = McColors.TextDim, fontSize = McDimens.bodySmall)
EmptyState(
title = "Geen zenders",
subtitle = "Deze categorie heeft geen kanalen.",
)
}
}
else -> {
@ -252,7 +262,8 @@ fun TvScreen(
items(channels, key = { it.streamId }) { ch ->
ChannelRow(
channel = ch,
nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle,
nowTitle = (epgNowTitles[ch.streamId] ?: ch.nowTitle)
?.takeUnless { it.equals("null", ignoreCase = true) },
isSelected = selectedStreamId == ch.streamId,
isLastWatched = ch.streamId == lastStreamId,
onClick = {
@ -440,7 +451,7 @@ private fun MobileEpgCard(
) {
if (epg == null) {
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
Text("Selecteer een zender", color = McColors.TextDim, fontSize = McDimens.bodySmall)
Text("Kies een zender", color = McColors.TextDim, fontSize = McDimens.bodySmall)
}
return
}
@ -469,7 +480,20 @@ private fun MobileEpgCard(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
McButton("▶ Kijk", primary = true, compact = true, onClick = onPlay)
Box(
Modifier
.clip(RoundedCornerShape(4.dp))
.background(Color.White)
.clickable(onClick = onPlay)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
"Afspelen",
color = Color.Black,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
)
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider))
@ -500,7 +524,7 @@ private fun MobileEpgCard(
}
if (epg.now == null && epg.next == null) {
Text("Geen programma-info", color = McColors.TextDim, fontSize = McDimens.meta)
Text("Geen programmagids", color = McColors.TextDim, fontSize = McDimens.meta)
}
}
}

View file

@ -1,9 +1,13 @@
package nl.vonas.mediacluster.tv.ui.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
@ -13,11 +17,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@ -41,15 +48,26 @@ fun McButton(
focusRequester: FocusRequester? = null,
onClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.96f else 1f,
animationSpec = tween(100),
label = "btnScale",
)
val bg = if (primary) Color.White else Color(0x33FFFFFF)
val fg = if (primary) Color.Black else Color.White
val hPad = if (compact) 12.dp else 16.dp
val vPad = if (compact) 8.dp else 10.dp
Box(
modifier = modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.clip(RoundedCornerShape(4.dp))
.background(bg)
.clickable(onClick = onClick)
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
.padding(horizontal = hPad, vertical = vPad),
contentAlignment = Alignment.Center,
) {
@ -98,12 +116,26 @@ fun PosterCard(
.aspectRatio(2f / 3f)
}
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.96f else 1f,
animationSpec = tween(120),
label = "posterScale",
)
Box(
modifier = modifier
.then(sizeModifier)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFF1A1A1A))
.combinedClickable(
interactionSource = interaction,
indication = null,
onClick = onClick,
onLongClick = onLongClick,
),
@ -115,6 +147,13 @@ fun PosterCard(
contentScale = ContentScale.Crop,
)
if (progress > 0.02f) {
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(3.dp)
.background(Color(0x66FFFFFF)),
)
Box(
Modifier
.align(Alignment.BottomStart)

View file

@ -0,0 +1,192 @@
package nl.vonas.mediacluster.tv.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import nl.vonas.mediacluster.tv.ui.theme.McColors
import nl.vonas.mediacluster.tv.ui.theme.McDimens
@Composable
private fun shimmerBrush(): Brush {
val transition = rememberInfiniteTransition(label = "shimmer")
val x by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(1100, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "shimmerX",
)
return Brush.linearGradient(
colors = listOf(
Color(0xFF1A1A1A),
Color(0xFF2A2A2A),
Color(0xFF1A1A1A),
),
start = Offset(x - 200f, 0f),
end = Offset(x, 200f),
)
}
@Composable
fun SkeletonBox(
modifier: Modifier = Modifier,
shape: RoundedCornerShape = RoundedCornerShape(4.dp),
) {
Box(modifier.clip(shape).background(shimmerBrush()))
}
@Composable
fun SkeletonPoster(
width: Dp,
height: Dp,
modifier: Modifier = Modifier,
) {
SkeletonBox(modifier.width(width).height(height))
}
@Composable
fun SkeletonShelfRow(
titleWidth: Dp = 120.dp,
posterCount: Int = 4,
) {
val posterW = McDimens.shelfPosterWidth()
val posterH = McDimens.posterHeight(posterW)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SkeletonBox(
Modifier
.padding(horizontal = McDimens.screenPad)
.width(titleWidth)
.height(14.dp),
shape = RoundedCornerShape(3.dp),
)
LazyRow(
contentPadding = PaddingValues(horizontal = McDimens.screenPad),
horizontalArrangement = Arrangement.spacedBy(McDimens.shelfGap),
) {
items(posterCount) {
SkeletonPoster(posterW, posterH)
}
}
}
}
@Composable
fun SkeletonBillboard(height: Dp = McDimens.billboardHeight()) {
Box(Modifier.fillMaxWidth().height(height)) {
SkeletonBox(Modifier.fillMaxSize(), shape = RoundedCornerShape(0.dp))
Column(
Modifier
.align(Alignment.BottomStart)
.padding(McDimens.screenPad),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SkeletonBox(Modifier.width(40.dp).height(10.dp))
SkeletonBox(Modifier.width(220.dp).height(22.dp))
SkeletonBox(Modifier.width(140.dp).height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SkeletonBox(Modifier.width(100.dp).height(34.dp), RoundedCornerShape(4.dp))
SkeletonBox(Modifier.width(88.dp).height(34.dp), RoundedCornerShape(4.dp))
}
}
}
}
@Composable
fun HomeSkeleton() {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SkeletonBillboard()
SkeletonShelfRow(140.dp)
SkeletonShelfRow(100.dp)
SkeletonShelfRow(160.dp)
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun EmptyState(
title: String,
subtitle: String? = null,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
Column(
modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
title,
color = Color.White,
fontSize = McDimens.body,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
color = McColors.TextDim,
fontSize = McDimens.bodySmall,
textAlign = TextAlign.Center,
lineHeight = 18.sp,
)
}
if (actionLabel != null && onAction != null) {
Spacer(Modifier.height(6.dp))
McButton(actionLabel, compact = true, onClick = onAction)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text(
text.uppercase(),
color = Color(0xFF777777),
fontSize = McDimens.caption,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
modifier = modifier,
)
}

View file

@ -0,0 +1,19 @@
package nl.vonas.mediacluster.tv.ui.profiles
data class ProfileAvatar(val id: String, val emoji: String, val label: String)
val PROFILE_AVATARS = listOf(
ProfileAvatar("fox", "🦊", "Vos"),
ProfileAvatar("panda", "🐼", "Panda"),
ProfileAvatar("robot", "🤖", "Robot"),
ProfileAvatar("alien", "👽", "Alien"),
ProfileAvatar("ninja", "🥷", "Ninja"),
ProfileAvatar("pirate", "🏴‍☠️", "Piraat"),
ProfileAvatar("wizard", "🧙", "Tovenaar"),
ProfileAvatar("dino", "🦕", "Dino"),
ProfileAvatar("cat", "🐱", "Kat"),
ProfileAvatar("rocket", "🚀", "Raket"),
)
fun avatarEmoji(avatarId: String): String =
PROFILE_AVATARS.firstOrNull { it.id == avatarId }?.emoji ?: "🙂"

View file

@ -0,0 +1,487 @@
package nl.vonas.mediacluster.tv.ui.profiles
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.data.ViewerProfile
import nl.vonas.mediacluster.tv.ui.theme.McColors
private enum class ProfilesMode { Pick, Pin, Create, DeleteConfirm }
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun ProfilesScreen(
api: ApiClient,
store: SessionStore,
switcherMode: Boolean = true,
onProfileSelected: () -> Unit,
) {
val scope = rememberCoroutineScope()
var profiles by remember { mutableStateOf<List<ViewerProfile>>(emptyList()) }
var canManage by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var mode by remember { mutableStateOf(ProfilesMode.Pick) }
var pending by remember { mutableStateOf<ViewerProfile?>(null) }
var pin by remember { mutableStateOf("") }
var createName by remember { mutableStateOf("") }
var createAvatar by remember { mutableStateOf("fox") }
var createKids by remember { mutableStateOf(false) }
var createPin by remember { mutableStateOf("") }
val firstFocus = remember { FocusRequester() }
fun reload() {
scope.launch {
loading = true
error = null
runCatching { api.listProfiles() }
.onSuccess {
profiles = it.profiles
canManage = it.canManage
}
.onFailure { error = it.message ?: "Laden mislukt" }
loading = false
}
}
LaunchedEffect(switcherMode) {
if (switcherMode) {
runCatching { api.clearProfile() }
store.setProfile(null)
}
reload()
runCatching { firstFocus.requestFocus() }
}
fun select(profile: ViewerProfile, pinValue: String? = null) {
scope.launch {
error = null
runCatching { api.selectProfile(profile.id, pinValue) }
.onSuccess { selected ->
store.setProfile(selected.id, selected.name, selected.avatarId)
onProfileSelected()
}
.onFailure {
error = it.message ?: "Selecteren mislukt"
if (profile.hasPin) {
mode = ProfilesMode.Pin
pending = profile
pin = ""
}
}
}
}
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0xFF0B0B0F), Color(0xFF14141C), Color(0xFF0B0B0F)),
),
),
) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 48.dp, vertical = 36.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
if (switcherMode) "Wie kijkt er?" else "Profielen beheren",
color = Color.White,
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(8.dp))
Text(
when {
!switcherMode && canManage -> "Voeg toe of verwijder (lang drukken)"
switcherMode -> "Kies je profiel"
else -> "Alleen het hoofdprofiel mag beheren"
},
color = Color(0xFFAAAAAA),
fontSize = 16.sp,
)
Spacer(Modifier.height(28.dp))
when (mode) {
ProfilesMode.Pick -> {
if (loading) {
Text("Profielen laden…", color = Color.White)
} else {
LazyRow(
contentPadding = PaddingValues(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
items(profiles, key = { it.id }) { p ->
ProfileTile(
profile = p,
modifier = if (profiles.firstOrNull()?.id == p.id) {
Modifier.focusRequester(firstFocus)
} else Modifier,
onClick = {
if (p.hasPin) {
pending = p
pin = ""
mode = ProfilesMode.Pin
} else {
select(p)
}
},
onLongDelete = if (canManage && !p.isOwner) {
{
pending = p
mode = ProfilesMode.DeleteConfirm
}
} else null,
)
}
if (canManage && profiles.size < 8) {
item {
AddProfileTile(onClick = {
createName = ""
createAvatar = "fox"
createKids = false
createPin = ""
mode = ProfilesMode.Create
})
}
}
}
if (canManage) {
Spacer(Modifier.height(20.dp))
Text(
"Houd OK lang ingedrukt op een profiel om te verwijderen",
color = Color(0xFF666666),
fontSize = 13.sp,
)
}
}
}
ProfilesMode.Pin -> {
val target = pending
Text(
"PIN voor ${target?.name ?: "profiel"}",
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = pin,
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) },
label = { androidx.compose.material3.Text("4-cijferige PIN") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = McColors.Brand,
unfocusedBorderColor = Color(0xFF555555),
focusedLabelColor = McColors.Brand,
unfocusedLabelColor = Color(0xFF888888),
cursorColor = McColors.Brand,
),
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip("Terug") {
mode = ProfilesMode.Pick
pending = null
pin = ""
}
ActionChip("Ontgrendel", primary = true) {
if (target != null) select(target, pin)
}
}
}
ProfilesMode.Create -> {
Text("Nieuw profiel", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = createName,
onValueChange = { createName = it.take(32) },
label = { androidx.compose.material3.Text("Naam") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = McColors.Brand,
unfocusedBorderColor = Color(0xFF555555),
focusedLabelColor = McColors.Brand,
unfocusedLabelColor = Color(0xFF888888),
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)
}
}
}
}
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip(if (createKids) "Kids: aan" else "Kids: uit") {
createKids = !createKids
}
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = createPin,
onValueChange = { v -> createPin = v.filter { it.isDigit() }.take(4) },
label = { androidx.compose.material3.Text("PIN (optioneel)") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = McColors.Brand,
unfocusedBorderColor = Color(0xFF555555),
focusedLabelColor = McColors.Brand,
unfocusedLabelColor = Color(0xFF888888),
cursorColor = McColors.Brand,
),
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip("Annuleer") { mode = ProfilesMode.Pick }
ActionChip("Aanmaken", primary = true) {
scope.launch {
error = null
runCatching {
api.createProfile(
name = createName.trim(),
avatarId = createAvatar,
isKids = createKids,
pin = createPin.ifBlank { null },
)
}.onSuccess {
mode = ProfilesMode.Pick
reload()
}.onFailure {
error = it.message ?: "Aanmaken mislukt"
}
}
}
}
}
ProfilesMode.DeleteConfirm -> {
val target = pending
Text(
"Verwijder ${target?.name ?: "profiel"}?",
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp))
Text(
"Kijkgeschiedenis van dit profiel gaat verloren.",
color = Color(0xFFAAAAAA),
fontSize = 14.sp,
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip("Annuleer") {
mode = ProfilesMode.Pick
pending = null
}
ActionChip("Verwijderen", primary = true) {
val id = target?.id ?: return@ActionChip
scope.launch {
runCatching { api.deleteProfile(id) }
.onSuccess {
mode = ProfilesMode.Pick
pending = null
reload()
}
.onFailure { error = it.message ?: "Verwijderen mislukt" }
}
}
}
}
}
error?.let {
Spacer(Modifier.height(16.dp))
Text(it, color = Color(0xFFFF6B6B), fontSize = 14.sp)
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun ProfileTile(
profile: ViewerProfile,
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongDelete: (() -> Unit)? = null,
) {
Surface(
onClick = onClick,
onLongClick = onLongDelete,
modifier = modifier.width(140.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1C1C24),
focusedContainerColor = Color(0xFF2A2A36),
),
) {
Column(
Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
Modifier
.size(88.dp)
.clip(CircleShape)
.background(Color(0xFF2E2E3A)),
contentAlignment = Alignment.Center,
) {
Text(avatarEmoji(profile.avatarId), fontSize = 40.sp)
}
Spacer(Modifier.height(12.dp))
Text(
profile.name,
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
val badge = when {
profile.isKids -> "Kids"
profile.isOwner -> "Hoofd"
profile.hasPin -> "PIN"
else -> null
}
if (badge != null) {
Spacer(Modifier.height(4.dp))
Text(badge, color = Color(0xFF999999), fontSize = 12.sp)
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun AddProfileTile(onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = Modifier.width(140.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF16161E),
focusedContainerColor = Color(0xFF242430),
),
) {
Column(
Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
Modifier
.size(88.dp)
.clip(CircleShape)
.background(Color(0xFF2A2A32)),
contentAlignment = Alignment.Center,
) {
Text("+", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.Light)
}
Spacer(Modifier.height(12.dp))
Text("Toevoegen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun ActionChip(label: String, primary: Boolean = false, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (primary) McColors.Brand else Color(0xFF2A2A32),
focusedContainerColor = if (primary) McColors.Brand else Color(0xFF3A3A48),
contentColor = Color.White,
focusedContentColor = Color.White,
),
) {
Text(
label,
Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
color = Color.Unspecified,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
)
}
}

View file

@ -16,18 +16,19 @@ object McDimens {
val screenPad: Dp = 16.dp
val screenPadTight: Dp = 12.dp
val shelfGap: Dp = 8.dp
val shelfBlockGap: Dp = 14.dp
val gridGap: Dp = 8.dp
val titleHero: TextUnit = 26.sp
val titleScreen: TextUnit = 20.sp
val titleHero: TextUnit = 28.sp
val titleScreen: TextUnit = 22.sp
val titleShelf: TextUnit = 15.sp
val body: TextUnit = 14.sp
val bodySmall: TextUnit = 13.sp
val meta: TextUnit = 12.sp
val caption: TextUnit = 11.sp
val detailPosterW: Dp = 110.dp
val detailPosterH: Dp = 165.dp
val detailPosterW: Dp = 118.dp
val detailPosterH: Dp = 177.dp
val episodeStillW: Dp = 112.dp
val episodeStillH: Dp = 63.dp
@ -71,7 +72,7 @@ object McDimens {
fun billboardHeight(): Dp {
val h = screenHeight()
return remember(h) {
(h * 0.36f).coerceIn(200.dp, 280.dp)
(h * 0.42f).coerceIn(220.dp, 320.dp)
}
}
}

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme
import nl.vonas.mediacluster.tv.ui.ApplyDarkSystemBars
private val Scheme = darkColorScheme(
primary = McColors.Accent,
@ -16,5 +17,8 @@ private val Scheme = darkColorScheme(
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun MediaClusterTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = Scheme, content = content)
MaterialTheme(colorScheme = Scheme) {
ApplyDarkSystemBars()
content()
}
}

View file

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

View file

@ -196,6 +196,60 @@ class ApiClient(
Unit
}
suspend fun listProfiles(): ProfilesPayload = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/profiles").get().build()
parseObject(execute(req)) { o ->
ProfilesPayload(
profiles = o.optJSONArray("profiles").toProfiles(),
activeProfileId = o.optString("activeProfileId").takeIf { it.isNotBlank() && it != "null" },
canManage = o.optBoolean("canManage", false),
)
}
}
suspend fun selectProfile(profileId: String, pin: String? = null): ViewerProfile =
withContext(Dispatchers.IO) {
val body = JSONObject()
if (!pin.isNullOrBlank()) body.put("pin", pin)
val req = authed("$baseUrl/api/v1/client/profiles/$profileId/select")
.post(body.toString().toRequestBody(jsonMedia))
.build()
parseObject(execute(req)) { o ->
o.getJSONObject("profile").toProfile()
}
}
suspend fun clearProfile() = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/profiles/clear")
.post("{}".toRequestBody(jsonMedia))
.build()
execute(req)
Unit
}
suspend fun createProfile(
name: String,
avatarId: String,
isKids: Boolean,
pin: String?,
): ViewerProfile = withContext(Dispatchers.IO) {
val body = JSONObject()
.put("name", name)
.put("avatarId", avatarId)
.put("isKids", isKids)
if (!pin.isNullOrBlank()) body.put("pin", pin)
val req = authed("$baseUrl/api/v1/client/profiles")
.post(body.toString().toRequestBody(jsonMedia))
.build()
parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() }
}
suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build()
execute(req)
Unit
}
suspend fun searchSubtitles(mediaFileId: String, languages: String = "nl,en"): SubtitleSearchResult =
withContext(Dispatchers.IO) {
val id = java.net.URLEncoder.encode(mediaFileId, "UTF-8")
@ -263,6 +317,7 @@ class ApiClient(
parseObject(execute(req)) { o ->
ClientMe(
iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true,
profile = o.optJSONObject("profile")?.toProfile(),
)
}
}
@ -306,6 +361,7 @@ class ApiClient(
number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") },
categoryId = c.optString("categoryId").ifBlank { null },
nowTitle = c.optString("nowTitle").ifBlank { null },
nextTitle = c.optString("nextTitle").ifBlank { null },
)
)
}
@ -449,6 +505,7 @@ data class MediaItem(
val year: Int? = null,
val posterUrl: String? = null,
val backdropUrl: String? = null,
val logoUrl: String? = null,
val mediaFileId: String? = null,
val seriesId: String? = null,
val positionMs: Long = 0,
@ -500,6 +557,22 @@ data class ShelfPref(
data class ClientMe(
val iptvEnabled: Boolean,
val profile: ViewerProfile? = null,
)
data class ViewerProfile(
val id: String,
val name: String,
val avatarId: String,
val isOwner: Boolean,
val isKids: Boolean,
val hasPin: Boolean,
)
data class ProfilesPayload(
val profiles: List<ViewerProfile>,
val activeProfileId: String?,
val canManage: Boolean,
)
data class IptvCategory(
@ -515,6 +588,7 @@ data class IptvChannel(
val number: Int? = null,
val categoryId: String? = null,
val nowTitle: String? = null,
val nextTitle: String? = null,
)
data class IptvPlayInfo(
@ -555,6 +629,7 @@ private fun JSONArray?.toItems(): List<MediaItem> {
year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") },
posterUrl = o.optString("posterUrl").ifBlank { null },
backdropUrl = o.optString("backdropUrl").ifBlank { null },
logoUrl = o.optString("logoUrl").ifBlank { null },
mediaFileId = if (o.isNull("mediaFileId")) null else o.optString("mediaFileId").ifBlank { null },
seriesId = if (o.isNull("seriesId")) null else o.optString("seriesId").ifBlank { null },
positionMs = o.optLong("positionMs"),
@ -585,3 +660,21 @@ private fun JSONArray?.toShelves(): List<ShelfRow> {
}
}
}
private fun JSONArray?.toProfiles(): List<ViewerProfile> {
if (this == null) return emptyList()
return buildList {
for (i in 0 until length()) {
add(getJSONObject(i).toProfile())
}
}
}
private fun JSONObject.toProfile(): ViewerProfile = ViewerProfile(
id = getString("id"),
name = optString("name"),
avatarId = optString("avatarId").ifBlank { "fox" },
isOwner = optBoolean("isOwner"),
isKids = optBoolean("isKids"),
hasPin = optBoolean("hasPin"),
)

View file

@ -0,0 +1,100 @@
package nl.vonas.mediacluster.tv.data
import org.json.JSONArray
import org.json.JSONObject
fun HomePayload.toCacheJson(): String {
fun MediaItem.toObj() = JSONObject().apply {
put("type", type)
put("id", id)
put("title", title)
put("subtitle", subtitle)
put("year", year)
put("posterUrl", posterUrl)
put("backdropUrl", backdropUrl)
put("logoUrl", logoUrl)
put("mediaFileId", mediaFileId)
put("seriesId", seriesId)
put("positionMs", positionMs)
put("durationMs", durationMs)
put("progressId", progressId)
put("badge", badge)
}
fun List<MediaItem>.toArr() = JSONArray().also { arr ->
forEach { arr.put(it.toObj()) }
}
return JSONObject().apply {
put("continueWatching", continueWatching.toArr())
put("spotlight", spotlight.toArr())
put(
"shelves",
JSONArray().also { arr ->
shelves.forEach { s ->
arr.put(
JSONObject().apply {
put("id", s.id)
put("title", s.title)
put("name", s.name)
put("kind", s.kind)
put("slug", s.slug)
put("items", s.items.toArr())
},
)
}
},
)
}.toString()
}
fun parseHomeCacheJson(raw: String): HomePayload? = runCatching {
fun JSONObject.toItem() = MediaItem(
type = optString("type"),
id = optString("id"),
title = optString("title"),
subtitle = optString("subtitle").ifBlank { null },
year = optInt("year").takeIf { has("year") && !isNull("year") && optInt("year") != 0 },
posterUrl = optString("posterUrl").ifBlank { null },
backdropUrl = optString("backdropUrl").ifBlank { null },
logoUrl = optString("logoUrl").ifBlank { null },
mediaFileId = optString("mediaFileId").ifBlank { null },
seriesId = optString("seriesId").ifBlank { null },
positionMs = optLong("positionMs"),
durationMs = optLong("durationMs"),
progressId = optString("progressId").takeIf { it.isNotBlank() && it != "null" },
badge = optString("badge").takeIf { it.isNotBlank() && it != "null" },
)
fun JSONArray?.toItems(): List<MediaItem> {
if (this == null) return emptyList()
return buildList {
for (i in 0 until length()) add(getJSONObject(i).toItem())
}
}
val o = JSONObject(raw)
val shelvesArr = o.optJSONArray("shelves")
val shelves = buildList {
if (shelvesArr != null) {
for (i in 0 until shelvesArr.length()) {
val s = shelvesArr.getJSONObject(i)
add(
ShelfRow(
id = s.optString("id"),
title = s.optString("title"),
name = s.optString("name").ifBlank { s.optString("title") },
kind = s.optString("kind"),
slug = s.optString("slug"),
items = s.optJSONArray("items").toItems(),
),
)
}
}
}
HomePayload(
continueWatching = o.optJSONArray("continueWatching").toItems(),
spotlight = o.optJSONArray("spotlight").toItems(),
shelves = shelves,
)
}.getOrNull()

View file

@ -2,30 +2,159 @@ package nl.vonas.mediacluster.tv.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import nl.vonas.mediacluster.tv.BuildConfig
import org.json.JSONArray
private val Context.dataStore by preferencesDataStore("mc_tv")
class SessionStore(private val context: Context) {
private val tokenKey = stringPreferencesKey("refresh_token")
private val apiKey = stringPreferencesKey("api_base")
private val profileIdKey = stringPreferencesKey("profile_id")
private val profileNameKey = stringPreferencesKey("profile_name")
private val profileAvatarKey = stringPreferencesKey("profile_avatar")
private val recentSearchKey = stringPreferencesKey("recent_searches")
private val homeCacheKey = stringPreferencesKey("home_cache_json")
private val tvFavoritesKey = stringPreferencesKey("tv_favorites")
private val tvLastCategoryKey = stringPreferencesKey("tv_last_category")
private val tvLastStreamKey = intPreferencesKey("tv_last_stream")
val refreshToken: Flow<String?> = context.dataStore.data.map { it[tokenKey] }
val apiBase: Flow<String> = context.dataStore.data.map {
it[apiKey] ?: BuildConfig.DEFAULT_API_BASE
}
val profileId: Flow<String?> = context.dataStore.data.map { it[profileIdKey] }
val profileName: Flow<String?> = context.dataStore.data.map { it[profileNameKey] }
val profileAvatarId: Flow<String?> = context.dataStore.data.map { it[profileAvatarKey] }
val recentSearches: Flow<List<String>> = context.dataStore.data.map { prefs ->
val raw = prefs[recentSearchKey].orEmpty()
if (raw.isBlank()) emptyList()
else runCatching {
val arr = JSONArray(raw)
buildList {
for (i in 0 until arr.length()) {
val q = arr.optString(i).trim()
if (q.isNotBlank()) add(q)
}
}
}.getOrDefault(emptyList())
}
val homeCacheJson: Flow<String?> = context.dataStore.data.map { it[homeCacheKey] }
val favoriteStreamIds: Flow<Set<Int>> = context.dataStore.data.map { prefs ->
parseIntSet(prefs[tvFavoritesKey])
}
val tvLastCategoryId: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[tvLastCategoryKey]?.takeIf { it.isNotBlank() }
}
val tvLastStreamId: Flow<Int?> = context.dataStore.data.map { prefs ->
prefs[tvLastStreamKey]
}
suspend fun setToken(token: String?) {
context.dataStore.edit { prefs ->
if (token.isNullOrBlank()) prefs.remove(tokenKey) else prefs[tokenKey] = token
if (token.isNullOrBlank()) {
prefs.remove(tokenKey)
prefs.remove(profileIdKey)
prefs.remove(profileNameKey)
prefs.remove(profileAvatarKey)
} else {
prefs[tokenKey] = token
}
}
}
suspend fun setProfile(id: String?, name: String? = null, avatarId: String? = null) {
context.dataStore.edit { prefs ->
if (id.isNullOrBlank()) {
prefs.remove(profileIdKey)
prefs.remove(profileNameKey)
prefs.remove(profileAvatarKey)
} else {
prefs[profileIdKey] = id
if (!name.isNullOrBlank()) prefs[profileNameKey] = name else prefs.remove(profileNameKey)
if (!avatarId.isNullOrBlank()) prefs[profileAvatarKey] = avatarId
else prefs.remove(profileAvatarKey)
}
}
}
suspend fun setApiBase(url: String) {
context.dataStore.edit { it[apiKey] = url.trim().trimEnd('/') }
}
suspend fun saveHomeCache(json: String) {
context.dataStore.edit { it[homeCacheKey] = json }
}
suspend fun addRecentSearch(query: String) {
val q = query.trim()
if (q.length < 2) return
context.dataStore.edit { prefs ->
val existing = prefs[recentSearchKey].orEmpty().let { raw ->
if (raw.isBlank()) mutableListOf()
else runCatching {
val arr = JSONArray(raw)
MutableList(arr.length()) { i -> arr.optString(i) }
.filter { it.isNotBlank() }
.toMutableList()
}.getOrElse { mutableListOf() }
}
existing.removeAll { it.equals(q, ignoreCase = true) }
existing.add(0, q)
while (existing.size > 8) existing.removeAt(existing.lastIndex)
prefs[recentSearchKey] = JSONArray(existing).toString()
}
}
suspend fun clearRecentSearches() {
context.dataStore.edit { it.remove(recentSearchKey) }
}
suspend fun toggleFavorite(streamId: Int): Boolean {
var nowFavorite = false
context.dataStore.edit { prefs ->
val set = parseIntSet(prefs[tvFavoritesKey]).toMutableSet()
if (set.contains(streamId)) {
set.remove(streamId)
nowFavorite = false
} else {
set.add(streamId)
nowFavorite = true
}
prefs[tvFavoritesKey] = JSONArray(set.sorted()).toString()
}
return nowFavorite
}
suspend fun setLastTv(categoryId: String?, streamId: Int?) {
context.dataStore.edit { prefs ->
if (categoryId.isNullOrBlank()) prefs.remove(tvLastCategoryKey)
else prefs[tvLastCategoryKey] = categoryId
if (streamId == null) prefs.remove(tvLastStreamKey)
else prefs[tvLastStreamKey] = streamId
}
}
private fun parseIntSet(raw: String?): Set<Int> {
if (raw.isNullOrBlank()) return emptySet()
return runCatching {
val arr = JSONArray(raw)
buildSet {
for (i in 0 until arr.length()) {
val v = arr.optInt(i, Int.MIN_VALUE)
if (v != Int.MIN_VALUE) add(v)
}
}
}.getOrDefault(emptySet())
}
}

View file

@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.MediaItem
import nl.vonas.mediacluster.tv.ui.components.EmptyState
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.components.PosterCard
import nl.vonas.mediacluster.tv.ui.theme.McColors
@ -138,7 +139,11 @@ fun BrowseScreen(
Column {
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Text(
if (total > 0) "$total titels" else if (loading) "Laden…" else "Geen resultaten",
when {
total > 0 -> "$total titels"
loading -> "Laden…"
else -> "Geen titels"
},
color = McColors.TextDim,
fontSize = 14.sp,
)
@ -151,6 +156,19 @@ fun BrowseScreen(
if (error != null) {
Text(error!!, color = McColors.Danger)
}
when {
loading && items.isEmpty() -> {
EmptyState(title = "Laden…", subtitle = "Even geduld.")
}
!loading && items.isEmpty() && error == null -> {
EmptyState(
title = "Geen titels gevonden",
subtitle = "Deze collectie is leeg of nog niet gesynchroniseerd.",
actionLabel = "Opnieuw proberen",
onAction = { scope.launch { load(reset = true) } },
)
}
else -> {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 140.dp),
state = gridState,
@ -162,8 +180,7 @@ fun BrowseScreen(
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard(
item = item,
width = 140.dp,
height = 210.dp,
fillWidth = true,
modifier = Modifier.padding(6.dp),
onClick = {
val canResume = onPlayContinue != null &&
@ -191,3 +208,5 @@ fun BrowseScreen(
}
}
}
}
}

View file

@ -382,36 +382,9 @@ fun MovieDetailScreen(
Spacer(Modifier.height(4.dp))
DetailLogoOrTitle(logoUrl, title)
MetaChips(metaParts)
if (versions.size > 1) {
SelectedQualityLine(selected)
if (director.isNotEmpty()) {
Text(
"Regie: ${director.take(2).joinToString(", ")}",
color = Color(0xFF9A9A9A),
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (overview.isNotBlank()) {
Text(
overview,
color = McColors.TextDim,
fontSize = 15.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
lineHeight = 21.sp,
)
}
if (cast.isNotEmpty()) {
Text(
"Met: ${cast.take(4).joinToString(", ")}",
color = Color(0xFF8A8A8A),
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (error != null) Text(error!!, color = McColors.Danger)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
selected?.let { ver ->
@ -424,13 +397,42 @@ fun MovieDetailScreen(
openYoutubeTrailer(context, trailerKey!!)
}
}
DetailActionButton("Als gekeken") {
DetailActionButton("Markeer als gezien") {
scope.launch {
runCatching { api.markWatched(movieId = movieId) }
onBack()
}
}
}
if (overview.isNotBlank()) {
Text(
overview,
color = McColors.TextDim,
fontSize = 15.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
lineHeight = 21.sp,
)
}
if (director.isNotEmpty()) {
Text(
"Regie · ${director.take(2).joinToString(", ")}",
color = Color(0xFF9A9A9A),
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (cast.isNotEmpty()) {
Text(
"Met · ${cast.take(4).joinToString(", ")}",
color = Color(0xFF8A8A8A),
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (error != null) Text(error!!, color = McColors.Danger)
if (versions.size > 1) {
Spacer(Modifier.height(2.dp))
Text(

View file

@ -120,6 +120,8 @@ fun LiveTvPlayerScreen(
var menuCategoryName by remember { mutableStateOf("Zenders") }
var menuRefreshing by remember { mutableStateOf(false) }
var lastCategoryShiftAt by remember { mutableLongStateOf(0L) }
var lastZapAt by remember { mutableLongStateOf(0L) }
var zapChannels by remember { mutableStateOf<List<IptvChannel>>(emptyList()) }
var menuLoadGen by remember { mutableIntStateOf(0) }
val menuCache = remember { mutableStateMapOf<String, MenuCategoryCache>() }
val menuEpgNow = remember { mutableStateMapOf<Int, String>() }
@ -203,6 +205,23 @@ fun LiveTvPlayerScreen(
fun cacheKey(catId: String?) = catId?.takeIf { it.isNotBlank() } ?: "__all__"
// Vooraf laden voor snel zappen zonder menu
LaunchedEffect(categoryId) {
runCatching {
val loaded = if (!categoryId.isNullOrBlank()) {
api.iptvChannels(categoryId, includeEpg = false)
} else {
api.iptvChannels(includeEpg = false)
}
zapChannels = loaded
menuChannels = loaded
val key = cacheKey(categoryId)
if (!menuCache.containsKey(key)) {
menuCache[key] = MenuCategoryCache(loaded, emptyMap(), System.currentTimeMillis())
}
}
}
suspend fun loadMenuEpgInBackground(catId: String?, gen: Int) {
val key = cacheKey(catId)
try {
@ -269,6 +288,7 @@ fun LiveTvPlayerScreen(
}
if (gen != menuLoadGen) return@LaunchedEffect
menuChannels = loaded
zapChannels = loaded
val staleTitles = cached?.epgTitles.orEmpty()
if (staleTitles.isNotEmpty()) applyMenuEpg(staleTitles)
menuCache[key] = MenuCategoryCache(loaded, staleTitles, System.currentTimeMillis())
@ -352,6 +372,20 @@ fun LiveTvPlayerScreen(
infoOverlay = true
}
fun zapChannel(delta: Int) {
val now = System.currentTimeMillis()
if (now - lastZapAt < 280) return
lastZapAt = now
val list = when {
zapChannels.isNotEmpty() -> zapChannels
menuChannels.isNotEmpty() -> menuChannels
else -> return
}
val idx = list.indexOfFirst { it.streamId == currentStreamId }.let { if (it < 0) 0 else it }
val next = list[(idx + delta).mod(list.size)]
selectChannel(next.streamId)
}
Box(
Modifier
.fillMaxSize()
@ -367,6 +401,18 @@ fun LiveTvPlayerScreen(
true
}
Key.DirectionUp -> {
zapChannel(-1)
true
}
Key.DirectionDown -> {
zapChannel(1)
true
}
Key.DirectionRight -> {
zapChannel(1)
true
}
Key.DirectionLeft, Key.Menu, Key.Guide -> {
channelMenuOpen = true
true
}
@ -479,6 +525,12 @@ fun LiveTvPlayerScreen(
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(8.dp))
Text(
"↑↓ zappen · ← zenderlijst · OK info",
color = Color(0xFF888888),
fontSize = 12.sp,
)
}
}
}

View file

@ -0,0 +1,21 @@
package nl.vonas.mediacluster.tv.ui
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
object McTransitions {
private const val Duration = 240
val enter =
fadeIn(tween(Duration)) + slideInHorizontally(tween(Duration)) { it / 16 }
val exit = fadeOut(tween(180))
val popEnter = fadeIn(tween(Duration))
val popExit =
fadeOut(tween(180)) + slideOutHorizontally(tween(Duration)) { it / 16 }
}

View file

@ -324,16 +324,37 @@ fun PlayerScreen(
false
}
}
Key.DirectionDown -> {
Key.MediaPlayPause -> {
if (menu != null) return@onPreviewKeyEvent false
if (!controls) {
togglePlay()
true
}
Key.MediaPlay -> {
if (menu != null) return@onPreviewKeyEvent false
player.play()
showControls()
true
} else {
false
}
Key.MediaPause -> {
if (menu != null) return@onPreviewKeyEvent false
player.pause()
showControls()
true
}
Key.MediaRewind -> {
if (menu != null) return@onPreviewKeyEvent false
player.seekBack()
showControls()
true
}
Key.MediaFastForward -> {
if (menu != null) return@onPreviewKeyEvent false
player.seekForward()
showControls()
true
}
Key.DirectionLeft -> {
// Alleen seeken zonder controls; mét controls = navigatie in menu
if (menu != null || controls) return@onPreviewKeyEvent false
player.seekBack()
showControls()
@ -345,6 +366,15 @@ fun PlayerScreen(
showControls()
true
}
Key.DirectionDown -> {
if (menu != null) return@onPreviewKeyEvent false
if (!controls) {
showControls()
true
} else {
false
}
}
Key.DirectionUp, Key.Menu -> {
if (menu != null) return@onPreviewKeyEvent false
showControls()
@ -383,7 +413,7 @@ fun PlayerScreen(
}
if (loading && error == null) {
Text("Laden…", color = Color(0xFFB0B8C8), fontSize = 18.sp, modifier = Modifier.align(Alignment.Center))
Text("Bezig met laden…", color = Color(0xFFB0B8C8), fontSize = 18.sp, modifier = Modifier.align(Alignment.Center))
}
if (error != null) {
Column(

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -29,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -45,7 +47,11 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.MediaItem
import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.ui.components.EmptyState
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.components.PosterCard
import nl.vonas.mediacluster.tv.ui.components.SectionLabel
import nl.vonas.mediacluster.tv.ui.theme.McColors
private val KEYBOARD_CHARS = listOf(
@ -65,6 +71,9 @@ fun SearchScreen(
onOpenSeries: (String) -> Unit,
onAtTopBack: (() -> Unit)? = null,
) {
val context = LocalContext.current
val store = remember { SessionStore(context) }
val recent by store.recentSearches.collectAsState(initial = emptyList())
var query by remember { mutableStateOf("") }
var results by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
var busy by remember { mutableStateOf(false) }
@ -85,21 +94,25 @@ fun SearchScreen(
}
}
fun scheduleSearch(next: String) {
fun scheduleSearch(next: String, saveRecent: Boolean = true) {
query = next
searchJob?.cancel()
searchJob = scope.launch {
delay(220)
if (next.isBlank()) {
results = emptyList()
busy = false
return@launch
}
busy = true
error = null
try {
results = api.search(next)
if (saveRecent && next.trim().length >= 2) {
store.addRecentSearch(next)
}
} catch (e: Exception) {
error = e.message
error = e.message ?: "Zoeken mislukt"
} finally {
busy = false
}
@ -214,9 +227,17 @@ fun SearchScreen(
when {
busy -> "Zoeken…"
error != null -> "Fout bij zoeken"
query.isBlank() -> "Resultaten"
results.isEmpty() -> "Niets gevonden"
else -> "${results.size} resultaten"
query.isBlank() -> if (recent.isNotEmpty()) "Recent" else "Resultaten"
results.isEmpty() -> "Geen resultaten"
else -> {
val movies = results.count { it.type == "movie" }
val series = results.size - movies
buildString {
if (movies > 0) append("$movies film${if (movies == 1) "" else "s"}")
if (movies > 0 && series > 0) append(" · ")
if (series > 0) append("$series serie${if (series == 1) "" else "s"}")
}
}
},
color = if (error != null) McColors.Danger else McColors.TextDim,
fontSize = 14.sp,
@ -245,18 +266,35 @@ fun SearchScreen(
}
Spacer(Modifier.height(10.dp))
if (query.isBlank() && results.isEmpty()) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
when {
query.isBlank() && recent.isNotEmpty() -> {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Begin links met typen",
color = Color(0xFF5A5A5A),
fontSize = 15.sp,
SectionLabel("Recent gezocht")
McButton("Wissen") { scope.launch { store.clearRecentSearches() } }
}
recent.forEach { q ->
McButton(q) { scheduleSearch(q) }
}
}
}
query.isBlank() -> {
EmptyState(
title = "Zoek in je bibliotheek",
subtitle = "Typ links een titel om films en series te vinden.",
)
}
} else {
!busy && results.isEmpty() && error == null -> {
EmptyState(
title = "Geen resultaten",
subtitle = "Probeer een andere zoekterm.",
)
}
else -> {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 110.dp),
state = gridState,
@ -268,8 +306,7 @@ fun SearchScreen(
items(results, key = { "${it.type}:${it.id}" }) { item ->
PosterCard(
item = item,
width = 110.dp,
height = 165.dp,
fillWidth = true,
modifier = Modifier.padding(6.dp),
onClick = {
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
@ -281,6 +318,7 @@ fun SearchScreen(
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable

View file

@ -54,6 +54,7 @@ import nl.vonas.mediacluster.tv.data.EpgProgram
import nl.vonas.mediacluster.tv.data.IptvCategory
import nl.vonas.mediacluster.tv.data.IptvChannel
import nl.vonas.mediacluster.tv.data.IptvEpg
import nl.vonas.mediacluster.tv.ui.components.EmptyState
import nl.vonas.mediacluster.tv.ui.theme.McColors
import java.time.Instant
import java.time.ZoneId
@ -65,6 +66,8 @@ private val TvRowActive = Color(0xFF1C1C1C)
private val TvDivider = Color(0xFF2A2A2A)
private val TvPanelBg = Color(0xFF101010)
const val TV_FAV_CATEGORY_ID = "__fav__"
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun TvScreen(
@ -73,6 +76,8 @@ fun TvScreen(
onCategoryChange: (String?) -> Unit,
lastStreamId: Int? = null,
onLastStreamChange: (Int?) -> Unit = {},
favoriteIds: Set<Int> = emptySet(),
onToggleFavorite: (Int) -> Unit = {},
focusContentNonce: Int = 0,
onAtTopBack: () -> Unit,
onPlayChannel: (streamId: Int) -> Unit,
@ -84,15 +89,43 @@ fun TvScreen(
var loadingChannels by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
var epgLoading by remember { mutableStateOf(false) }
var focusedStreamId by remember { mutableStateOf<Int?>(null) }
var epgJob by remember { mutableStateOf<Job?>(null) }
var focusInList by remember { mutableStateOf(false) }
val channelCache = remember { mutableStateMapOf<String, List<IptvChannel>>() }
val channelListState = rememberLazyListState()
val categoryRowState = rememberLazyListState()
val scope = rememberCoroutineScope()
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
val channelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
val epgNowTitles = remember { mutableStateMapOf<Int, String>() }
val epgNextTitles = remember { mutableStateMapOf<Int, String>() }
val displayCategories = remember(categories, favoriteIds) {
val fav = IptvCategory(
id = TV_FAV_CATEGORY_ID,
name = "Favorieten",
channelCount = favoriteIds.size,
)
listOf(fav) + categories
}
val filteredChannels = remember(channels, favoriteIds, selectedCategoryId) {
var list = channels
// Favorieten bovenaan binnen categorie (niet in Favorieten-tab zelf)
if (selectedCategoryId != TV_FAV_CATEGORY_ID && favoriteIds.isNotEmpty()) {
list = list.sortedByDescending { favoriteIds.contains(it.streamId) }
}
list
}
fun applyEpgTitles(list: List<IptvChannel>) {
list.forEach { ch ->
ch.nowTitle?.takeIf { it.isNotBlank() }?.let { epgNowTitles[ch.streamId] = it }
ch.nextTitle?.takeIf { it.isNotBlank() }?.let { epgNextTitles[ch.streamId] = it }
}
}
LaunchedEffect(Unit) {
loading = true
@ -100,7 +133,9 @@ fun TvScreen(
try {
val cats = api.iptvCategories()
categories = cats
val restored = selectedCategoryId?.takeIf { id -> cats.any { it.id == id } }
val restored = selectedCategoryId?.takeIf { id ->
id == TV_FAV_CATEGORY_ID || cats.any { it.id == id }
}
if (restored != null) {
onCategoryChange(restored)
} else {
@ -113,69 +148,143 @@ fun TvScreen(
}
}
LaunchedEffect(selectedCategoryId) {
LaunchedEffect(selectedCategoryId, favoriteIds) {
val catId = selectedCategoryId ?: return@LaunchedEffect
loadingChannels = true
focusedEpg = null
focusedStreamId = null
epgNowTitles.clear()
if (catId == TV_FAV_CATEGORY_ID) {
loadingChannels = channelCache["__all__"] == null && channels.isEmpty()
try {
val loaded = api.iptvChannels(catId, includeEpg = true)
val titles = api.fetchMissingNowTitles(loaded)
val all = channelCache["__all__"] ?: api.iptvChannels("__all__", includeEpg = false).also {
channelCache["__all__"] = it
applyEpgTitles(it)
}
val favList = all.filter { favoriteIds.contains(it.streamId) }
channels = favList
loadingChannels = false
applyEpgTitles(favList)
val focusId = lastStreamId?.takeIf { id -> favList.any { it.streamId == id } }
?: favList.firstOrNull()?.streamId
focusId?.let { id ->
focusedStreamId = id
epgLoading = focusedEpg?.streamId != id
runCatching {
val epg = api.iptvEpg(id)
focusedEpg = epg
epg.now?.title?.let { epgNowTitles[id] = it }
epg.next?.title?.let { epgNextTitles[id] = it }
}
epgLoading = false
}
if (favList.isEmpty()) focusedEpg = null
scope.launch {
// XMLTV kan even duren; daarna short_epg-fill — opnieuw pollen
repeat(5) { attempt ->
runCatching {
val withEpg = api.iptvChannels("__all__", includeEpg = true)
channelCache["__all__"] = withEpg
applyEpgTitles(withEpg)
if (selectedCategoryId == TV_FAV_CATEGORY_ID) {
channels = withEpg.filter { favoriteIds.contains(it.streamId) }
}
val covered = withEpg.count { !it.nowTitle.isNullOrBlank() || !it.nextTitle.isNullOrBlank() }
if (covered >= (withEpg.size * 0.5).toInt() || attempt == 4) return@repeat
}
delay(2_500L * (attempt + 1))
}
}
} catch (e: Exception) {
if (channels.isEmpty()) error = e.message
loadingChannels = false
}
return@LaunchedEffect
}
val cached = channelCache[catId]
if (cached != null) {
channels = cached
loadingChannels = false
applyEpgTitles(cached)
} else {
loadingChannels = true
channels = emptyList()
}
try {
val loaded = api.iptvChannels(catId, includeEpg = false)
channels = loaded
titles.forEach { (id, title) -> epgNowTitles[id] = title }
channelCache[catId] = loaded
applyEpgTitles(loaded)
loadingChannels = false
val focusId = lastStreamId?.takeIf { id -> loaded.any { it.streamId == id } }
?: loaded.firstOrNull()?.streamId
focusId?.let { id ->
focusedStreamId = id
runCatching { focusedEpg = api.iptvEpg(id) }
epgLoading = focusedEpg?.streamId != id
runCatching {
val epg = api.iptvEpg(id)
focusedEpg = epg
epg.now?.title?.let { epgNowTitles[id] = it }
epg.next?.title?.let { epgNextTitles[id] = it }
}
epgLoading = false
}
// Achtergrond: XMLTV + missing short_epg; pollen tot dekking goed is
scope.launch {
repeat(5) { attempt ->
runCatching {
val withEpg = api.iptvChannels(catId, includeEpg = true)
applyEpgTitles(withEpg)
if (selectedCategoryId == catId) {
channelCache[catId] = withEpg
channels = withEpg
}
val covered = withEpg.count { !it.nowTitle.isNullOrBlank() || !it.nextTitle.isNullOrBlank() }
if (withEpg.isEmpty() || covered >= (withEpg.size * 0.5).toInt() || attempt == 4) {
return@repeat
}
}
delay(2_500L * (attempt + 1))
}
}
} catch (e: Exception) {
if (channels.isEmpty()) {
error = e.message
channels = emptyList()
} finally {
}
loadingChannels = false
}
}
LaunchedEffect(selectedCategoryId, channels, loadingChannels, focusInList) {
if (!focusInList || loadingChannels || channels.isEmpty()) return@LaunchedEffect
LaunchedEffect(selectedCategoryId, filteredChannels, loadingChannels, focusInList) {
if (!focusInList || loadingChannels || filteredChannels.isEmpty()) return@LaunchedEffect
delay(100)
val targetId = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } }
?: channels.first().streamId
val idx = channels.indexOfFirst { it.streamId == targetId }.coerceAtLeast(0)
val targetId = lastStreamId?.takeIf { id -> filteredChannels.any { it.streamId == id } }
?: filteredChannels.first().streamId
val idx = filteredChannels.indexOfFirst { it.streamId == targetId }.coerceAtLeast(0)
channelListState.scrollToItem(idx)
delay(80)
channelFocus[targetId]?.let { runCatching { it.requestFocus() } }
focusedStreamId = targetId
}
LaunchedEffect(focusContentNonce, loading, categories, channels, selectedCategoryId, lastStreamId) {
if (loading || categories.isEmpty()) return@LaunchedEffect
// TV-tab / omlaag vanaf nav → altijd eerst categorieën (niet meteen zenderlijst of toetsenbord)
LaunchedEffect(focusContentNonce, loading, displayCategories, selectedCategoryId) {
if (loading || displayCategories.isEmpty()) return@LaunchedEffect
delay(120)
val channelTarget = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } }
if (channelTarget != null) {
val idx = channels.indexOfFirst { it.streamId == channelTarget }
if (idx >= 0) channelListState.scrollToItem(idx)
channelFocus[channelTarget]?.let { runCatching { it.requestFocus() } }
focusInList = true
focusedStreamId = channelTarget
runCatching {
val epg = api.iptvEpg(channelTarget)
focusedEpg = epg
epg.now?.title?.let { epgNowTitles[channelTarget] = it }
}
} else {
val catId = selectedCategoryId ?: categories.firstOrNull()?.id ?: return@LaunchedEffect
val catIdx = categories.indexOfFirst { it.id == catId }.coerceAtLeast(0)
val catId = selectedCategoryId ?: displayCategories.firstOrNull()?.id ?: return@LaunchedEffect
val catIdx = displayCategories.indexOfFirst { it.id == catId }.coerceAtLeast(0)
categoryRowState.scrollToItem(catIdx)
categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
focusInList = false
}
}
BackHandler(enabled = focusInList) {
val catId = selectedCategoryId ?: categories.firstOrNull()?.id
val catId = selectedCategoryId ?: displayCategories.firstOrNull()?.id
if (catId != null) {
categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
focusInList = false
@ -189,12 +298,15 @@ fun TvScreen(
focusedStreamId = streamId
epgJob?.cancel()
epgJob = scope.launch {
delay(200)
delay(180)
if (focusedEpg?.streamId != streamId) epgLoading = true
runCatching {
val epg = api.iptvEpg(streamId)
focusedEpg = epg
epg.now?.title?.let { epgNowTitles[streamId] = it }
epg.next?.title?.let { epgNextTitles[streamId] = it }
}
epgLoading = false
}
}
@ -216,7 +328,7 @@ fun TvScreen(
else -> {
Column(Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 8.dp)) {
CategoryTabRow(
categories = categories,
categories = displayCategories,
selectedId = selectedCategoryId,
listState = categoryRowState,
categoryFocus = categoryFocus,
@ -248,17 +360,28 @@ fun TvScreen(
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Zender", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium)
Text("Nu op TV", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium)
Text("Nu · Straks", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium)
}
when {
loadingChannels -> {
loadingChannels && filteredChannels.isEmpty() -> {
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
Text("Zenders en gids laden…", color = McColors.TextDim, fontSize = 14.sp)
Text("Zenders laden…", color = McColors.TextDim, fontSize = 14.sp)
}
}
channels.isEmpty() -> {
filteredChannels.isEmpty() -> {
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
Text("Geen zenders", color = McColors.TextDim, fontSize = 14.sp)
EmptyState(
title = if (selectedCategoryId == TV_FAV_CATEGORY_ID) {
"Nog geen favorieten"
} else {
"Geen zenders"
},
subtitle = if (selectedCategoryId == TV_FAV_CATEGORY_ID) {
"Markeer een zender met ★ in het detailpaneel."
} else {
"Deze categorie heeft geen kanalen."
},
)
}
}
else -> {
@ -268,13 +391,15 @@ fun TvScreen(
contentPadding = PaddingValues(bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
items(channels, key = { it.streamId }) { ch ->
items(filteredChannels, key = { it.streamId }) { ch ->
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
ChannelRow(
channel = ch,
nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle,
nextTitle = epgNextTitles[ch.streamId] ?: ch.nextTitle,
isFocused = focusedStreamId == ch.streamId,
isLastWatched = ch.streamId == lastStreamId,
isFavorite = favoriteIds.contains(ch.streamId),
modifier = Modifier.focusRequester(fr),
onClick = {
onLastStreamChange(ch.streamId)
@ -303,6 +428,11 @@ fun TvScreen(
ChannelDetailPanel(
epg = focusedEpg,
loading = epgLoading && focusedEpg == null,
isFavorite = focusedStreamId?.let { favoriteIds.contains(it) } == true,
onToggleFavorite = {
focusedStreamId?.let { onToggleFavorite(it) }
},
modifier = Modifier
.weight(0.42f)
.fillMaxHeight()
@ -354,6 +484,10 @@ private fun CategoryTabRow(
Column(
Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
cat.name,
@ -363,11 +497,19 @@ private fun CategoryTabRow(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (cat.channelCount > 0) {
Text(
"${cat.channelCount}",
color = if (active) Color(0xFFAAAAAA) else Color(0xFF555555),
fontSize = 11.sp,
)
}
}
Spacer(Modifier.height(5.dp))
Box(
Modifier
.height(2.dp)
.width(if (active) 24.dp else 0.dp)
.width(if (active) 28.dp else 0.dp)
.clip(RoundedCornerShape(1.dp))
.background(if (active) McColors.Brand else Color.Transparent),
)
@ -382,8 +524,10 @@ private fun CategoryTabRow(
private fun ChannelRow(
channel: IptvChannel,
nowTitle: String?,
nextTitle: String?,
isFocused: Boolean,
isLastWatched: Boolean,
isFavorite: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
onFocused: () -> Unit,
@ -413,7 +557,7 @@ private fun ChannelRow(
Row(
Modifier
.fillMaxWidth()
.height(52.dp)
.height(56.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@ -466,6 +610,8 @@ private fun ChannelRow(
modifier = Modifier.width(36.dp),
)
}
Column(Modifier.width(140.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
channel.name,
color = Color.White,
@ -473,23 +619,56 @@ private fun ChannelRow(
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(140.dp),
modifier = Modifier.weight(1f, fill = false),
)
if (isFavorite) {
Text("★", color = McColors.Brand, fontSize = 11.sp)
}
}
if (isLastWatched && !isFocused) {
Text("Laatst bekeken", color = Color(0xFF666666), fontSize = 10.sp, maxLines = 1)
}
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (isFocused && !nowTitle.isNullOrBlank()) {
Text(
nowTitle ?: "",
"LIVE",
color = McColors.Brand,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
)
}
Text(
nowTitle ?: if (isFocused) "Programma laden…" else "",
color = if (isFocused) Color(0xFFCCCCCC) else McColors.TextDim,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
if (!nextTitle.isNullOrBlank()) {
Text(
"Straks · $nextTitle",
color = Color(0xFF666666),
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun ChannelDetailPanel(
epg: IptvEpg?,
loading: Boolean,
isFavorite: Boolean,
onToggleFavorite: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -499,7 +678,11 @@ private fun ChannelDetailPanel(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (epg == null) {
when {
epg == null && loading -> {
EpgSkeleton()
}
epg == null -> {
Spacer(Modifier.weight(1f))
Text(
"Selecteer een zender",
@ -507,10 +690,15 @@ private fun ChannelDetailPanel(
fontSize = 14.sp,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Text(
"Programma-info verschijnt hier",
color = Color(0xFF555555),
fontSize = 12.sp,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Spacer(Modifier.weight(1f))
return
}
else -> {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@ -526,7 +714,7 @@ private fun ChannelDetailPanel(
contentScale = ContentScale.Fit,
)
}
Column {
Column(Modifier.weight(1f)) {
Text(
epg.channelName,
color = Color.White,
@ -543,36 +731,86 @@ private fun ChannelDetailPanel(
epg.now?.let { now ->
EpgBlock(label = "Nu", program = now, highlight = true)
val progress = epgProgress(now)
if (progress > 0f) {
Box(
Modifier
.fillMaxWidth()
.height(2.dp)
.height(3.dp)
.clip(RoundedCornerShape(1.dp))
.background(Color(0xFF333333)),
) {
Box(
Modifier
.fillMaxWidth(progress)
.height(2.dp)
.fillMaxWidth(progress.coerceAtLeast(0.02f))
.height(3.dp)
.background(McColors.Brand),
)
}
}
}
epg.next?.let { next ->
EpgBlock(label = "Straks", program = next, highlight = false)
}
if (epg.now == null && epg.next == null) {
Text("Geen programma-info beschikbaar", color = McColors.TextDim, fontSize = 13.sp)
Text(
"Geen programmagids voor deze zender",
color = McColors.TextDim,
fontSize = 13.sp,
)
Text(
"Je kunt gewoon kijken — de gids is niet altijd beschikbaar.",
color = Color(0xFF555555),
fontSize = 12.sp,
lineHeight = 16.sp,
)
}
Spacer(Modifier.weight(1f))
Surface(
onClick = onToggleFavorite,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1A1A1A),
focusedContainerColor = Color(0xFF2A2A2A),
contentColor = Color.White,
focusedContentColor = Color.White,
),
) {
Text(
if (isFavorite) "★ Favoriet" else "☆ Favoriet",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
color = if (isFavorite) McColors.Brand else Color(0xFFAAAAAA),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
}
Text("OK om te kijken", color = Color(0xFF666666), fontSize = 12.sp)
}
}
}
}
@Composable
private fun EpgSkeleton() {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(48.dp).clip(RoundedCornerShape(4.dp)).background(Color(0xFF1A1A1A)))
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Box(Modifier.width(140.dp).height(14.dp).clip(RoundedCornerShape(2.dp)).background(Color(0xFF1A1A1A)))
Box(Modifier.width(90.dp).height(10.dp).clip(RoundedCornerShape(2.dp)).background(Color(0xFF151515)))
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider))
Box(Modifier.width(40.dp).height(10.dp).clip(RoundedCornerShape(2.dp)).background(Color(0xFF1A1A1A)))
Box(Modifier.fillMaxWidth(0.85f).height(16.dp).clip(RoundedCornerShape(2.dp)).background(Color(0xFF1A1A1A)))
Box(Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(1.dp)).background(Color(0xFF222222)))
Spacer(Modifier.weight(1f))
Text("Programma laden…", color = Color(0xFF555555), fontSize = 12.sp)
}
}
@Composable
private fun EpgBlock(label: String, program: EpgProgram, highlight: Boolean) {

View file

@ -0,0 +1,188 @@
package nl.vonas.mediacluster.tv.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import nl.vonas.mediacluster.tv.ui.theme.McColors
@Composable
private fun shimmerBrush(): Brush {
val transition = rememberInfiniteTransition(label = "shimmer")
val x by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(1100, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "shimmerX",
)
return Brush.linearGradient(
colors = listOf(
Color(0xFF1A1A1A),
Color(0xFF2A2A2A),
Color(0xFF1A1A1A),
),
start = Offset(x - 200f, 0f),
end = Offset(x, 200f),
)
}
@Composable
fun SkeletonBox(
modifier: Modifier = Modifier,
shape: RoundedCornerShape = RoundedCornerShape(4.dp),
) {
Box(modifier.clip(shape).background(shimmerBrush()))
}
@Composable
fun SkeletonPoster(
width: Dp = 140.dp,
height: Dp = 210.dp,
modifier: Modifier = Modifier,
) {
SkeletonBox(modifier.width(width).height(height))
}
@Composable
fun SkeletonShelfRow(
titleWidth: Dp = 160.dp,
posterCount: Int = 6,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
SkeletonBox(
Modifier
.padding(horizontal = 48.dp)
.width(titleWidth)
.height(18.dp),
shape = RoundedCornerShape(3.dp),
)
LazyRow(
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
items(posterCount) {
SkeletonPoster()
}
}
}
}
@Composable
fun SkeletonBillboard(height: Dp = 480.dp) {
Box(Modifier.fillMaxWidth().height(height)) {
SkeletonBox(Modifier.fillMaxSize(), shape = RoundedCornerShape(0.dp))
Column(
Modifier
.align(Alignment.BottomStart)
.padding(start = 48.dp, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
SkeletonBox(Modifier.width(48.dp).height(12.dp))
SkeletonBox(Modifier.width(360.dp).height(36.dp))
SkeletonBox(Modifier.width(220.dp).height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
SkeletonBox(Modifier.width(140.dp).height(44.dp), RoundedCornerShape(4.dp))
SkeletonBox(Modifier.width(120.dp).height(44.dp), RoundedCornerShape(4.dp))
}
}
}
}
@Composable
fun HomeSkeleton() {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(28.dp),
) {
SkeletonBillboard()
SkeletonShelfRow(180.dp)
SkeletonShelfRow(140.dp)
SkeletonShelfRow(200.dp)
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun EmptyState(
title: String,
subtitle: String? = null,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
Column(
modifier
.fillMaxWidth()
.padding(horizontal = 48.dp, vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
title,
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
color = McColors.TextDim,
fontSize = 15.sp,
textAlign = TextAlign.Center,
lineHeight = 22.sp,
)
}
if (actionLabel != null && onAction != null) {
Spacer(Modifier.height(8.dp))
McButton(actionLabel, onClick = onAction)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text(
text.uppercase(),
color = Color(0xFF777777),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp,
modifier = modifier,
)
}

View file

@ -20,7 +20,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -30,6 +34,21 @@ import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import nl.vonas.mediacluster.tv.ui.theme.McColors
/**
* Berekende hoogte van de Home overlay-topnav (geen subtabs).
*
* TopNavBar overlay-row:
* - vertical padding: 14.dp + 14.dp
* - tab-inhoud: 8.dp + ~21.dp tekst + 4.dp + 3.dp underline + 8.dp ≈ 44.dp
* - totaal ≈ 72.dp; TV Surface min. interactive ≈ 48.dp → veilig 76.dp
*/
object HomeChrome {
val TopNavHeight = 76.dp
/** Lucht tussen onderkant menu en badge. */
val BadgeGap = 12.dp
val BadgeTop = TopNavHeight + BadgeGap // 88.dp
}
enum class MainTab(val label: String) {
Home("Home"),
Movies("Films"),
@ -56,35 +75,81 @@ fun TopNavBar(
selectedSubId: String? = null,
onSelectSub: (SubNavItem) -> Unit = {},
modifier: Modifier = Modifier,
/** Home gescrolld: zachtere chrome. */
compact: Boolean = false,
/** Netflix-stijl: zweeft over hero met scrim. */
overlay: Boolean = false,
/** Omlaag vanuit menu → Home-content (Afspelen / planken). */
contentDownFocus: FocusRequester? = null,
profileAvatarEmoji: String? = null,
profileLabel: String? = null,
onProfileClick: (() -> Unit)? = null,
) {
val alpha = when {
// Overlay: transparantie zit in de gradient, niet in ekstra fade
overlay -> 1f
compact -> 0.42f
else -> 1f
}
val vPad = if (compact && !overlay) 8.dp else 14.dp
val downLink = Modifier.then(
if (contentDownFocus != null) {
Modifier.focusProperties { down = contentDownFocus }
} else {
Modifier
},
)
Column(
modifier
.fillMaxWidth()
.then(
if (overlay) {
// Semi-transparant: hero zichtbaar, menu blijft leesbaar
Modifier.background(
Brush.verticalGradient(
0f to Color(0xB8000000),
0.55f to Color(0x88000000),
1f to Color.Transparent,
),
)
} else {
Modifier
},
)
.graphicsLayer { this.alpha = alpha },
) {
Column(modifier.fillMaxWidth()) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp, vertical = 14.dp),
.padding(horizontal = 40.dp, vertical = vPad),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"VMC",
color = McColors.Brand,
fontSize = 28.sp,
fontSize = if (compact && !overlay) 22.sp else 28.sp,
fontWeight = FontWeight.Black,
modifier = Modifier.padding(end = 16.dp),
)
visibleTabs.forEach { tab ->
val active = selected == tab
val color by animateColorAsState(
if (active) Color.White else Color(0xFF808080),
when {
active -> Color.White
overlay -> Color(0xFFE8E8E8)
else -> Color(0xFF808080)
},
label = "tabColor",
)
Surface(
onClick = { onSelect(tab) },
modifier = downLink,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (active) Color(0x22FFFFFF) else Color.Transparent,
focusedContainerColor = Color(0x33FFFFFF),
containerColor = if (active) Color(0x33FFFFFF) else Color.Transparent,
focusedContainerColor = Color(0x44FFFFFF),
contentColor = color,
focusedContentColor = Color.White,
),
@ -110,6 +175,38 @@ fun TopNavBar(
}
}
}
Spacer(Modifier.weight(1f))
if (onProfileClick != null && profileAvatarEmoji != null) {
Surface(
onClick = onProfileClick,
modifier = downLink,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(50)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0x33FFFFFF),
focusedContainerColor = Color(0x55FFFFFF),
contentColor = Color.White,
focusedContentColor = Color.White,
),
) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(profileAvatarEmoji, fontSize = 20.sp)
if (!profileLabel.isNullOrBlank()) {
Text(
profileLabel,
color = Color.Unspecified,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
}
}
}
}
if (subItems.isNotEmpty()) {
@ -122,6 +219,7 @@ fun TopNavBar(
val active = item.id == selectedSubId
Surface(
onClick = { onSelectSub(item) },
modifier = downLink,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(20.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors(

View file

@ -0,0 +1,19 @@
package nl.vonas.mediacluster.tv.ui.profiles
data class ProfileAvatar(val id: String, val emoji: String, val label: String)
val PROFILE_AVATARS = listOf(
ProfileAvatar("fox", "🦊", "Vos"),
ProfileAvatar("panda", "🐼", "Panda"),
ProfileAvatar("robot", "🤖", "Robot"),
ProfileAvatar("alien", "👽", "Alien"),
ProfileAvatar("ninja", "🥷", "Ninja"),
ProfileAvatar("pirate", "🏴‍☠️", "Piraat"),
ProfileAvatar("wizard", "🧙", "Tovenaar"),
ProfileAvatar("dino", "🦕", "Dino"),
ProfileAvatar("cat", "🐱", "Kat"),
ProfileAvatar("rocket", "🚀", "Raket"),
)
fun avatarEmoji(avatarId: String): String =
PROFILE_AVATARS.firstOrNull { it.id == avatarId }?.emoji ?: "🙂"

View file

@ -0,0 +1,487 @@
package nl.vonas.mediacluster.tv.ui.profiles
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.data.ViewerProfile
import nl.vonas.mediacluster.tv.ui.theme.McColors
private enum class ProfilesMode { Pick, Pin, Create, DeleteConfirm }
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun ProfilesScreen(
api: ApiClient,
store: SessionStore,
switcherMode: Boolean = true,
onProfileSelected: () -> Unit,
) {
val scope = rememberCoroutineScope()
var profiles by remember { mutableStateOf<List<ViewerProfile>>(emptyList()) }
var canManage by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var mode by remember { mutableStateOf(ProfilesMode.Pick) }
var pending by remember { mutableStateOf<ViewerProfile?>(null) }
var pin by remember { mutableStateOf("") }
var createName by remember { mutableStateOf("") }
var createAvatar by remember { mutableStateOf("fox") }
var createKids by remember { mutableStateOf(false) }
var createPin by remember { mutableStateOf("") }
val firstFocus = remember { FocusRequester() }
fun reload() {
scope.launch {
loading = true
error = null
runCatching { api.listProfiles() }
.onSuccess {
profiles = it.profiles
canManage = it.canManage
}
.onFailure { error = it.message ?: "Laden mislukt" }
loading = false
}
}
LaunchedEffect(switcherMode) {
if (switcherMode) {
runCatching { api.clearProfile() }
store.setProfile(null)
}
reload()
runCatching { firstFocus.requestFocus() }
}
fun select(profile: ViewerProfile, pinValue: String? = null) {
scope.launch {
error = null
runCatching { api.selectProfile(profile.id, pinValue) }
.onSuccess { selected ->
store.setProfile(selected.id, selected.name, selected.avatarId)
onProfileSelected()
}
.onFailure {
error = it.message ?: "Selecteren mislukt"
if (profile.hasPin) {
mode = ProfilesMode.Pin
pending = profile
pin = ""
}
}
}
}
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0xFF0B0B0F), Color(0xFF14141C), Color(0xFF0B0B0F)),
),
),
) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 48.dp, vertical = 36.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
if (switcherMode) "Wie kijkt er?" else "Profielen beheren",
color = Color.White,
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(8.dp))
Text(
when {
!switcherMode && canManage -> "Voeg toe of verwijder (lang drukken)"
switcherMode -> "Kies je profiel"
else -> "Alleen het hoofdprofiel mag beheren"
},
color = Color(0xFFAAAAAA),
fontSize = 16.sp,
)
Spacer(Modifier.height(28.dp))
when (mode) {
ProfilesMode.Pick -> {
if (loading) {
Text("Profielen laden…", color = Color.White)
} else {
LazyRow(
contentPadding = PaddingValues(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
items(profiles, key = { it.id }) { p ->
ProfileTile(
profile = p,
modifier = if (profiles.firstOrNull()?.id == p.id) {
Modifier.focusRequester(firstFocus)
} else Modifier,
onClick = {
if (p.hasPin) {
pending = p
pin = ""
mode = ProfilesMode.Pin
} else {
select(p)
}
},
onLongDelete = if (canManage && !p.isOwner) {
{
pending = p
mode = ProfilesMode.DeleteConfirm
}
} else null,
)
}
if (canManage && profiles.size < 8) {
item {
AddProfileTile(onClick = {
createName = ""
createAvatar = "fox"
createKids = false
createPin = ""
mode = ProfilesMode.Create
})
}
}
}
if (canManage) {
Spacer(Modifier.height(20.dp))
Text(
"Houd OK lang ingedrukt op een profiel om te verwijderen",
color = Color(0xFF666666),
fontSize = 13.sp,
)
}
}
}
ProfilesMode.Pin -> {
val target = pending
Text(
"PIN voor ${target?.name ?: "profiel"}",
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = pin,
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) },
label = { androidx.compose.material3.Text("4-cijferige PIN") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = McColors.Brand,
unfocusedBorderColor = Color(0xFF555555),
focusedLabelColor = McColors.Brand,
unfocusedLabelColor = Color(0xFF888888),
cursorColor = McColors.Brand,
),
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip("Terug") {
mode = ProfilesMode.Pick
pending = null
pin = ""
}
ActionChip("Ontgrendel", primary = true) {
if (target != null) select(target, pin)
}
}
}
ProfilesMode.Create -> {
Text("Nieuw profiel", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = createName,
onValueChange = { createName = it.take(32) },
label = { androidx.compose.material3.Text("Naam") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = McColors.Brand,
unfocusedBorderColor = Color(0xFF555555),
focusedLabelColor = McColors.Brand,
unfocusedLabelColor = Color(0xFF888888),
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)
}
}
}
}
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip(if (createKids) "Kids: aan" else "Kids: uit") {
createKids = !createKids
}
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = createPin,
onValueChange = { v -> createPin = v.filter { it.isDigit() }.take(4) },
label = { androidx.compose.material3.Text("PIN (optioneel)") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = McColors.Brand,
unfocusedBorderColor = Color(0xFF555555),
focusedLabelColor = McColors.Brand,
unfocusedLabelColor = Color(0xFF888888),
cursorColor = McColors.Brand,
),
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip("Annuleer") { mode = ProfilesMode.Pick }
ActionChip("Aanmaken", primary = true) {
scope.launch {
error = null
runCatching {
api.createProfile(
name = createName.trim(),
avatarId = createAvatar,
isKids = createKids,
pin = createPin.ifBlank { null },
)
}.onSuccess {
mode = ProfilesMode.Pick
reload()
}.onFailure {
error = it.message ?: "Aanmaken mislukt"
}
}
}
}
}
ProfilesMode.DeleteConfirm -> {
val target = pending
Text(
"Verwijder ${target?.name ?: "profiel"}?",
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp))
Text(
"Kijkgeschiedenis van dit profiel gaat verloren.",
color = Color(0xFFAAAAAA),
fontSize = 14.sp,
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ActionChip("Annuleer") {
mode = ProfilesMode.Pick
pending = null
}
ActionChip("Verwijderen", primary = true) {
val id = target?.id ?: return@ActionChip
scope.launch {
runCatching { api.deleteProfile(id) }
.onSuccess {
mode = ProfilesMode.Pick
pending = null
reload()
}
.onFailure { error = it.message ?: "Verwijderen mislukt" }
}
}
}
}
}
error?.let {
Spacer(Modifier.height(16.dp))
Text(it, color = Color(0xFFFF6B6B), fontSize = 14.sp)
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun ProfileTile(
profile: ViewerProfile,
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongDelete: (() -> Unit)? = null,
) {
Surface(
onClick = onClick,
onLongClick = onLongDelete,
modifier = modifier.width(140.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF1C1C24),
focusedContainerColor = Color(0xFF2A2A36),
),
) {
Column(
Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
Modifier
.size(88.dp)
.clip(CircleShape)
.background(Color(0xFF2E2E3A)),
contentAlignment = Alignment.Center,
) {
Text(avatarEmoji(profile.avatarId), fontSize = 40.sp)
}
Spacer(Modifier.height(12.dp))
Text(
profile.name,
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
val badge = when {
profile.isKids -> "Kids"
profile.isOwner -> "Hoofd"
profile.hasPin -> "PIN"
else -> null
}
if (badge != null) {
Spacer(Modifier.height(4.dp))
Text(badge, color = Color(0xFF999999), fontSize = 12.sp)
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun AddProfileTile(onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = Modifier.width(140.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color(0xFF16161E),
focusedContainerColor = Color(0xFF242430),
),
) {
Column(
Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
Modifier
.size(88.dp)
.clip(CircleShape)
.background(Color(0xFF2A2A32)),
contentAlignment = Alignment.Center,
) {
Text("+", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.Light)
}
Spacer(Modifier.height(12.dp))
Text("Toevoegen", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun ActionChip(label: String, primary: Boolean = false, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (primary) McColors.Brand else Color(0xFF2A2A32),
focusedContainerColor = if (primary) McColors.Brand else Color(0xFF3A3A48),
contentColor = Color.White,
focusedContentColor = Color.White,
),
) {
Text(
label,
Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
color = Color.Unspecified,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
)
}
}

View file

@ -0,0 +1,114 @@
-- Viewer profiles (Netflix-style) + per-profile progress/prefs + TMDB adult flags
-- 1) Profiles table
CREATE TABLE "viewer_profiles" (
"id" TEXT NOT NULL,
"viewer_user_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"avatar_id" TEXT NOT NULL DEFAULT 'fox',
"is_owner" BOOLEAN NOT NULL DEFAULT false,
"is_kids" BOOLEAN NOT NULL DEFAULT false,
"pin_hash" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "viewer_profiles_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "viewer_profiles_viewer_user_id_name_key" ON "viewer_profiles"("viewer_user_id", "name");
CREATE INDEX "viewer_profiles_viewer_user_id_idx" ON "viewer_profiles"("viewer_user_id");
ALTER TABLE "viewer_profiles"
ADD CONSTRAINT "viewer_profiles_viewer_user_id_fkey"
FOREIGN KEY ("viewer_user_id") REFERENCES "viewer_users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- 2) Default owner profile per existing viewer
INSERT INTO "viewer_profiles" ("id", "viewer_user_id", "name", "avatar_id", "is_owner", "is_kids", "created_at", "updated_at")
SELECT
gen_random_uuid()::text,
vu."id",
COALESCE(NULLIF(TRIM(vu."name"), ''), 'Hoofdprofiel'),
'fox',
true,
false,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "viewer_users" vu;
-- 3) Device active profile
ALTER TABLE "viewer_devices" ADD COLUMN "active_profile_id" TEXT;
ALTER TABLE "viewer_devices"
ADD CONSTRAINT "viewer_devices_active_profile_id_fkey"
FOREIGN KEY ("active_profile_id") REFERENCES "viewer_profiles"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "viewer_devices_active_profile_id_idx" ON "viewer_devices"("active_profile_id");
-- 4) Watch progress → profile_id
ALTER TABLE "watch_progress" ADD COLUMN "profile_id" TEXT;
UPDATE "watch_progress" wp
SET "profile_id" = vp."id"
FROM "viewer_profiles" vp
WHERE vp."viewer_user_id" = wp."viewer_user_id"
AND vp."is_owner" = true;
DELETE FROM "watch_progress" WHERE "profile_id" IS NULL;
ALTER TABLE "watch_progress" ALTER COLUMN "profile_id" SET NOT NULL;
ALTER TABLE "watch_progress" DROP CONSTRAINT IF EXISTS "watch_progress_viewer_user_id_fkey";
DROP INDEX IF EXISTS "watch_progress_viewer_user_id_movie_id_key";
DROP INDEX IF EXISTS "watch_progress_viewer_user_id_episode_id_key";
DROP INDEX IF EXISTS "watch_progress_viewer_user_id_updated_at_idx";
DROP INDEX IF EXISTS "watch_progress_viewer_user_id_hidden_from_continue_completed_idx";
ALTER TABLE "watch_progress" DROP COLUMN "viewer_user_id";
CREATE UNIQUE INDEX "watch_progress_profile_id_movie_id_key" ON "watch_progress"("profile_id", "movie_id");
CREATE UNIQUE INDEX "watch_progress_profile_id_episode_id_key" ON "watch_progress"("profile_id", "episode_id");
CREATE INDEX "watch_progress_profile_id_updated_at_idx" ON "watch_progress"("profile_id", "updated_at");
CREATE INDEX "watch_progress_profile_id_hidden_from_continue_completed_idx"
ON "watch_progress"("profile_id", "hidden_from_continue", "completed");
ALTER TABLE "watch_progress"
ADD CONSTRAINT "watch_progress_profile_id_fkey"
FOREIGN KEY ("profile_id") REFERENCES "viewer_profiles"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- 5) Shelf prefs → profile_id
ALTER TABLE "viewer_shelf_preferences" ADD COLUMN "profile_id" TEXT;
UPDATE "viewer_shelf_preferences" sp
SET "profile_id" = vp."id"
FROM "viewer_profiles" vp
WHERE vp."viewer_user_id" = sp."viewer_user_id"
AND vp."is_owner" = true;
DELETE FROM "viewer_shelf_preferences" WHERE "profile_id" IS NULL;
ALTER TABLE "viewer_shelf_preferences" ALTER COLUMN "profile_id" SET NOT NULL;
ALTER TABLE "viewer_shelf_preferences" DROP CONSTRAINT IF EXISTS "viewer_shelf_preferences_viewer_user_id_fkey";
DROP INDEX IF EXISTS "viewer_shelf_preferences_viewer_user_id_shelf_id_key";
DROP INDEX IF EXISTS "viewer_shelf_preferences_viewer_user_id_sort_order_idx";
ALTER TABLE "viewer_shelf_preferences" DROP COLUMN "viewer_user_id";
CREATE UNIQUE INDEX "viewer_shelf_preferences_profile_id_shelf_id_key"
ON "viewer_shelf_preferences"("profile_id", "shelf_id");
CREATE INDEX "viewer_shelf_preferences_profile_id_sort_order_idx"
ON "viewer_shelf_preferences"("profile_id", "sort_order");
ALTER TABLE "viewer_shelf_preferences"
ADD CONSTRAINT "viewer_shelf_preferences_profile_id_fkey"
FOREIGN KEY ("profile_id") REFERENCES "viewer_profiles"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- 6) TMDB adult flags (kids-profielen filteren adult + geblokkeerde genres in API)
ALTER TABLE "movies" ADD COLUMN "adult" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "series" ADD COLUMN "adult" BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX "movies_adult_idx" ON "movies"("adult");
CREATE INDEX "series_adult_idx" ON "series"("adult");

View file

@ -89,8 +89,7 @@ model ViewerUser {
updatedAt DateTime @updatedAt @map("updated_at")
devices ViewerDevice[]
progress WatchProgress[]
shelfPrefs ViewerShelfPreference[]
profiles ViewerProfile[]
linkCodes DeviceLinkCode[]
googlePlayAuditLogs GooglePlayAccessAuditLog[]
iptvAddonToken AddonToken? @relation(fields: [iptvAddonTokenId], references: [id], onDelete: SetNull)
@ -98,20 +97,45 @@ model ViewerUser {
@@map("viewer_users")
}
/** Netflix-stijl kijkprofiel onder één ViewerUser (voortgang/prefs per profiel). */
model ViewerProfile {
id String @id @default(uuid())
viewerUserId String @map("viewer_user_id")
name String
avatarId String @default("fox") @map("avatar_id")
isOwner Boolean @default(false) @map("is_owner")
isKids Boolean @default(false) @map("is_kids")
pinHash String? @map("pin_hash")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
devices ViewerDevice[]
progress WatchProgress[]
shelfPrefs ViewerShelfPreference[]
@@unique([viewerUserId, name])
@@index([viewerUserId])
@@map("viewer_profiles")
}
model ViewerDevice {
id String @id @default(uuid())
viewerUserId String @map("viewer_user_id")
name String
platform String @default("android_tv")
refreshTokenHash String @unique @map("refresh_token_hash")
activeProfileId String? @map("active_profile_id")
lastSeenAt DateTime? @map("last_seen_at")
revoked Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
activeProfile ViewerProfile? @relation(fields: [activeProfileId], references: [id], onDelete: SetNull)
@@index([viewerUserId])
@@index([activeProfileId])
@@map("viewer_devices")
}
@ -143,7 +167,7 @@ enum WatchMediaType {
model WatchProgress {
id String @id @default(uuid())
viewerUserId String @map("viewer_user_id")
profileId String @map("profile_id")
mediaType WatchMediaType @map("media_type")
movieId String? @map("movie_id")
episodeId String? @map("episode_id")
@ -155,26 +179,26 @@ model WatchProgress {
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
profile ViewerProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@unique([viewerUserId, movieId])
@@unique([viewerUserId, episodeId])
@@index([viewerUserId, updatedAt])
@@index([viewerUserId, hiddenFromContinue, completed])
@@unique([profileId, movieId])
@@unique([profileId, episodeId])
@@index([profileId, updatedAt])
@@index([profileId, hiddenFromContinue, completed])
@@map("watch_progress")
}
model ViewerShelfPreference {
id String @id @default(uuid())
viewerUserId String @map("viewer_user_id")
profileId String @map("profile_id")
shelfId String @map("shelf_id")
visible Boolean @default(true)
sortOrder Int @default(0) @map("sort_order")
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
profile ViewerProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@unique([viewerUserId, shelfId])
@@index([viewerUserId, sortOrder])
@@unique([profileId, shelfId])
@@index([profileId, sortOrder])
@@map("viewer_shelf_preferences")
}
@ -350,6 +374,8 @@ model Movie {
backdropUrl String? @map("backdrop_url")
logoUrl String? @map("logo_url")
genres String[] @default([])
/** TMDB adult-flag; kids-profielen filteren hierop. */
adult Boolean @default(false)
runtime Int?
imdbId String? @unique @map("imdb_id")
tmdbId Int? @unique @map("tmdb_id")
@ -360,6 +386,7 @@ model Movie {
matches LibraryMatch[]
subtitles LibrarySubtitle[]
@@index([adult])
@@map("movies")
}
@ -372,6 +399,8 @@ model Series {
backdropUrl String? @map("backdrop_url")
logoUrl String? @map("logo_url")
genres String[] @default([])
/** TMDB adult-flag; kids-profielen filteren hierop. */
adult Boolean @default(false)
imdbId String? @unique @map("imdb_id")
tmdbId Int? @unique @map("tmdb_id")
createdAt DateTime @default(now()) @map("created_at")
@ -381,6 +410,7 @@ model Series {
episodes Episode[]
matches LibraryMatch[]
@@index([adult])
@@map("series")
}

View file

@ -49,6 +49,7 @@ type TmdbMeta = {
backdropUrl?: string;
logoUrl?: string;
imdbId?: string;
adult?: boolean;
};
/** Extra fields for Stremio meta detail pages. */
@ -562,6 +563,7 @@ export class MetadataService {
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
tmdbId: tmdb.id,
adult: tmdb.adult === true,
},
update: {
title: tmdb.title,
@ -569,6 +571,7 @@ export class MetadataService {
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
year: tmdb.year,
adult: tmdb.adult === true,
},
});
if (tmdb.imdbId) {
@ -1004,6 +1007,7 @@ export class MetadataService {
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
tmdbId: tmdb.id,
adult: tmdb.adult === true,
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
},
update: {
@ -1012,6 +1016,7 @@ export class MetadataService {
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
year: tmdb.year,
adult: tmdb.adult === true,
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
},
});
@ -1764,6 +1769,7 @@ export class MetadataService {
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
tmdbId: tmdb.id,
adult: tmdb.adult === true,
};
try {
return await prisma.movie.upsert({
@ -1958,6 +1964,7 @@ export class MetadataService {
release_date?: string;
poster_path?: string;
backdrop_path?: string;
adult?: boolean;
};
const imdbId = await this.fetchTmdbMovieImdbId(tmdbId);
const meta: TmdbMeta = {
@ -1972,6 +1979,7 @@ export class MetadataService {
? `${TMDB_BACKDROP_BASE}${item.backdrop_path}`
: undefined,
imdbId,
adult: item.adult === true,
};
this.movieCache.set(tmdbId, meta);
return meta;
@ -2013,6 +2021,7 @@ export class MetadataService {
first_air_date?: string;
poster_path?: string;
backdrop_path?: string;
adult?: boolean;
};
const logoUrl = await this.fetchTmdbSeriesLogo(tmdbId);
const imdbId = await this.fetchTmdbSeriesImdbId(tmdbId);
@ -2029,6 +2038,7 @@ export class MetadataService {
: undefined,
logoUrl,
imdbId,
adult: item.adult === true,
};
this.seriesCache.set(tmdbId, meta);
return meta;

View file

@ -0,0 +1,74 @@
import { Prisma } from "@prisma/client";
/** Genres die kids-profielen niet mogen zien (NL + EN, case-insensitive match via lower). */
export const KIDS_BLOCKED_GENRES = [
"Horror",
"Thriller",
"Crime",
"War",
"Adult",
"Erotica",
"Erotiek",
"Misdaad",
"Oorlog",
] as const;
const blockedLower = KIDS_BLOCKED_GENRES.map((g) => g.toLowerCase());
/** Prisma where voor movies/series in kids-modus. */
export function kidsContentWhere(): {
adult: false;
NOT: { genres: { hasSome: string[] } };
} {
return {
adult: false,
NOT: { genres: { hasSome: [...KIDS_BLOCKED_GENRES] } },
};
}
/** SQL fragment: movie_id kids-safe (voor media_files queries). */
export function kidsMovieSql(movieIdExpr: Prisma.Sql = Prisma.sql`mf.movie_id`): Prisma.Sql {
const arr = Prisma.join(
blockedLower.map((g) => Prisma.sql`${g}`),
", "
);
return Prisma.sql`
AND EXISTS (
SELECT 1 FROM movies m
WHERE m.id = ${movieIdExpr}
AND m.adult = false
AND NOT EXISTS (
SELECT 1 FROM unnest(m.genres) AS g
WHERE lower(g) IN (${arr})
)
)
`;
}
/** SQL fragment: series via episode kids-safe. */
export function kidsSeriesSql(): Prisma.Sql {
const arr = Prisma.join(
blockedLower.map((g) => Prisma.sql`${g}`),
", "
);
return Prisma.sql`
AND EXISTS (
SELECT 1 FROM series s
WHERE s.id = e.series_id
AND s.adult = false
AND NOT EXISTS (
SELECT 1 FROM unnest(s.genres) AS g
WHERE lower(g) IN (${arr})
)
)
`;
}
export function isKidsBlockedTitle(opts: {
adult?: boolean | null;
genres?: string[] | null;
}): boolean {
if (opts.adult) return true;
const genres = opts.genres ?? [];
return genres.some((g) => blockedLower.includes(g.trim().toLowerCase()));
}

View file

@ -0,0 +1,313 @@
import { prisma } from "../database/client";
import { hashPassword, verifyPassword } from "../security/crypto";
import { AppError } from "../security/errors";
/** Vaste avatar-set (client toont emoji/tekening per id). */
export const PROFILE_AVATARS = [
{ id: "fox", label: "Vos" },
{ id: "panda", label: "Panda" },
{ id: "robot", label: "Robot" },
{ id: "alien", label: "Alien" },
{ id: "ninja", label: "Ninja" },
{ id: "pirate", label: "Piraat" },
{ id: "wizard", label: "Tovenaar" },
{ id: "dino", label: "Dino" },
{ id: "cat", label: "Kat" },
{ id: "rocket", label: "Raket" },
] as const;
export type ProfileAvatarId = (typeof PROFILE_AVATARS)[number]["id"];
const MAX_PROFILES = 8;
const PIN_ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
const PIN_MAX_ATTEMPTS = 5;
/** In-memory PIN rate limit: profileId:deviceId → attempts */
const pinAttempts = new Map<string, { count: number; resetAt: number }>();
function isAvatarId(id: string): id is ProfileAvatarId {
return PROFILE_AVATARS.some((a) => a.id === id);
}
function normalizePin(pin: string | null | undefined): string | null {
if (pin == null || pin === "") return null;
const digits = String(pin).replace(/\D/g, "");
if (digits.length !== 4) {
throw new AppError("INVALID_REQUEST", "PIN moet precies 4 cijfers zijn", 400);
}
return digits;
}
function publicProfile(row: {
id: string;
name: string;
avatarId: string;
isOwner: boolean;
isKids: boolean;
pinHash: string | null;
}) {
return {
id: row.id,
name: row.name,
avatarId: row.avatarId,
isOwner: row.isOwner,
isKids: row.isKids,
hasPin: !!row.pinHash,
};
}
async function assertCanManage(
viewerId: string,
activeProfileId: string | null
) {
if (!activeProfileId) {
throw new AppError(
"FORBIDDEN",
"Selecteer eerst het hoofdprofiel om te beheren",
403
);
}
const me = await prisma.viewerProfile.findFirst({
where: { id: activeProfileId, viewerUserId: viewerId },
});
if (!me?.isOwner) {
throw new AppError("FORBIDDEN", "Alleen de hoofdgebruiker mag dit", 403);
}
}
export async function ensureOwnerProfile(
viewerId: string,
preferredName?: string | null
) {
const existing = await prisma.viewerProfile.findFirst({
where: { viewerUserId: viewerId, isOwner: true },
});
if (existing) return existing;
const name =
preferredName?.trim() ||
(await prisma.viewerUser.findUnique({ where: { id: viewerId } }))?.name?.trim() ||
"Hoofdprofiel";
return prisma.viewerProfile.create({
data: {
viewerUserId: viewerId,
name: name.slice(0, 32),
avatarId: "fox",
isOwner: true,
isKids: false,
},
});
}
export class ViewerProfileService {
avatars() {
return PROFILE_AVATARS.map((a) => ({ ...a }));
}
async list(viewerId: string) {
await ensureOwnerProfile(viewerId);
const rows = await prisma.viewerProfile.findMany({
where: { viewerUserId: viewerId },
orderBy: [{ isOwner: "desc" }, { createdAt: "asc" }],
});
return rows.map(publicProfile);
}
async create(
viewerId: string,
activeProfileId: string | null,
input: {
name?: string;
avatarId?: string;
isKids?: boolean;
pin?: string | null;
}
) {
await assertCanManage(viewerId, activeProfileId);
const count = await prisma.viewerProfile.count({ where: { viewerUserId: viewerId } });
if (count >= MAX_PROFILES) {
throw new AppError("LIMIT", `Maximaal ${MAX_PROFILES} profielen`, 400);
}
const name = (input.name || "").trim().slice(0, 32);
if (name.length < 1) throw new AppError("INVALID_REQUEST", "Naam verplicht", 400);
const avatarId = input.avatarId || "fox";
if (!isAvatarId(avatarId)) {
throw new AppError("INVALID_REQUEST", "Ongeldige avatar", 400);
}
const pin = normalizePin(input.pin);
const isKids = input.isKids === true;
try {
const row = await prisma.viewerProfile.create({
data: {
viewerUserId: viewerId,
name,
avatarId,
isOwner: false,
isKids,
pinHash: pin ? await hashPassword(pin) : null,
},
});
return publicProfile(row);
} catch {
throw new AppError("CONFLICT", "Profielnaam bestaat al", 409);
}
}
async update(
viewerId: string,
activeProfileId: string | null,
profileId: string,
input: {
name?: string;
avatarId?: string;
isKids?: boolean;
pin?: string | null;
clearPin?: boolean;
}
) {
const target = await prisma.viewerProfile.findFirst({
where: { id: profileId, viewerUserId: viewerId },
});
if (!target) throw new AppError("NOT_FOUND", "Profiel niet gevonden", 404);
const active = activeProfileId
? await prisma.viewerProfile.findFirst({
where: { id: activeProfileId, viewerUserId: viewerId },
})
: null;
const isSelf = activeProfileId === profileId;
const isOwnerActing = active?.isOwner === true;
if (!isOwnerActing && !isSelf) {
throw new AppError("FORBIDDEN", "Geen rechten om dit profiel te wijzigen", 403);
}
// Alleen owner mag kids-flag of andermans PIN wijzigen
if (!isOwnerActing && (input.isKids !== undefined || input.pin !== undefined || input.clearPin)) {
if (!isSelf || input.isKids !== undefined) {
throw new AppError("FORBIDDEN", "Alleen de hoofdgebruiker mag dit", 403);
}
}
const data: {
name?: string;
avatarId?: string;
isKids?: boolean;
pinHash?: string | null;
} = {};
if (input.name !== undefined) {
const name = input.name.trim().slice(0, 32);
if (name.length < 1) throw new AppError("INVALID_REQUEST", "Naam verplicht", 400);
data.name = name;
}
if (input.avatarId !== undefined) {
if (!isAvatarId(input.avatarId)) {
throw new AppError("INVALID_REQUEST", "Ongeldige avatar", 400);
}
data.avatarId = input.avatarId;
}
if (input.isKids !== undefined && isOwnerActing) {
// Owner-profiel mag geen kids worden
if (target.isOwner && input.isKids) {
throw new AppError("INVALID_REQUEST", "Hoofdprofiel kan geen kids-profiel zijn", 400);
}
data.isKids = input.isKids;
}
if (input.clearPin === true && isOwnerActing) {
data.pinHash = null;
} else if (input.pin !== undefined && (isOwnerActing || isSelf)) {
const pin = normalizePin(input.pin);
data.pinHash = pin ? await hashPassword(pin) : null;
}
try {
const row = await prisma.viewerProfile.update({
where: { id: profileId },
data,
});
return publicProfile(row);
} catch {
throw new AppError("CONFLICT", "Profielnaam bestaat al", 409);
}
}
async remove(viewerId: string, activeProfileId: string | null, profileId: string) {
await assertCanManage(viewerId, activeProfileId);
const target = await prisma.viewerProfile.findFirst({
where: { id: profileId, viewerUserId: viewerId },
});
if (!target) throw new AppError("NOT_FOUND", "Profiel niet gevonden", 404);
if (target.isOwner) {
throw new AppError("FORBIDDEN", "Hoofdprofiel kan niet verwijderd worden", 403);
}
if (activeProfileId === profileId) {
throw new AppError("INVALID_REQUEST", "Je kunt het actieve profiel niet verwijderen", 400);
}
await prisma.viewerDevice.updateMany({
where: { activeProfileId: profileId },
data: { activeProfileId: null },
});
await prisma.viewerProfile.delete({ where: { id: profileId } });
return { ok: true };
}
async select(
viewerId: string,
deviceId: string,
profileId: string,
pin?: string | null
) {
const profile = await prisma.viewerProfile.findFirst({
where: { id: profileId, viewerUserId: viewerId },
});
if (!profile) throw new AppError("NOT_FOUND", "Profiel niet gevonden", 404);
if (profile.pinHash) {
const key = `${profileId}:${deviceId}`;
const now = Date.now();
let bucket = pinAttempts.get(key);
if (!bucket || bucket.resetAt < now) {
bucket = { count: 0, resetAt: now + PIN_ATTEMPT_WINDOW_MS };
pinAttempts.set(key, bucket);
}
if (bucket.count >= PIN_MAX_ATTEMPTS) {
throw new AppError(
"RATE_LIMITED",
"Te veel PIN-pogingen. Probeer later opnieuw.",
429
);
}
const ok = pin ? await verifyPassword(profile.pinHash, String(pin).replace(/\D/g, "")) : false;
if (!ok) {
bucket.count += 1;
throw new AppError("FORBIDDEN", "Onjuiste PIN", 403);
}
pinAttempts.delete(key);
}
await prisma.viewerDevice.update({
where: { id: deviceId },
data: { activeProfileId: profile.id },
});
return { profile: publicProfile(profile) };
}
async clearActive(deviceId: string) {
await prisma.viewerDevice.update({
where: { id: deviceId },
data: { activeProfileId: null },
});
return { ok: true };
}
}

View file

@ -3,7 +3,8 @@ import { AppError } from "../security/errors";
import { requireAdmin, type AuthenticatedRequest } from "../auth/routes";
import { PlaybackService } from "../playback/service";
import type { Config } from "../config";
import { ViewerService } from "./service";
import { ViewerService, requireProfile } from "./service";
import { ViewerProfileService } from "./profiles";
import type { DownloadService } from "../downloads/service";
import type { SubtitleSource } from "../opensubtitles/client";
import { GooglePlayAccessService } from "../google-play/service";
@ -15,6 +16,7 @@ export function registerViewerRoutes(
) {
const playback = new PlaybackService(config);
const viewers = new ViewerService(config, playback, downloads);
const profiles = new ViewerProfileService();
const googlePlay = new GooglePlayAccessService(config);
// ——— Admin: kijkers ———
@ -125,29 +127,105 @@ export function registerViewerRoutes(
return {
viewer: { id: auth.viewerId, email: auth.email, name: auth.name },
deviceId: auth.deviceId,
profile: auth.profile,
iptv: { enabled: iptv.enabled },
};
});
app.get("/api/v1/client/profiles/avatars", async (request) => {
await viewers.authFromBearer(request.headers.authorization);
return { avatars: profiles.avatars() };
});
app.get("/api/v1/client/profiles", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
return {
profiles: await profiles.list(auth.viewerId),
activeProfileId: auth.profileId,
// Alleen als het hoofdprofiel actief is (niet vanaf kale switcher)
canManage: auth.profile?.isOwner === true,
};
});
app.post(
"/api/v1/client/profiles",
{
config: { rateLimit: { max: 20, timeWindow: "1 minute" } },
},
async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const body = (request.body || {}) as {
name?: string;
avatarId?: string;
isKids?: boolean;
pin?: string | null;
};
const profile = await profiles.create(auth.viewerId, auth.profileId, body);
return { profile };
}
);
app.patch("/api/v1/client/profiles/:id", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const { id } = request.params as { id: string };
const body = (request.body || {}) as {
name?: string;
avatarId?: string;
isKids?: boolean;
pin?: string | null;
clearPin?: boolean;
};
const profile = await profiles.update(auth.viewerId, auth.profileId, id, body);
return { profile };
});
app.delete("/api/v1/client/profiles/:id", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const { id } = request.params as { id: string };
return profiles.remove(auth.viewerId, auth.profileId, id);
});
app.post(
"/api/v1/client/profiles/:id/select",
{
config: { rateLimit: { max: 30, 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 { pin?: string };
return profiles.select(auth.viewerId, auth.deviceId, id, body.pin);
}
);
app.post("/api/v1/client/profiles/clear", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
return profiles.clearActive(auth.deviceId);
});
app.get("/api/v1/client/home", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
return viewers.home(auth.viewerId);
const profileId = requireProfile(auth);
return viewers.home(profileId, auth.profile?.isKids === true);
});
app.get("/api/v1/client/movies/:id", async (request) => {
await viewers.authFromBearer(request.headers.authorization);
const auth = await viewers.authFromBearer(request.headers.authorization);
requireProfile(auth);
const { id } = request.params as { id: string };
return viewers.movieDetail(id);
return viewers.movieDetail(id, auth.profile?.isKids === true);
});
app.get("/api/v1/client/series/:id", async (request) => {
await viewers.authFromBearer(request.headers.authorization);
const auth = await viewers.authFromBearer(request.headers.authorization);
requireProfile(auth);
const { id } = request.params as { id: string };
return viewers.seriesDetail(id);
return viewers.seriesDetail(id, auth.profile?.isKids === true);
});
app.post("/api/v1/client/play", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
requireProfile(auth);
const body = request.body as { mediaFileId?: string };
if (!body.mediaFileId) throw new AppError("INVALID_REQUEST", "mediaFileId verplicht", 400);
return viewers.play(body.mediaFileId, auth.viewerId);
@ -155,6 +233,7 @@ export function registerViewerRoutes(
app.post("/api/v1/client/progress", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const profileId = requireProfile(auth);
const body = request.body as {
mediaType?: "MOVIE" | "EPISODE";
movieId?: string;
@ -166,7 +245,7 @@ export function registerViewerRoutes(
hiddenFromContinue?: boolean;
};
if (!body.mediaType) throw new AppError("INVALID_REQUEST", "mediaType verplicht", 400);
const row = await viewers.upsertProgress(auth.viewerId, {
const row = await viewers.upsertProgress(profileId, {
mediaType: body.mediaType,
movieId: body.movieId,
episodeId: body.episodeId,
@ -181,49 +260,63 @@ export function registerViewerRoutes(
app.post("/api/v1/client/progress/mark-watched", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const profileId = requireProfile(auth);
const body = request.body as { movieId?: string; episodeId?: string };
const row = await viewers.markWatched(auth.viewerId, body);
const row = await viewers.markWatched(profileId, body);
return { progress: row };
});
app.post("/api/v1/client/progress/:id/dismiss", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const profileId = requireProfile(auth);
const { id } = request.params as { id: string };
return viewers.dismissContinue(auth.viewerId, id);
return viewers.dismissContinue(profileId, id);
});
app.get("/api/v1/client/search", async (request) => {
await viewers.authFromBearer(request.headers.authorization);
const auth = await viewers.authFromBearer(request.headers.authorization);
requireProfile(auth);
const q = request.query as { q?: string; take?: string };
const query = (q.q || "").trim();
if (query.length < 1) return { results: [] };
const take = Math.min(parseInt(q.take || "48", 10) || 48, 100);
return { results: await viewers.search(query, take) };
return {
results: await viewers.search(query, take, auth.profile?.isKids === true),
};
});
app.get("/api/v1/client/catalog/:id", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const profileId = requireProfile(auth);
const { id } = request.params as { id: string };
const q = request.query as { skip?: string; take?: string };
const skip = Math.max(0, parseInt(q.skip || "0", 10) || 0);
const take = Math.min(parseInt(q.take || "60", 10) || 60, 100);
return viewers.browseCatalog(id, skip, take, auth.viewerId);
return viewers.browseCatalog(
id,
skip,
take,
profileId,
auth.profile?.isKids === true
);
});
app.get("/api/v1/client/shelves/prefs", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
return { shelves: await viewers.getShelfPrefs(auth.viewerId) };
const profileId = requireProfile(auth);
return { shelves: await viewers.getShelfPrefs(profileId) };
});
app.put("/api/v1/client/shelves/prefs", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const profileId = requireProfile(auth);
const body = request.body as {
shelves?: Array<{ shelfId: string; visible: boolean; sortOrder: number }>;
};
if (!Array.isArray(body.shelves)) {
throw new AppError("INVALID_REQUEST", "shelves array verplicht", 400);
}
return { shelves: await viewers.setShelfPrefs(auth.viewerId, body.shelves) };
return { shelves: await viewers.setShelfPrefs(profileId, body.shelves) };
});
app.get("/api/v1/client/iptv/status", async (request) => {

View file

@ -15,6 +15,13 @@ import { cleanMeta, formatMediaFileLabel } from "./media-label";
import type { DownloadService } from "../downloads/service";
import type { SubtitleSource } from "../opensubtitles/client";
import { MetadataService } from "../metadata/service";
import { ensureOwnerProfile } from "./profiles";
import {
kidsContentWhere,
kidsMovieSql,
kidsSeriesSql,
isKidsBlockedTitle,
} from "./kids-filter";
const CODE_TTL_MS = 10 * 60 * 1000;
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@ -27,8 +34,24 @@ export type AuthedViewer = {
deviceId: string;
email: string;
name: string | null;
profileId: string | null;
profile: {
id: string;
name: string;
avatarId: string;
isOwner: boolean;
isKids: boolean;
hasPin: boolean;
} | null;
};
export function requireProfile(auth: AuthedViewer): string {
if (!auth.profileId) {
throw new AppError("PROFILE_REQUIRED", "Selecteer eerst een profiel", 428);
}
return auth.profileId;
}
export function generateLinkCode(length = 8): string {
const bytes = randomBytes(length);
let out = "";
@ -69,7 +92,7 @@ export class ViewerService {
const existing = await prisma.viewerUser.findUnique({ where: { email } });
if (existing) throw new AppError("CONFLICT", "E-mail bestaat al", 409);
return prisma.viewerUser.create({
const viewer = await prisma.viewerUser.create({
data: {
email,
passwordHash: await hashPassword(input.password),
@ -85,6 +108,8 @@ export class ViewerService {
createdAt: true,
},
});
await ensureOwnerProfile(viewer.id, viewer.name);
return viewer;
}
async listViewers() {
@ -297,7 +322,10 @@ export class ViewerService {
const device = await prisma.viewerDevice.findUnique({
where: { refreshTokenHash: hashToken(token) },
include: { viewer: true },
include: {
viewer: true,
activeProfile: true,
},
});
if (!device || device.revoked) {
throw new AppError("UNAUTHORIZED", "Ongeldig of ingetrokken apparaat", 401);
@ -311,27 +339,46 @@ export class ViewerService {
data: { lastSeenAt: new Date() },
});
// Zorg dat oudere accounts altijd een hoofdprofiel hebben
await ensureOwnerProfile(device.viewer.id, device.viewer.name);
const profile =
device.activeProfile && device.activeProfile.viewerUserId === device.viewer.id
? device.activeProfile
: null;
return {
viewerId: device.viewer.id,
deviceId: device.id,
email: device.viewer.email,
name: device.viewer.name,
profileId: profile?.id ?? null,
profile: profile
? {
id: profile.id,
name: profile.name,
avatarId: profile.avatarId,
isOwner: profile.isOwner,
isKids: profile.isKids,
hasPin: !!profile.pinHash,
}
: null,
};
}
async home(viewerId: string) {
async home(profileId: string, kidsMode: boolean) {
const [shelves, prefs, continueItems, recentMovies, recentSeries, fourK, spotlight] =
await Promise.all([
prisma.libraryShelf.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
}),
prisma.viewerShelfPreference.findMany({ where: { viewerUserId: viewerId } }),
this.continueWatching(viewerId),
this.recentMovies(48),
this.recentSeries(36),
this.fourKMovies(36),
this.spotlight(12),
prisma.viewerShelfPreference.findMany({ where: { profileId } }),
this.continueWatching(profileId, kidsMode),
this.recentMovies(48, 0, kidsMode),
this.recentSeries(36, 0, kidsMode),
this.fourKMovies(36, 0, kidsMode),
this.spotlight(12, kidsMode),
]);
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
@ -401,7 +448,7 @@ export class ViewerService {
}
for (const shelf of ordered) {
const items = await this.shelfItems(shelf.id, shelf.kind, 48);
const items = await this.shelfItems(shelf.id, shelf.kind, 48, kidsMode);
if (items.length) {
const homepageLabel = shelf.homepageName?.trim();
rows.push({
@ -423,11 +470,14 @@ export class ViewerService {
}
/** Nieuwste titels (op bestand-mtime van de node) voor de home billboard. */
async spotlight(take: number) {
async spotlight(take: number, kidsMode = false) {
const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty;
const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty;
const movieIds = await prisma.$queryRaw<Array<{ movie_id: string; added_at: Date }>>`
SELECT mf.movie_id, MAX(mf.discovered_at) AS added_at
FROM media_files mf
WHERE mf.available = true AND mf.movie_id IS NOT NULL
${kidsMovie}
GROUP BY mf.movie_id
ORDER BY added_at DESC, MAX(mf.discovered_at) DESC
LIMIT ${take * 2}
@ -437,6 +487,7 @@ export class ViewerService {
FROM media_files mf
INNER JOIN episodes e ON e.id = mf.episode_id
WHERE mf.available = true AND mf.episode_id IS NOT NULL
${kidsSeries}
GROUP BY e.series_id
ORDER BY added_at DESC, MAX(mf.discovered_at) DESC
LIMIT ${take * 2}
@ -517,13 +568,16 @@ export class ViewerService {
.map(({ addedAt: _a, ...rest }) => rest);
}
async recentMovies(take: number, skip = 0) {
const page = await this.moviesByRecency(skip, take, Prisma.empty, { cap: RECENT_CATALOG_CAP });
async recentMovies(take: number, skip = 0, kidsMode = false) {
const page = await this.moviesByRecency(skip, take, Prisma.empty, {
cap: RECENT_CATALOG_CAP,
kidsMode,
});
return page.items;
}
async recentSeries(take: number, skip = 0) {
const page = await this.seriesByRecency(skip, take, { cap: RECENT_CATALOG_CAP });
async recentSeries(take: number, skip = 0, kidsMode = false) {
const page = await this.seriesByRecency(skip, take, { cap: RECENT_CATALOG_CAP, kidsMode });
return page.items;
}
@ -535,15 +589,17 @@ export class ViewerService {
skip: number,
take: number,
extraWhereSql: Prisma.Sql = Prisma.empty,
opts: { cap?: number } = {}
opts: { cap?: number; kidsMode?: boolean } = {}
) {
const cap = opts.cap;
const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty;
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id
FROM media_files mf
WHERE mf.available = true
AND mf.movie_id IS NOT NULL
${extraWhereSql}
${kidsSql}
GROUP BY mf.movie_id
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC, MIN(mf.release_name) ASC
${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty}
@ -595,14 +651,16 @@ export class ViewerService {
private async seriesByRecency(
skip: number,
take: number,
opts: { cap?: number } = {}
opts: { cap?: number; kidsMode?: boolean } = {}
) {
const cap = opts.cap;
const kidsSql = opts.kidsMode ? kidsSeriesSql() : Prisma.empty;
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
SELECT e.series_id
FROM media_files mf
INNER JOIN episodes e ON e.id = mf.episode_id
WHERE mf.available = true AND mf.episode_id IS NOT NULL
${kidsSql}
GROUP BY e.series_id
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty}
@ -644,7 +702,7 @@ export class ViewerService {
};
}
async fourKMovies(take: number, skip = 0) {
async fourKMovies(take: number, skip = 0, kidsMode = false) {
const page = await this.moviesByRecency(
skip,
take,
@ -653,13 +711,13 @@ export class ViewerService {
OR mf.resolution ILIKE '%4K%'
OR mf.resolution ILIKE '%UHD%'
)`,
{ cap: RECENT_CATALOG_CAP }
{ cap: RECENT_CATALOG_CAP, kidsMode }
);
return page.items;
}
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
const page = await this.shelfItemsPage(shelfId, kind, 0, take);
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number, kidsMode = false) {
const page = await this.shelfItemsPage(shelfId, kind, 0, take, kidsMode);
return page.items;
}
@ -668,8 +726,11 @@ export class ViewerService {
shelfId: string,
kind: "MOVIE" | "SERIES",
skip: number,
take: number
take: number,
kidsMode = false
) {
const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty;
const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty;
if (kind === "MOVIE") {
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count
@ -677,6 +738,7 @@ export class ViewerService {
WHERE mf.shelf_id = ${shelfId}
AND mf.available = true
AND mf.movie_id IS NOT NULL
${kidsMovie}
`;
const total = Number(countRow?.count ?? 0);
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
@ -685,6 +747,7 @@ export class ViewerService {
WHERE mf.shelf_id = ${shelfId}
AND mf.available = true
AND mf.movie_id IS NOT NULL
${kidsMovie}
GROUP BY mf.movie_id
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
OFFSET ${skip}
@ -728,6 +791,7 @@ export class ViewerService {
WHERE mf.shelf_id = ${shelfId}
AND mf.available = true
AND mf.episode_id IS NOT NULL
${kidsSeries}
`;
const total = Number(countRow?.count ?? 0);
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
@ -737,6 +801,7 @@ export class ViewerService {
WHERE mf.shelf_id = ${shelfId}
AND mf.available = true
AND mf.episode_id IS NOT NULL
${kidsSeries}
GROUP BY e.series_id
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
OFFSET ${skip}
@ -764,10 +829,10 @@ export class ViewerService {
};
}
async continueWatching(viewerId: string) {
async continueWatching(profileId: string, kidsMode = false) {
const rows = await prisma.watchProgress.findMany({
where: {
viewerUserId: viewerId,
profileId,
hiddenFromContinue: false,
completed: false,
positionMs: { gt: 30_000 },
@ -781,6 +846,7 @@ export class ViewerService {
if (p.mediaType === "MOVIE" && p.movieId) {
const movie = await prisma.movie.findUnique({ where: { id: p.movieId } });
if (!movie) continue;
if (kidsMode && isKidsBlockedTitle(movie)) continue;
const file = await prisma.mediaFile.findFirst({
where: { movieId: movie.id, available: true },
orderBy: { updatedAt: "desc" },
@ -803,6 +869,7 @@ export class ViewerService {
include: { series: true },
});
if (!episode) continue;
if (kidsMode && isKidsBlockedTitle(episode.series)) continue;
const file = await prisma.mediaFile.findFirst({
where: { episodeId: episode.id, available: true },
orderBy: { updatedAt: "desc" },
@ -829,7 +896,7 @@ export class ViewerService {
}
async upsertProgress(
viewerId: string,
profileId: string,
body: {
mediaType: "MOVIE" | "EPISODE";
movieId?: string;
@ -851,10 +918,10 @@ export class ViewerService {
if (!body.movieId) throw new AppError("INVALID_REQUEST", "movieId verplicht", 400);
return prisma.watchProgress.upsert({
where: {
viewerUserId_movieId: { viewerUserId: viewerId, movieId: body.movieId },
profileId_movieId: { profileId, movieId: body.movieId },
},
create: {
viewerUserId: viewerId,
profileId,
mediaType: "MOVIE",
movieId: body.movieId,
positionMs,
@ -885,10 +952,10 @@ export class ViewerService {
}
return prisma.watchProgress.upsert({
where: {
viewerUserId_episodeId: { viewerUserId: viewerId, episodeId: body.episodeId },
profileId_episodeId: { profileId, episodeId: body.episodeId },
},
create: {
viewerUserId: viewerId,
profileId,
mediaType: "EPISODE",
episodeId: body.episodeId,
seriesId,
@ -913,9 +980,9 @@ export class ViewerService {
});
}
async markWatched(viewerId: string, opts: { movieId?: string; episodeId?: string }) {
async markWatched(profileId: string, opts: { movieId?: string; episodeId?: string }) {
if (opts.movieId) {
return this.upsertProgress(viewerId, {
return this.upsertProgress(profileId, {
mediaType: "MOVIE",
movieId: opts.movieId,
positionMs: 1,
@ -925,7 +992,7 @@ export class ViewerService {
});
}
if (opts.episodeId) {
return this.upsertProgress(viewerId, {
return this.upsertProgress(profileId, {
mediaType: "EPISODE",
episodeId: opts.episodeId,
positionMs: 1,
@ -937,9 +1004,9 @@ export class ViewerService {
throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400);
}
async dismissContinue(viewerId: string, progressId: string) {
async dismissContinue(profileId: string, progressId: string) {
const row = await prisma.watchProgress.findFirst({
where: { id: progressId, viewerUserId: viewerId },
where: { id: progressId, profileId },
});
if (!row) throw new AppError("NOT_FOUND", "Niet gevonden", 404);
await prisma.watchProgress.update({
@ -949,14 +1016,16 @@ export class ViewerService {
return { ok: true };
}
async search(query: string, take: number) {
async search(query: string, take: number, kidsMode = false) {
const q = query.trim();
if (!q) return [];
const kidsWhere = kidsMode ? kidsContentWhere() : {};
const [movies, series] = await Promise.all([
prisma.movie.findMany({
where: {
title: { contains: q, mode: "insensitive" },
mediaFiles: { some: { available: true } },
...kidsWhere,
},
orderBy: { title: "asc" },
take,
@ -972,6 +1041,7 @@ export class ViewerService {
where: {
title: { contains: q, mode: "insensitive" },
episodes: { some: { mediaFiles: { some: { available: true } } } },
...kidsWhere,
},
orderBy: { title: "asc" },
take,
@ -1000,12 +1070,18 @@ export class ViewerService {
.slice(0, take);
}
async browseCatalog(catalogId: string, skip: number, take: number, viewerId?: string) {
async browseCatalog(
catalogId: string,
skip: number,
take: number,
profileId?: string,
kidsMode = false
) {
const id = catalogId.trim().toLowerCase();
if (id === "continue-watching" || id === "continue" || id === "verder-kijken") {
if (!viewerId) throw new AppError("UNAUTHORIZED", "Niet ingelogd", 401);
const all = await this.continueWatching(viewerId);
if (!profileId) throw new AppError("UNAUTHORIZED", "Niet ingelogd", 401);
const all = await this.continueWatching(profileId, kidsMode);
return {
id: "continue-watching",
title: "Verder kijken",
@ -1016,7 +1092,7 @@ export class ViewerService {
}
if (id === "movies" || id === "films") {
const page = await this.moviesByRecency(skip, take);
const page = await this.moviesByRecency(skip, take, Prisma.empty, { kidsMode });
return {
id,
title: "Films",
@ -1027,7 +1103,7 @@ export class ViewerService {
}
if (id === "series" || id === "shows") {
const page = await this.seriesByRecency(skip, take);
const page = await this.seriesByRecency(skip, take, { kidsMode });
return {
id,
title: "Series",
@ -1040,6 +1116,7 @@ export class ViewerService {
if (id === "recent-movies") {
const page = await this.moviesByRecency(skip, take, Prisma.empty, {
cap: RECENT_CATALOG_CAP,
kidsMode,
});
return {
id,
@ -1051,7 +1128,10 @@ export class ViewerService {
}
if (id === "recent-series") {
const page = await this.seriesByRecency(skip, take, { cap: RECENT_CATALOG_CAP });
const page = await this.seriesByRecency(skip, take, {
cap: RECENT_CATALOG_CAP,
kidsMode,
});
return {
id,
title: "Recent toegevoegde series",
@ -1070,7 +1150,7 @@ export class ViewerService {
OR mf.resolution ILIKE '%4K%'
OR mf.resolution ILIKE '%UHD%'
)`,
{ cap: RECENT_CATALOG_CAP }
{ cap: RECENT_CATALOG_CAP, kidsMode }
);
return {
id,
@ -1095,7 +1175,8 @@ export class ViewerService {
shelf.id,
shelf.kind as "MOVIE" | "SERIES",
skip,
take
take,
kidsMode
);
return {
id: shelf.slug,
@ -1106,13 +1187,13 @@ export class ViewerService {
};
}
async getShelfPrefs(viewerId: string) {
async getShelfPrefs(profileId: string) {
const shelves = await prisma.libraryShelf.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
});
const prefs = await prisma.viewerShelfPreference.findMany({
where: { viewerUserId: viewerId },
where: { profileId },
});
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
return shelves.map((s, idx) => {
@ -1129,16 +1210,16 @@ export class ViewerService {
}
async setShelfPrefs(
viewerId: string,
profileId: string,
items: Array<{ shelfId: string; visible: boolean; sortOrder: number }>
) {
for (const item of items) {
await prisma.viewerShelfPreference.upsert({
where: {
viewerUserId_shelfId: { viewerUserId: viewerId, shelfId: item.shelfId },
profileId_shelfId: { profileId, shelfId: item.shelfId },
},
create: {
viewerUserId: viewerId,
profileId,
shelfId: item.shelfId,
visible: item.visible,
sortOrder: item.sortOrder,
@ -1149,10 +1230,10 @@ export class ViewerService {
},
});
}
return this.getShelfPrefs(viewerId);
return this.getShelfPrefs(profileId);
}
async movieDetail(movieId: string) {
async movieDetail(movieId: string, kidsMode = false) {
const movie = await prisma.movie.findUnique({
where: { id: movieId },
include: {
@ -1164,6 +1245,9 @@ export class ViewerService {
},
});
if (!movie) throw new AppError("NOT_FOUND", "Film niet gevonden", 404);
if (kidsMode && isKidsBlockedTitle(movie)) {
throw new AppError("FORBIDDEN", "Niet beschikbaar in kids-profiel", 403);
}
const extras = await this.metadata.getStremioMetaExtras(
"movie",
@ -1217,7 +1301,7 @@ export class ViewerService {
};
}
async seriesDetail(seriesId: string) {
async seriesDetail(seriesId: string, kidsMode = false) {
const series = await prisma.series.findUnique({
where: { id: seriesId },
include: {
@ -1239,6 +1323,9 @@ export class ViewerService {
},
});
if (!series) throw new AppError("NOT_FOUND", "Serie niet gevonden", 404);
if (kidsMode && isKidsBlockedTitle(series)) {
throw new AppError("FORBIDDEN", "Niet beschikbaar in kids-profiel", 403);
}
const extras = await this.metadata.getStremioMetaExtras(
"series",