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:
parent
4cd4347daf
commit
f58a088156
42 changed files with 4940 additions and 745 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 ?: "🙂"
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ?: "🙂"
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
74
apps/master-api/src/viewer/kids-filter.ts
Normal file
74
apps/master-api/src/viewer/kids-filter.ts
Normal 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()));
|
||||
}
|
||||
313
apps/master-api/src/viewer/profiles.ts
Normal file
313
apps/master-api/src/viewer/profiles.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue