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"
|
applicationId = "nl.vonas.mediacluster"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 4
|
versionCode = 9
|
||||||
versionName = "1.0.3"
|
versionName = "1.0.8"
|
||||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
import nl.vonas.mediacluster.tv.ui.McNav
|
import nl.vonas.mediacluster.tv.ui.McNav
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme
|
import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme
|
||||||
|
|
||||||
|
|
@ -13,6 +14,10 @@ class MainActivity : ComponentActivity() {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
volumeControlStream = AudioManager.STREAM_MUSIC
|
volumeControlStream = AudioManager.STREAM_MUSIC
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
isAppearanceLightStatusBars = false
|
||||||
|
isAppearanceLightNavigationBars = false
|
||||||
|
}
|
||||||
setContent {
|
setContent {
|
||||||
MediaClusterTheme {
|
MediaClusterTheme {
|
||||||
McNav()
|
McNav()
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,60 @@ class ApiClient(
|
||||||
Unit
|
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 =
|
suspend fun searchSubtitles(mediaFileId: String, languages: String = "nl,en"): SubtitleSearchResult =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val id = java.net.URLEncoder.encode(mediaFileId, "UTF-8")
|
val id = java.net.URLEncoder.encode(mediaFileId, "UTF-8")
|
||||||
|
|
@ -263,6 +317,7 @@ class ApiClient(
|
||||||
parseObject(execute(req)) { o ->
|
parseObject(execute(req)) { o ->
|
||||||
ClientMe(
|
ClientMe(
|
||||||
iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true,
|
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) {
|
suspend fun iptvChannels(categoryId: String? = null, includeEpg: Boolean = false): List<IptvChannel> = withContext(Dispatchers.IO) {
|
||||||
val params = buildList {
|
val params = buildList {
|
||||||
categoryId?.let { add("categoryId=${java.net.URLEncoder.encode(it, "UTF-8")}") }
|
categoryId?.let { add("categoryId=${java.net.URLEncoder.encode(it, "UTF-8")}") }
|
||||||
|
|
@ -302,10 +366,10 @@ class ApiClient(
|
||||||
IptvChannel(
|
IptvChannel(
|
||||||
streamId = c.getInt("streamId"),
|
streamId = c.getInt("streamId"),
|
||||||
name = c.getString("name"),
|
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") },
|
number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") },
|
||||||
categoryId = c.optString("categoryId").ifBlank { null },
|
categoryId = c.optClean("categoryId"),
|
||||||
nowTitle = c.optString("nowTitle").ifBlank { null },
|
nowTitle = c.optClean("nowTitle"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -341,25 +405,47 @@ class ApiClient(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vul ontbrekende now-titels aan (server-batch of parallel per zender). */
|
/** Vul ontbrekende now-titels aan; [onBatch] voor live UI-updates. */
|
||||||
suspend fun fetchMissingNowTitles(channels: List<IptvChannel>): Map<Int, String> {
|
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>()
|
val result = linkedMapOf<Int, String>()
|
||||||
channels.forEach { ch ->
|
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) }
|
val missing = channels.filter { !result.containsKey(it.streamId) }
|
||||||
if (missing.isEmpty()) return result
|
if (missing.isEmpty()) return result
|
||||||
|
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
missing.chunked(10).forEach { batch ->
|
missing.chunked(12).forEach { batch ->
|
||||||
|
val chunk = linkedMapOf<Int, String>()
|
||||||
batch.map { ch ->
|
batch.map { ch ->
|
||||||
async(Dispatchers.IO) {
|
async(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
iptvEpg(ch.streamId).now?.title?.takeIf { it.isNotBlank() }
|
clean(iptvEpg(ch.streamId).now?.title)
|
||||||
}.getOrNull()?.let { ch.streamId to it }
|
}.getOrNull()?.let { ch.streamId to it }
|
||||||
}
|
}
|
||||||
}.awaitAll().forEach { pair ->
|
}.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? {
|
private fun JSONObject.toEpgProgram(): EpgProgram? {
|
||||||
val title = optString("title").ifBlank { return null }
|
val title = optClean("title") ?: return null
|
||||||
return EpgProgram(
|
return EpgProgram(
|
||||||
title = title,
|
title = title,
|
||||||
start = optString("start").ifBlank { null },
|
start = optClean("start"),
|
||||||
end = optString("end").ifBlank { null },
|
end = optClean("end"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -500,6 +586,22 @@ data class ShelfPref(
|
||||||
|
|
||||||
data class ClientMe(
|
data class ClientMe(
|
||||||
val iptvEnabled: Boolean,
|
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(
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import nl.vonas.mediacluster.BuildConfig
|
import nl.vonas.mediacluster.BuildConfig
|
||||||
|
import org.json.JSONArray
|
||||||
|
|
||||||
private val Context.dataStore by preferencesDataStore("mc_mobile")
|
private val Context.dataStore by preferencesDataStore("mc_mobile")
|
||||||
|
|
||||||
class SessionStore(private val context: Context) {
|
class SessionStore(private val context: Context) {
|
||||||
private val tokenKey = stringPreferencesKey("refresh_token")
|
private val tokenKey = stringPreferencesKey("refresh_token")
|
||||||
private val apiKey = stringPreferencesKey("api_base")
|
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 refreshToken: Flow<String?> = context.dataStore.data.map { it[tokenKey] }
|
||||||
val apiBase: Flow<String> = context.dataStore.data.map {
|
val apiBase: Flow<String> = context.dataStore.data.map {
|
||||||
it[apiKey] ?: BuildConfig.DEFAULT_API_BASE
|
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?) {
|
suspend fun setToken(token: String?) {
|
||||||
context.dataStore.edit { prefs ->
|
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) {
|
suspend fun setApiBase(url: String) {
|
||||||
context.dataStore.edit { it[apiKey] = url.trim().trimEnd('/') }
|
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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
|
@ -35,8 +36,10 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
import nl.vonas.mediacluster.tv.data.MediaItem
|
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.McButton
|
||||||
import nl.vonas.mediacluster.tv.ui.components.PosterCard
|
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.McColors
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
||||||
|
|
||||||
|
|
@ -147,7 +150,11 @@ fun BrowseScreen(
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Text(
|
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,
|
color = McColors.TextDim,
|
||||||
fontSize = McDimens.meta,
|
fontSize = McDimens.meta,
|
||||||
)
|
)
|
||||||
|
|
@ -159,7 +166,33 @@ fun BrowseScreen(
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
|
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(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = minCell),
|
columns = GridCells.Adaptive(minSize = minCell),
|
||||||
state = gridState,
|
state = gridState,
|
||||||
|
|
@ -197,4 +230,6 @@ fun BrowseScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -368,39 +368,11 @@ fun MovieDetailScreen(
|
||||||
) {
|
) {
|
||||||
DetailLogoOrTitle(logoUrl, title)
|
DetailLogoOrTitle(logoUrl, title)
|
||||||
MetaChips(metaParts)
|
MetaChips(metaParts)
|
||||||
|
if (versions.size > 1) {
|
||||||
SelectedQualityLine(selected)
|
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(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|
@ -416,13 +388,46 @@ fun MovieDetailScreen(
|
||||||
openYoutubeTrailer(context, trailerKey!!)
|
openYoutubeTrailer(context, trailerKey!!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DetailActionButton("Als gekeken") {
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
DetailActionButton("Markeer als gezien") {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { api.markWatched(movieId = movieId) }
|
runCatching { api.markWatched(movieId = movieId) }
|
||||||
onBack()
|
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) {
|
if (versions.size > 1) {
|
||||||
Text(
|
Text(
|
||||||
"Kwaliteit",
|
"Kwaliteit",
|
||||||
|
|
@ -447,6 +452,7 @@ fun MovieDetailScreen(
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
StatusBarGlassOverlay(Modifier.align(Alignment.TopCenter))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -569,18 +575,6 @@ fun SeriesDetailScreen(
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
DetailLogoOrTitle(logoUrl, title)
|
DetailLogoOrTitle(logoUrl, title)
|
||||||
MetaChips(metaParts)
|
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))
|
Spacer(Modifier.height(4.dp))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
episodes.firstOrNull()?.let { ep ->
|
episodes.firstOrNull()?.let { ep ->
|
||||||
|
|
@ -592,6 +586,18 @@ fun SeriesDetailScreen(
|
||||||
DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) }
|
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) {
|
if (seasons.size > 1) {
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text("Seizoenen", color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.SemiBold)
|
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 player = remember { buildLiveTvPlayer(context) }
|
||||||
val playerFocus = remember { FocusRequester() }
|
val playerFocus = remember { FocusRequester() }
|
||||||
val menuScope = rememberCoroutineScope()
|
val menuScope = rememberCoroutineScope()
|
||||||
|
RememberPlayerImmersiveMode()
|
||||||
|
|
||||||
var currentStreamId by remember { mutableIntStateOf(streamId) }
|
var currentStreamId by remember { mutableIntStateOf(streamId) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
package nl.vonas.mediacluster.tv.ui
|
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.app.Activity
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -15,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
|
@ -33,6 +41,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -55,20 +64,28 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Switch
|
import androidx.tv.material3.Switch
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import coil.imageLoader
|
||||||
|
import coil.request.ImageRequest
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import nl.vonas.mediacluster.BuildConfig
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
import nl.vonas.mediacluster.tv.data.HomePayload
|
import nl.vonas.mediacluster.tv.data.HomePayload
|
||||||
import nl.vonas.mediacluster.tv.data.MediaItem
|
import nl.vonas.mediacluster.tv.data.MediaItem
|
||||||
import nl.vonas.mediacluster.tv.data.PollResult
|
import nl.vonas.mediacluster.tv.data.PollResult
|
||||||
import nl.vonas.mediacluster.tv.data.SessionStore
|
import nl.vonas.mediacluster.tv.data.SessionStore
|
||||||
import nl.vonas.mediacluster.tv.data.ShelfPref
|
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.MainTab
|
||||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||||
import nl.vonas.mediacluster.tv.ui.components.PosterCard
|
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.SubNavItem
|
||||||
import nl.vonas.mediacluster.tv.ui.components.TopNavBar
|
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.McColors
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
||||||
|
|
||||||
|
|
@ -81,6 +98,9 @@ fun McNav() {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var ready by remember { mutableStateOf(false) }
|
var ready by remember { mutableStateOf(false) }
|
||||||
var authed 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)
|
// Blijft behouden als main uit compositie gaat (bijv. live player)
|
||||||
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
|
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
|
||||||
var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) }
|
var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|
@ -95,6 +115,18 @@ fun McNav() {
|
||||||
val token = store.refreshToken.first()
|
val token = store.refreshToken.first()
|
||||||
api.configure(base, token)
|
api.configure(base, token)
|
||||||
authed = !token.isNullOrBlank()
|
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
|
ready = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,11 +135,35 @@ fun McNav() {
|
||||||
return
|
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") {
|
composable("link") {
|
||||||
LinkScreen(api, store) {
|
LinkScreen(api, store) {
|
||||||
authed = true
|
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") {
|
composable("main") {
|
||||||
|
|
@ -121,6 +177,8 @@ fun McNav() {
|
||||||
onTvLastStreamChange = { tvLastStreamId = it },
|
onTvLastStreamChange = { tvLastStreamId = it },
|
||||||
tvFocusNonce = tvFocusNonce,
|
tvFocusNonce = tvFocusNonce,
|
||||||
onTvFocusContent = { tvFocusNonce++ },
|
onTvFocusContent = { tvFocusNonce++ },
|
||||||
|
profileAvatarId = profileAvatarId,
|
||||||
|
profileName = profileName,
|
||||||
onOpenMovie = { nav.navigate("movie/$it") },
|
onOpenMovie = { nav.navigate("movie/$it") },
|
||||||
onOpenSeries = { nav.navigate("series/$it") },
|
onOpenSeries = { nav.navigate("series/$it") },
|
||||||
onBrowse = { catalogId, _title ->
|
onBrowse = { catalogId, _title ->
|
||||||
|
|
@ -137,11 +195,25 @@ fun McNav() {
|
||||||
if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name
|
if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name
|
||||||
nav.navigate("live/$streamId")
|
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 = {
|
onLogout = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
store.setToken(null)
|
store.setToken(null)
|
||||||
api.configure(store.apiBase.first(), null)
|
api.configure(store.apiBase.first(), null)
|
||||||
authed = false
|
authed = false
|
||||||
|
hasProfile = false
|
||||||
nav.navigate("link") { popUpTo(0) { inclusive = true } }
|
nav.navigate("link") { popUpTo(0) { inclusive = true } }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -246,11 +318,15 @@ private fun MainHub(
|
||||||
onTvLastStreamChange: (Int?) -> Unit,
|
onTvLastStreamChange: (Int?) -> Unit,
|
||||||
tvFocusNonce: Int,
|
tvFocusNonce: Int,
|
||||||
onTvFocusContent: () -> Unit,
|
onTvFocusContent: () -> Unit,
|
||||||
|
profileAvatarId: String?,
|
||||||
|
profileName: String?,
|
||||||
onOpenMovie: (String) -> Unit,
|
onOpenMovie: (String) -> Unit,
|
||||||
onOpenSeries: (String) -> Unit,
|
onOpenSeries: (String) -> Unit,
|
||||||
onBrowse: (catalogId: String, title: String) -> Unit,
|
onBrowse: (catalogId: String, title: String) -> Unit,
|
||||||
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
|
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
|
||||||
onPlayLive: (Int) -> Unit,
|
onPlayLive: (Int) -> Unit,
|
||||||
|
onSwitchProfile: () -> Unit,
|
||||||
|
onManageProfiles: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var iptvEnabled by remember { mutableStateOf(false) }
|
var iptvEnabled by remember { mutableStateOf(false) }
|
||||||
|
|
@ -370,7 +446,14 @@ private fun MainHub(
|
||||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
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(
|
MainTab.Home -> HomeScreen(
|
||||||
api = api,
|
api = api,
|
||||||
listState = homeListState,
|
listState = homeListState,
|
||||||
|
|
@ -419,10 +502,46 @@ private fun MainHub(
|
||||||
onTabChange(MainTab.Home)
|
onTabChange(MainTab.Home)
|
||||||
focusTopNav()
|
focusTopNav()
|
||||||
},
|
},
|
||||||
|
onSwitchProfile = onSwitchProfile,
|
||||||
|
onManageProfiles = onManageProfiles,
|
||||||
onLogout = onLogout,
|
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)) {
|
Box(Modifier.focusRequester(navFocus)) {
|
||||||
TopNavBar(
|
TopNavBar(
|
||||||
selected = tab,
|
selected = tab,
|
||||||
|
|
@ -611,20 +730,37 @@ private fun HomeScreen(
|
||||||
) {
|
) {
|
||||||
var home by remember { mutableStateOf<HomePayload?>(null) }
|
var home by remember { mutableStateOf<HomePayload?>(null) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var loading by remember { mutableStateOf(true) }
|
||||||
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
|
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
|
||||||
var menuReady by remember { mutableStateOf(false) }
|
var menuReady by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val menuFocus = remember { FocusRequester() }
|
val menuFocus = remember { FocusRequester() }
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
suspend fun reloadHome() {
|
suspend fun reloadHome() {
|
||||||
home = api.home()
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
try {
|
try {
|
||||||
reloadHome()
|
reloadHome()
|
||||||
} catch (e: Exception) {
|
} 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 {
|
val spotlight = home?.spotlight.orEmpty().ifEmpty {
|
||||||
home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty()
|
home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty()
|
||||||
}
|
}
|
||||||
|
val continueItems = home?.continueWatching.orEmpty()
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
if (loading && home == null) {
|
||||||
|
HomeSkeleton()
|
||||||
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 12.dp),
|
contentPadding = PaddingValues(bottom = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
verticalArrangement = Arrangement.spacedBy(McDimens.shelfBlockGap),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
SpotlightBillboard(
|
SpotlightBillboard(
|
||||||
|
|
@ -684,7 +824,6 @@ private fun HomeScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val continueItems = home?.continueWatching.orEmpty()
|
|
||||||
if (continueItems.isNotEmpty()) {
|
if (continueItems.isNotEmpty()) {
|
||||||
val preview = continueItems.take(5)
|
val preview = continueItems.take(5)
|
||||||
item {
|
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
|
val menuItem = continueMenu
|
||||||
|
|
@ -755,7 +903,7 @@ private fun HomeScreen(
|
||||||
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = McDimens.bodySmall)
|
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = McDimens.bodySmall)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
McButton("Markeren als gezien") {
|
McButton("Markeer als gezien") {
|
||||||
if (!menuReady) return@McButton
|
if (!menuReady) return@McButton
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|
@ -769,7 +917,7 @@ private fun HomeScreen(
|
||||||
continueMenu = null
|
continueMenu = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
McButton("Verbergen") {
|
McButton("Verberg uit lijst") {
|
||||||
if (!menuReady) return@McButton
|
if (!menuReady) return@McButton
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val pid = menuItem.progressId
|
val pid = menuItem.progressId
|
||||||
|
|
@ -810,9 +958,17 @@ private fun SpotlightBillboard(
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(Modifier.fillMaxWidth().height(billboardH)) {
|
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(
|
AsyncImage(
|
||||||
model = current.backdropUrl ?: current.posterUrl,
|
model = slide.backdropUrl ?: slide.posterUrl,
|
||||||
contentDescription = current.title,
|
contentDescription = slide.title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
|
|
@ -823,6 +979,8 @@ private fun SpotlightBillboard(
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -859,7 +1017,7 @@ private fun SpotlightBillboard(
|
||||||
fontWeight = FontWeight.Black,
|
fontWeight = FontWeight.Black,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
lineHeight = 30.sp,
|
lineHeight = 32.sp,
|
||||||
)
|
)
|
||||||
val meta = listOfNotNull(
|
val meta = listOfNotNull(
|
||||||
current.year?.toString(),
|
current.year?.toString(),
|
||||||
|
|
@ -871,7 +1029,7 @@ private fun SpotlightBillboard(
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
if (current.id.isNotBlank()) {
|
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) })
|
McButton("Meer info", compact = true, onClick = { onOpen(current) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -905,7 +1063,17 @@ private fun ShelfBlock(
|
||||||
val posterH = McDimens.posterHeight(posterW)
|
val posterH = McDimens.posterHeight(posterW)
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Row(
|
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,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
|
@ -919,7 +1087,13 @@ private fun ShelfBlock(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (onSeeAll != null) {
|
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(
|
LazyRow(
|
||||||
|
|
@ -944,6 +1118,8 @@ private fun ShelfBlock(
|
||||||
private fun SettingsScreen(
|
private fun SettingsScreen(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onSwitchProfile: () -> Unit = {},
|
||||||
|
onManageProfiles: () -> Unit = {},
|
||||||
onLogout: () -> Unit = {},
|
onLogout: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
|
var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
|
||||||
|
|
@ -973,16 +1149,106 @@ private fun SettingsScreen(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(McColors.Bg)
|
.background(McColors.Bg)
|
||||||
.statusBarsPadding()
|
.statusBarsPadding(),
|
||||||
.padding(horizontal = McDimens.screenPad, vertical = 12.dp),
|
contentPadding = PaddingValues(
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
start = McDimens.screenPad,
|
||||||
|
end = McDimens.screenPad,
|
||||||
|
top = 12.dp,
|
||||||
|
bottom = 28.dp,
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Text("Instellingen", color = Color.White, fontSize = McDimens.titleScreen, fontWeight = FontWeight.Bold)
|
Text(
|
||||||
Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = McDimens.bodySmall)
|
"Instellingen",
|
||||||
if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
|
color = Color.White,
|
||||||
|
fontSize = McDimens.titleScreen,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SectionLabel("Account")
|
||||||
Spacer(Modifier.height(8.dp))
|
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))
|
Spacer(Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
itemsIndexed(prefs) { index, p ->
|
itemsIndexed(prefs) { index, p ->
|
||||||
|
|
@ -991,13 +1257,32 @@ private fun SettingsScreen(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(10.dp))
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.background(McColors.BgElevated)
|
.background(McColors.BgElevated)
|
||||||
.padding(12.dp),
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(p.name, color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
|
Column(Modifier.weight(1f)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
Text(
|
||||||
McButton("↑", compact = true) {
|
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) {
|
if (index > 0) {
|
||||||
val next = prefs.toMutableList()
|
val next = prefs.toMutableList()
|
||||||
val tmp = next[index - 1]
|
val tmp = next[index - 1]
|
||||||
|
|
@ -1005,8 +1290,12 @@ private fun SettingsScreen(
|
||||||
next[index] = tmp
|
next[index] = tmp
|
||||||
persist(next)
|
persist(next)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
McButton("↓", compact = true) {
|
)
|
||||||
|
SettingsIconChip(
|
||||||
|
label = "↓",
|
||||||
|
enabled = index < prefs.lastIndex,
|
||||||
|
onClick = {
|
||||||
if (index < prefs.lastIndex) {
|
if (index < prefs.lastIndex) {
|
||||||
val next = prefs.toMutableList()
|
val next = prefs.toMutableList()
|
||||||
val tmp = next[index + 1]
|
val tmp = next[index + 1]
|
||||||
|
|
@ -1014,7 +1303,9 @@ private fun SettingsScreen(
|
||||||
next[index] = tmp
|
next[index] = tmp
|
||||||
persist(next)
|
persist(next)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
Switch(
|
Switch(
|
||||||
checked = p.visible,
|
checked = p.visible,
|
||||||
onCheckedChange = { checked ->
|
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
|
package nl.vonas.mediacluster.tv.ui
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
import android.view.ViewGroup.LayoutParams
|
import android.view.ViewGroup.LayoutParams
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import android.util.Base64
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.focusable
|
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.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -95,6 +97,7 @@ fun PlayerScreen(
|
||||||
val controlsFocus = remember { FocusRequester() }
|
val controlsFocus = remember { FocusRequester() }
|
||||||
val catcherFocus = remember { FocusRequester() }
|
val catcherFocus = remember { FocusRequester() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
RememberPlayerImmersiveMode()
|
||||||
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
|
@ -387,7 +390,7 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && error == null) {
|
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) {
|
if (error != null) {
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -459,24 +462,31 @@ fun PlayerScreen(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 48.dp, vertical = 28.dp),
|
.padding(horizontal = 24.dp, vertical = 28.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Box(
|
val progress = if (durationMs > 0) {
|
||||||
Modifier
|
(positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
|
||||||
.fillMaxWidth()
|
} else {
|
||||||
.height(3.dp)
|
0f
|
||||||
.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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
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(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,41 @@ package nl.vonas.mediacluster.tv.ui
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
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.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
|
@ -39,7 +48,11 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
import nl.vonas.mediacluster.tv.data.MediaItem
|
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.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.McColors
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
||||||
|
|
||||||
|
|
@ -51,6 +64,9 @@ fun SearchScreen(
|
||||||
onOpenSeries: (String) -> Unit,
|
onOpenSeries: (String) -> Unit,
|
||||||
onAtTopBack: (() -> Unit)? = null,
|
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 query by remember { mutableStateOf("") }
|
||||||
var results by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
|
var results by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
|
||||||
var busy by remember { mutableStateOf(false) }
|
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
|
query = next
|
||||||
searchJob?.cancel()
|
searchJob?.cancel()
|
||||||
searchJob = scope.launch {
|
searchJob = scope.launch {
|
||||||
delay(250)
|
delay(220)
|
||||||
if (next.isBlank()) {
|
if (next.isBlank()) {
|
||||||
results = emptyList()
|
results = emptyList()
|
||||||
|
busy = false
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
busy = true
|
busy = true
|
||||||
error = null
|
error = null
|
||||||
try {
|
try {
|
||||||
results = api.search(next)
|
results = api.search(next)
|
||||||
|
if (saveRecent && next.trim().length >= 2) {
|
||||||
|
store.addRecentSearch(next)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = e.message
|
error = e.message ?: "Zoeken mislukt"
|
||||||
} finally {
|
} finally {
|
||||||
busy = false
|
busy = false
|
||||||
}
|
}
|
||||||
|
|
@ -111,15 +131,20 @@ fun SearchScreen(
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = query,
|
value = query,
|
||||||
onValueChange = { scheduleSearch(it) },
|
onValueChange = { runSearch(it) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
placeholder = {
|
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),
|
shape = RoundedCornerShape(10.dp),
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
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(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
focusedTextColor = Color.White,
|
focusedTextColor = Color.White,
|
||||||
unfocusedTextColor = Color.White,
|
unfocusedTextColor = Color.White,
|
||||||
|
|
@ -131,12 +156,81 @@ fun SearchScreen(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
when {
|
when {
|
||||||
error != null -> Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall)
|
query.isBlank() -> {
|
||||||
busy -> Text("Zoeken…", color = McColors.TextDim, fontSize = McDimens.bodySmall)
|
if (recent.isNotEmpty()) {
|
||||||
query.isNotBlank() && results.isEmpty() ->
|
Row(
|
||||||
Text("Geen resultaten", color = McColors.TextDim, fontSize = McDimens.bodySmall)
|
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(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = minCell),
|
columns = GridCells.Adaptive(minSize = minCell),
|
||||||
state = gridState,
|
state = gridState,
|
||||||
|
|
@ -159,4 +253,6 @@ 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.IptvCategory
|
||||||
import nl.vonas.mediacluster.tv.data.IptvChannel
|
import nl.vonas.mediacluster.tv.data.IptvChannel
|
||||||
import nl.vonas.mediacluster.tv.data.IptvEpg
|
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.McColors
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
import nl.vonas.mediacluster.tv.ui.theme.McDimens
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
@ -134,12 +134,19 @@ fun TvScreen(
|
||||||
epgNowTitles.clear()
|
epgNowTitles.clear()
|
||||||
try {
|
try {
|
||||||
val loaded = api.iptvChannels(catId, includeEpg = true)
|
val loaded = api.iptvChannels(catId, includeEpg = true)
|
||||||
val titles = api.fetchMissingNowTitles(loaded)
|
|
||||||
channels = 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 } }
|
val focusId = lastStreamId?.takeIf { id -> loaded.any { it.streamId == id } }
|
||||||
?: loaded.firstOrNull()?.streamId
|
?: loaded.firstOrNull()?.streamId
|
||||||
focusId?.let { loadEpg(it) }
|
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) {
|
} catch (e: Exception) {
|
||||||
error = e.message
|
error = e.message
|
||||||
channels = emptyList()
|
channels = emptyList()
|
||||||
|
|
@ -225,7 +232,7 @@ fun TvScreen(
|
||||||
|
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text(
|
Text(
|
||||||
"Tik om te selecteren · nogmaals om te kijken",
|
"Tik nogmaals om te kijken",
|
||||||
color = Color(0xFF777777),
|
color = Color(0xFF777777),
|
||||||
fontSize = McDimens.caption,
|
fontSize = McDimens.caption,
|
||||||
)
|
)
|
||||||
|
|
@ -239,7 +246,10 @@ fun TvScreen(
|
||||||
}
|
}
|
||||||
channels.isEmpty() -> {
|
channels.isEmpty() -> {
|
||||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
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 -> {
|
else -> {
|
||||||
|
|
@ -252,7 +262,8 @@ fun TvScreen(
|
||||||
items(channels, key = { it.streamId }) { ch ->
|
items(channels, key = { it.streamId }) { ch ->
|
||||||
ChannelRow(
|
ChannelRow(
|
||||||
channel = ch,
|
channel = ch,
|
||||||
nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle,
|
nowTitle = (epgNowTitles[ch.streamId] ?: ch.nowTitle)
|
||||||
|
?.takeUnless { it.equals("null", ignoreCase = true) },
|
||||||
isSelected = selectedStreamId == ch.streamId,
|
isSelected = selectedStreamId == ch.streamId,
|
||||||
isLastWatched = ch.streamId == lastStreamId,
|
isLastWatched = ch.streamId == lastStreamId,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
@ -440,7 +451,7 @@ private fun MobileEpgCard(
|
||||||
) {
|
) {
|
||||||
if (epg == null) {
|
if (epg == null) {
|
||||||
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -469,7 +480,20 @@ private fun MobileEpgCard(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f),
|
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))
|
Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider))
|
||||||
|
|
@ -500,7 +524,7 @@ private fun MobileEpgCard(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (epg.now == null && epg.next == null) {
|
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
|
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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
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.Box
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
@ -41,15 +48,26 @@ fun McButton(
|
||||||
focusRequester: FocusRequester? = null,
|
focusRequester: FocusRequester? = null,
|
||||||
onClick: () -> Unit,
|
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 bg = if (primary) Color.White else Color(0x33FFFFFF)
|
||||||
val fg = if (primary) Color.Black else Color.White
|
val fg = if (primary) Color.Black else Color.White
|
||||||
val hPad = if (compact) 12.dp else 16.dp
|
val hPad = if (compact) 12.dp else 16.dp
|
||||||
val vPad = if (compact) 8.dp else 10.dp
|
val vPad = if (compact) 8.dp else 10.dp
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(bg)
|
.background(bg)
|
||||||
.clickable(onClick = onClick)
|
.clickable(interactionSource = interaction, indication = null, onClick = onClick)
|
||||||
.padding(horizontal = hPad, vertical = vPad),
|
.padding(horizontal = hPad, vertical = vPad),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
|
@ -98,12 +116,26 @@ fun PosterCard(
|
||||||
.aspectRatio(2f / 3f)
|
.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(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.then(sizeModifier)
|
.then(sizeModifier)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(Color(0xFF1A1A1A))
|
.background(Color(0xFF1A1A1A))
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
|
interactionSource = interaction,
|
||||||
|
indication = null,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
),
|
),
|
||||||
|
|
@ -115,6 +147,13 @@ fun PosterCard(
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
if (progress > 0.02f) {
|
if (progress > 0.02f) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(3.dp)
|
||||||
|
.background(Color(0x66FFFFFF)),
|
||||||
|
)
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomStart)
|
.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 screenPad: Dp = 16.dp
|
||||||
val screenPadTight: Dp = 12.dp
|
val screenPadTight: Dp = 12.dp
|
||||||
val shelfGap: Dp = 8.dp
|
val shelfGap: Dp = 8.dp
|
||||||
|
val shelfBlockGap: Dp = 14.dp
|
||||||
val gridGap: Dp = 8.dp
|
val gridGap: Dp = 8.dp
|
||||||
|
|
||||||
val titleHero: TextUnit = 26.sp
|
val titleHero: TextUnit = 28.sp
|
||||||
val titleScreen: TextUnit = 20.sp
|
val titleScreen: TextUnit = 22.sp
|
||||||
val titleShelf: TextUnit = 15.sp
|
val titleShelf: TextUnit = 15.sp
|
||||||
val body: TextUnit = 14.sp
|
val body: TextUnit = 14.sp
|
||||||
val bodySmall: TextUnit = 13.sp
|
val bodySmall: TextUnit = 13.sp
|
||||||
val meta: TextUnit = 12.sp
|
val meta: TextUnit = 12.sp
|
||||||
val caption: TextUnit = 11.sp
|
val caption: TextUnit = 11.sp
|
||||||
|
|
||||||
val detailPosterW: Dp = 110.dp
|
val detailPosterW: Dp = 118.dp
|
||||||
val detailPosterH: Dp = 165.dp
|
val detailPosterH: Dp = 177.dp
|
||||||
val episodeStillW: Dp = 112.dp
|
val episodeStillW: Dp = 112.dp
|
||||||
val episodeStillH: Dp = 63.dp
|
val episodeStillH: Dp = 63.dp
|
||||||
|
|
||||||
|
|
@ -71,7 +72,7 @@ object McDimens {
|
||||||
fun billboardHeight(): Dp {
|
fun billboardHeight(): Dp {
|
||||||
val h = screenHeight()
|
val h = screenHeight()
|
||||||
return remember(h) {
|
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.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.darkColorScheme
|
import androidx.tv.material3.darkColorScheme
|
||||||
|
import nl.vonas.mediacluster.tv.ui.ApplyDarkSystemBars
|
||||||
|
|
||||||
private val Scheme = darkColorScheme(
|
private val Scheme = darkColorScheme(
|
||||||
primary = McColors.Accent,
|
primary = McColors.Accent,
|
||||||
|
|
@ -16,5 +17,8 @@ private val Scheme = darkColorScheme(
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaClusterTheme(content: @Composable () -> Unit) {
|
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"
|
applicationId = "nl.vonas.mediacluster.tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 68
|
versionCode = 71
|
||||||
versionName = "0.14.5"
|
versionName = "0.14.8"
|
||||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,60 @@ class ApiClient(
|
||||||
Unit
|
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 =
|
suspend fun searchSubtitles(mediaFileId: String, languages: String = "nl,en"): SubtitleSearchResult =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val id = java.net.URLEncoder.encode(mediaFileId, "UTF-8")
|
val id = java.net.URLEncoder.encode(mediaFileId, "UTF-8")
|
||||||
|
|
@ -263,6 +317,7 @@ class ApiClient(
|
||||||
parseObject(execute(req)) { o ->
|
parseObject(execute(req)) { o ->
|
||||||
ClientMe(
|
ClientMe(
|
||||||
iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true,
|
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") },
|
number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") },
|
||||||
categoryId = c.optString("categoryId").ifBlank { null },
|
categoryId = c.optString("categoryId").ifBlank { null },
|
||||||
nowTitle = c.optString("nowTitle").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 year: Int? = null,
|
||||||
val posterUrl: String? = null,
|
val posterUrl: String? = null,
|
||||||
val backdropUrl: String? = null,
|
val backdropUrl: String? = null,
|
||||||
|
val logoUrl: String? = null,
|
||||||
val mediaFileId: String? = null,
|
val mediaFileId: String? = null,
|
||||||
val seriesId: String? = null,
|
val seriesId: String? = null,
|
||||||
val positionMs: Long = 0,
|
val positionMs: Long = 0,
|
||||||
|
|
@ -500,6 +557,22 @@ data class ShelfPref(
|
||||||
|
|
||||||
data class ClientMe(
|
data class ClientMe(
|
||||||
val iptvEnabled: Boolean,
|
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(
|
data class IptvCategory(
|
||||||
|
|
@ -515,6 +588,7 @@ data class IptvChannel(
|
||||||
val number: Int? = null,
|
val number: Int? = null,
|
||||||
val categoryId: String? = null,
|
val categoryId: String? = null,
|
||||||
val nowTitle: String? = null,
|
val nowTitle: String? = null,
|
||||||
|
val nextTitle: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class IptvPlayInfo(
|
data class IptvPlayInfo(
|
||||||
|
|
@ -555,6 +629,7 @@ private fun JSONArray?.toItems(): List<MediaItem> {
|
||||||
year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") },
|
year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") },
|
||||||
posterUrl = o.optString("posterUrl").ifBlank { null },
|
posterUrl = o.optString("posterUrl").ifBlank { null },
|
||||||
backdropUrl = o.optString("backdropUrl").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 },
|
mediaFileId = if (o.isNull("mediaFileId")) null else o.optString("mediaFileId").ifBlank { null },
|
||||||
seriesId = if (o.isNull("seriesId")) null else o.optString("seriesId").ifBlank { null },
|
seriesId = if (o.isNull("seriesId")) null else o.optString("seriesId").ifBlank { null },
|
||||||
positionMs = o.optLong("positionMs"),
|
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 android.content.Context
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import nl.vonas.mediacluster.tv.BuildConfig
|
import nl.vonas.mediacluster.tv.BuildConfig
|
||||||
|
import org.json.JSONArray
|
||||||
|
|
||||||
private val Context.dataStore by preferencesDataStore("mc_tv")
|
private val Context.dataStore by preferencesDataStore("mc_tv")
|
||||||
|
|
||||||
class SessionStore(private val context: Context) {
|
class SessionStore(private val context: Context) {
|
||||||
private val tokenKey = stringPreferencesKey("refresh_token")
|
private val tokenKey = stringPreferencesKey("refresh_token")
|
||||||
private val apiKey = stringPreferencesKey("api_base")
|
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 refreshToken: Flow<String?> = context.dataStore.data.map { it[tokenKey] }
|
||||||
val apiBase: Flow<String> = context.dataStore.data.map {
|
val apiBase: Flow<String> = context.dataStore.data.map {
|
||||||
it[apiKey] ?: BuildConfig.DEFAULT_API_BASE
|
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?) {
|
suspend fun setToken(token: String?) {
|
||||||
context.dataStore.edit { prefs ->
|
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) {
|
suspend fun setApiBase(url: String) {
|
||||||
context.dataStore.edit { it[apiKey] = url.trim().trimEnd('/') }
|
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 kotlinx.coroutines.launch
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
import nl.vonas.mediacluster.tv.data.MediaItem
|
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.McButton
|
||||||
import nl.vonas.mediacluster.tv.ui.components.PosterCard
|
import nl.vonas.mediacluster.tv.ui.components.PosterCard
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
|
|
@ -138,7 +139,11 @@ fun BrowseScreen(
|
||||||
Column {
|
Column {
|
||||||
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
||||||
Text(
|
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,
|
color = McColors.TextDim,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
)
|
)
|
||||||
|
|
@ -151,6 +156,19 @@ fun BrowseScreen(
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
Text(error!!, color = McColors.Danger)
|
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(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 140.dp),
|
columns = GridCells.Adaptive(minSize = 140.dp),
|
||||||
state = gridState,
|
state = gridState,
|
||||||
|
|
@ -162,8 +180,7 @@ fun BrowseScreen(
|
||||||
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
||||||
PosterCard(
|
PosterCard(
|
||||||
item = item,
|
item = item,
|
||||||
width = 140.dp,
|
fillWidth = true,
|
||||||
height = 210.dp,
|
|
||||||
modifier = Modifier.padding(6.dp),
|
modifier = Modifier.padding(6.dp),
|
||||||
onClick = {
|
onClick = {
|
||||||
val canResume = onPlayContinue != null &&
|
val canResume = onPlayContinue != null &&
|
||||||
|
|
@ -190,4 +207,6 @@ fun BrowseScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -382,36 +382,9 @@ fun MovieDetailScreen(
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
DetailLogoOrTitle(logoUrl, title)
|
DetailLogoOrTitle(logoUrl, title)
|
||||||
MetaChips(metaParts)
|
MetaChips(metaParts)
|
||||||
|
if (versions.size > 1) {
|
||||||
SelectedQualityLine(selected)
|
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))
|
Spacer(Modifier.height(4.dp))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
selected?.let { ver ->
|
selected?.let { ver ->
|
||||||
|
|
@ -424,13 +397,42 @@ fun MovieDetailScreen(
|
||||||
openYoutubeTrailer(context, trailerKey!!)
|
openYoutubeTrailer(context, trailerKey!!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DetailActionButton("Als gekeken") {
|
DetailActionButton("Markeer als gezien") {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { api.markWatched(movieId = movieId) }
|
runCatching { api.markWatched(movieId = movieId) }
|
||||||
onBack()
|
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) {
|
if (versions.size > 1) {
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,8 @@ fun LiveTvPlayerScreen(
|
||||||
var menuCategoryName by remember { mutableStateOf("Zenders") }
|
var menuCategoryName by remember { mutableStateOf("Zenders") }
|
||||||
var menuRefreshing by remember { mutableStateOf(false) }
|
var menuRefreshing by remember { mutableStateOf(false) }
|
||||||
var lastCategoryShiftAt by remember { mutableLongStateOf(0L) }
|
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) }
|
var menuLoadGen by remember { mutableIntStateOf(0) }
|
||||||
val menuCache = remember { mutableStateMapOf<String, MenuCategoryCache>() }
|
val menuCache = remember { mutableStateMapOf<String, MenuCategoryCache>() }
|
||||||
val menuEpgNow = remember { mutableStateMapOf<Int, String>() }
|
val menuEpgNow = remember { mutableStateMapOf<Int, String>() }
|
||||||
|
|
@ -203,6 +205,23 @@ fun LiveTvPlayerScreen(
|
||||||
|
|
||||||
fun cacheKey(catId: String?) = catId?.takeIf { it.isNotBlank() } ?: "__all__"
|
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) {
|
suspend fun loadMenuEpgInBackground(catId: String?, gen: Int) {
|
||||||
val key = cacheKey(catId)
|
val key = cacheKey(catId)
|
||||||
try {
|
try {
|
||||||
|
|
@ -269,6 +288,7 @@ fun LiveTvPlayerScreen(
|
||||||
}
|
}
|
||||||
if (gen != menuLoadGen) return@LaunchedEffect
|
if (gen != menuLoadGen) return@LaunchedEffect
|
||||||
menuChannels = loaded
|
menuChannels = loaded
|
||||||
|
zapChannels = loaded
|
||||||
val staleTitles = cached?.epgTitles.orEmpty()
|
val staleTitles = cached?.epgTitles.orEmpty()
|
||||||
if (staleTitles.isNotEmpty()) applyMenuEpg(staleTitles)
|
if (staleTitles.isNotEmpty()) applyMenuEpg(staleTitles)
|
||||||
menuCache[key] = MenuCategoryCache(loaded, staleTitles, System.currentTimeMillis())
|
menuCache[key] = MenuCategoryCache(loaded, staleTitles, System.currentTimeMillis())
|
||||||
|
|
@ -352,6 +372,20 @@ fun LiveTvPlayerScreen(
|
||||||
infoOverlay = true
|
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(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -367,6 +401,18 @@ fun LiveTvPlayerScreen(
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Key.DirectionUp -> {
|
Key.DirectionUp -> {
|
||||||
|
zapChannel(-1)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.DirectionDown -> {
|
||||||
|
zapChannel(1)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.DirectionRight -> {
|
||||||
|
zapChannel(1)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.DirectionLeft, Key.Menu, Key.Guide -> {
|
||||||
channelMenuOpen = true
|
channelMenuOpen = true
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
@ -479,6 +525,12 @@ fun LiveTvPlayerScreen(
|
||||||
overflow = TextOverflow.Ellipsis,
|
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
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Key.DirectionDown -> {
|
Key.MediaPlayPause -> {
|
||||||
if (menu != null) return@onPreviewKeyEvent false
|
if (menu != null) return@onPreviewKeyEvent false
|
||||||
if (!controls) {
|
togglePlay()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.MediaPlay -> {
|
||||||
|
if (menu != null) return@onPreviewKeyEvent false
|
||||||
|
player.play()
|
||||||
showControls()
|
showControls()
|
||||||
true
|
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 -> {
|
Key.DirectionLeft -> {
|
||||||
|
// Alleen seeken zonder controls; mét controls = navigatie in menu
|
||||||
if (menu != null || controls) return@onPreviewKeyEvent false
|
if (menu != null || controls) return@onPreviewKeyEvent false
|
||||||
player.seekBack()
|
player.seekBack()
|
||||||
showControls()
|
showControls()
|
||||||
|
|
@ -345,6 +366,15 @@ fun PlayerScreen(
|
||||||
showControls()
|
showControls()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
Key.DirectionDown -> {
|
||||||
|
if (menu != null) return@onPreviewKeyEvent false
|
||||||
|
if (!controls) {
|
||||||
|
showControls()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
Key.DirectionUp, Key.Menu -> {
|
Key.DirectionUp, Key.Menu -> {
|
||||||
if (menu != null) return@onPreviewKeyEvent false
|
if (menu != null) return@onPreviewKeyEvent false
|
||||||
showControls()
|
showControls()
|
||||||
|
|
@ -383,7 +413,7 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && error == null) {
|
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) {
|
if (error != null) {
|
||||||
Column(
|
Column(
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -29,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
|
@ -45,7 +47,11 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
import nl.vonas.mediacluster.tv.data.MediaItem
|
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.PosterCard
|
||||||
|
import nl.vonas.mediacluster.tv.ui.components.SectionLabel
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
|
|
||||||
private val KEYBOARD_CHARS = listOf(
|
private val KEYBOARD_CHARS = listOf(
|
||||||
|
|
@ -65,6 +71,9 @@ fun SearchScreen(
|
||||||
onOpenSeries: (String) -> Unit,
|
onOpenSeries: (String) -> Unit,
|
||||||
onAtTopBack: (() -> Unit)? = null,
|
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 query by remember { mutableStateOf("") }
|
||||||
var results by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
|
var results by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
|
||||||
var busy by remember { mutableStateOf(false) }
|
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
|
query = next
|
||||||
searchJob?.cancel()
|
searchJob?.cancel()
|
||||||
searchJob = scope.launch {
|
searchJob = scope.launch {
|
||||||
delay(220)
|
delay(220)
|
||||||
if (next.isBlank()) {
|
if (next.isBlank()) {
|
||||||
results = emptyList()
|
results = emptyList()
|
||||||
|
busy = false
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
busy = true
|
busy = true
|
||||||
error = null
|
error = null
|
||||||
try {
|
try {
|
||||||
results = api.search(next)
|
results = api.search(next)
|
||||||
|
if (saveRecent && next.trim().length >= 2) {
|
||||||
|
store.addRecentSearch(next)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = e.message
|
error = e.message ?: "Zoeken mislukt"
|
||||||
} finally {
|
} finally {
|
||||||
busy = false
|
busy = false
|
||||||
}
|
}
|
||||||
|
|
@ -214,9 +227,17 @@ fun SearchScreen(
|
||||||
when {
|
when {
|
||||||
busy -> "Zoeken…"
|
busy -> "Zoeken…"
|
||||||
error != null -> "Fout bij zoeken"
|
error != null -> "Fout bij zoeken"
|
||||||
query.isBlank() -> "Resultaten"
|
query.isBlank() -> if (recent.isNotEmpty()) "Recent" else "Resultaten"
|
||||||
results.isEmpty() -> "Niets gevonden"
|
results.isEmpty() -> "Geen resultaten"
|
||||||
else -> "${results.size} 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,
|
color = if (error != null) McColors.Danger else McColors.TextDim,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
|
|
@ -245,18 +266,35 @@ fun SearchScreen(
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
if (query.isBlank() && results.isEmpty()) {
|
when {
|
||||||
Box(
|
query.isBlank() && recent.isNotEmpty() -> {
|
||||||
Modifier.fillMaxSize(),
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
contentAlignment = Alignment.Center,
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
SectionLabel("Recent gezocht")
|
||||||
"Begin links met typen",
|
McButton("Wissen") { scope.launch { store.clearRecentSearches() } }
|
||||||
color = Color(0xFF5A5A5A),
|
}
|
||||||
fontSize = 15.sp,
|
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(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 110.dp),
|
columns = GridCells.Adaptive(minSize = 110.dp),
|
||||||
state = gridState,
|
state = gridState,
|
||||||
|
|
@ -268,8 +306,7 @@ fun SearchScreen(
|
||||||
items(results, key = { "${it.type}:${it.id}" }) { item ->
|
items(results, key = { "${it.type}:${it.id}" }) { item ->
|
||||||
PosterCard(
|
PosterCard(
|
||||||
item = item,
|
item = item,
|
||||||
width = 110.dp,
|
fillWidth = true,
|
||||||
height = 165.dp,
|
|
||||||
modifier = Modifier.padding(6.dp),
|
modifier = Modifier.padding(6.dp),
|
||||||
onClick = {
|
onClick = {
|
||||||
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
|
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
|
||||||
|
|
@ -280,6 +317,7 @@ fun SearchScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import nl.vonas.mediacluster.tv.data.EpgProgram
|
||||||
import nl.vonas.mediacluster.tv.data.IptvCategory
|
import nl.vonas.mediacluster.tv.data.IptvCategory
|
||||||
import nl.vonas.mediacluster.tv.data.IptvChannel
|
import nl.vonas.mediacluster.tv.data.IptvChannel
|
||||||
import nl.vonas.mediacluster.tv.data.IptvEpg
|
import nl.vonas.mediacluster.tv.data.IptvEpg
|
||||||
|
import nl.vonas.mediacluster.tv.ui.components.EmptyState
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
|
@ -65,6 +66,8 @@ private val TvRowActive = Color(0xFF1C1C1C)
|
||||||
private val TvDivider = Color(0xFF2A2A2A)
|
private val TvDivider = Color(0xFF2A2A2A)
|
||||||
private val TvPanelBg = Color(0xFF101010)
|
private val TvPanelBg = Color(0xFF101010)
|
||||||
|
|
||||||
|
const val TV_FAV_CATEGORY_ID = "__fav__"
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TvScreen(
|
fun TvScreen(
|
||||||
|
|
@ -73,6 +76,8 @@ fun TvScreen(
|
||||||
onCategoryChange: (String?) -> Unit,
|
onCategoryChange: (String?) -> Unit,
|
||||||
lastStreamId: Int? = null,
|
lastStreamId: Int? = null,
|
||||||
onLastStreamChange: (Int?) -> Unit = {},
|
onLastStreamChange: (Int?) -> Unit = {},
|
||||||
|
favoriteIds: Set<Int> = emptySet(),
|
||||||
|
onToggleFavorite: (Int) -> Unit = {},
|
||||||
focusContentNonce: Int = 0,
|
focusContentNonce: Int = 0,
|
||||||
onAtTopBack: () -> Unit,
|
onAtTopBack: () -> Unit,
|
||||||
onPlayChannel: (streamId: Int) -> Unit,
|
onPlayChannel: (streamId: Int) -> Unit,
|
||||||
|
|
@ -84,15 +89,43 @@ fun TvScreen(
|
||||||
var loadingChannels by remember { mutableStateOf(false) }
|
var loadingChannels by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
|
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
|
||||||
|
var epgLoading by remember { mutableStateOf(false) }
|
||||||
var focusedStreamId by remember { mutableStateOf<Int?>(null) }
|
var focusedStreamId by remember { mutableStateOf<Int?>(null) }
|
||||||
var epgJob by remember { mutableStateOf<Job?>(null) }
|
var epgJob by remember { mutableStateOf<Job?>(null) }
|
||||||
var focusInList by remember { mutableStateOf(false) }
|
var focusInList by remember { mutableStateOf(false) }
|
||||||
|
val channelCache = remember { mutableStateMapOf<String, List<IptvChannel>>() }
|
||||||
val channelListState = rememberLazyListState()
|
val channelListState = rememberLazyListState()
|
||||||
val categoryRowState = rememberLazyListState()
|
val categoryRowState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
|
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
|
||||||
val channelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
|
val channelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
|
||||||
val epgNowTitles = remember { mutableStateMapOf<Int, String>() }
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
loading = true
|
loading = true
|
||||||
|
|
@ -100,7 +133,9 @@ fun TvScreen(
|
||||||
try {
|
try {
|
||||||
val cats = api.iptvCategories()
|
val cats = api.iptvCategories()
|
||||||
categories = cats
|
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) {
|
if (restored != null) {
|
||||||
onCategoryChange(restored)
|
onCategoryChange(restored)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -113,69 +148,143 @@ fun TvScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(selectedCategoryId) {
|
LaunchedEffect(selectedCategoryId, favoriteIds) {
|
||||||
val catId = selectedCategoryId ?: return@LaunchedEffect
|
val catId = selectedCategoryId ?: return@LaunchedEffect
|
||||||
loadingChannels = true
|
|
||||||
focusedEpg = null
|
|
||||||
focusedStreamId = null
|
focusedStreamId = null
|
||||||
epgNowTitles.clear()
|
|
||||||
|
if (catId == TV_FAV_CATEGORY_ID) {
|
||||||
|
loadingChannels = channelCache["__all__"] == null && channels.isEmpty()
|
||||||
try {
|
try {
|
||||||
val loaded = api.iptvChannels(catId, includeEpg = true)
|
val all = channelCache["__all__"] ?: api.iptvChannels("__all__", includeEpg = false).also {
|
||||||
val titles = api.fetchMissingNowTitles(loaded)
|
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
|
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 } }
|
val focusId = lastStreamId?.takeIf { id -> loaded.any { it.streamId == id } }
|
||||||
?: loaded.firstOrNull()?.streamId
|
?: loaded.firstOrNull()?.streamId
|
||||||
focusId?.let { id ->
|
focusId?.let { id ->
|
||||||
focusedStreamId = 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) {
|
} catch (e: Exception) {
|
||||||
|
if (channels.isEmpty()) {
|
||||||
error = e.message
|
error = e.message
|
||||||
channels = emptyList()
|
channels = emptyList()
|
||||||
} finally {
|
}
|
||||||
loadingChannels = false
|
loadingChannels = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(selectedCategoryId, channels, loadingChannels, focusInList) {
|
LaunchedEffect(selectedCategoryId, filteredChannels, loadingChannels, focusInList) {
|
||||||
if (!focusInList || loadingChannels || channels.isEmpty()) return@LaunchedEffect
|
if (!focusInList || loadingChannels || filteredChannels.isEmpty()) return@LaunchedEffect
|
||||||
delay(100)
|
delay(100)
|
||||||
val targetId = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } }
|
val targetId = lastStreamId?.takeIf { id -> filteredChannels.any { it.streamId == id } }
|
||||||
?: channels.first().streamId
|
?: filteredChannels.first().streamId
|
||||||
val idx = channels.indexOfFirst { it.streamId == targetId }.coerceAtLeast(0)
|
val idx = filteredChannels.indexOfFirst { it.streamId == targetId }.coerceAtLeast(0)
|
||||||
channelListState.scrollToItem(idx)
|
channelListState.scrollToItem(idx)
|
||||||
delay(80)
|
delay(80)
|
||||||
channelFocus[targetId]?.let { runCatching { it.requestFocus() } }
|
channelFocus[targetId]?.let { runCatching { it.requestFocus() } }
|
||||||
focusedStreamId = targetId
|
focusedStreamId = targetId
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(focusContentNonce, loading, categories, channels, selectedCategoryId, lastStreamId) {
|
// TV-tab / omlaag vanaf nav → altijd eerst categorieën (niet meteen zenderlijst of toetsenbord)
|
||||||
if (loading || categories.isEmpty()) return@LaunchedEffect
|
LaunchedEffect(focusContentNonce, loading, displayCategories, selectedCategoryId) {
|
||||||
|
if (loading || displayCategories.isEmpty()) return@LaunchedEffect
|
||||||
delay(120)
|
delay(120)
|
||||||
val channelTarget = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } }
|
val catId = selectedCategoryId ?: displayCategories.firstOrNull()?.id ?: return@LaunchedEffect
|
||||||
if (channelTarget != null) {
|
val catIdx = displayCategories.indexOfFirst { it.id == catId }.coerceAtLeast(0)
|
||||||
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)
|
|
||||||
categoryRowState.scrollToItem(catIdx)
|
categoryRowState.scrollToItem(catIdx)
|
||||||
categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
|
categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
|
||||||
focusInList = false
|
focusInList = false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
BackHandler(enabled = focusInList) {
|
BackHandler(enabled = focusInList) {
|
||||||
val catId = selectedCategoryId ?: categories.firstOrNull()?.id
|
val catId = selectedCategoryId ?: displayCategories.firstOrNull()?.id
|
||||||
if (catId != null) {
|
if (catId != null) {
|
||||||
categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
|
categoryFocus[catId]?.let { runCatching { it.requestFocus() } }
|
||||||
focusInList = false
|
focusInList = false
|
||||||
|
|
@ -189,12 +298,15 @@ fun TvScreen(
|
||||||
focusedStreamId = streamId
|
focusedStreamId = streamId
|
||||||
epgJob?.cancel()
|
epgJob?.cancel()
|
||||||
epgJob = scope.launch {
|
epgJob = scope.launch {
|
||||||
delay(200)
|
delay(180)
|
||||||
|
if (focusedEpg?.streamId != streamId) epgLoading = true
|
||||||
runCatching {
|
runCatching {
|
||||||
val epg = api.iptvEpg(streamId)
|
val epg = api.iptvEpg(streamId)
|
||||||
focusedEpg = epg
|
focusedEpg = epg
|
||||||
epg.now?.title?.let { epgNowTitles[streamId] = it }
|
epg.now?.title?.let { epgNowTitles[streamId] = it }
|
||||||
|
epg.next?.title?.let { epgNextTitles[streamId] = it }
|
||||||
}
|
}
|
||||||
|
epgLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,7 +328,7 @@ fun TvScreen(
|
||||||
else -> {
|
else -> {
|
||||||
Column(Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 8.dp)) {
|
Column(Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 8.dp)) {
|
||||||
CategoryTabRow(
|
CategoryTabRow(
|
||||||
categories = categories,
|
categories = displayCategories,
|
||||||
selectedId = selectedCategoryId,
|
selectedId = selectedCategoryId,
|
||||||
listState = categoryRowState,
|
listState = categoryRowState,
|
||||||
categoryFocus = categoryFocus,
|
categoryFocus = categoryFocus,
|
||||||
|
|
@ -248,17 +360,28 @@ fun TvScreen(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Text("Zender", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium)
|
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 {
|
when {
|
||||||
loadingChannels -> {
|
loadingChannels && filteredChannels.isEmpty() -> {
|
||||||
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
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) {
|
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 -> {
|
else -> {
|
||||||
|
|
@ -268,13 +391,15 @@ fun TvScreen(
|
||||||
contentPadding = PaddingValues(bottom = 12.dp),
|
contentPadding = PaddingValues(bottom = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(1.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() }
|
val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() }
|
||||||
ChannelRow(
|
ChannelRow(
|
||||||
channel = ch,
|
channel = ch,
|
||||||
nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle,
|
nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle,
|
||||||
|
nextTitle = epgNextTitles[ch.streamId] ?: ch.nextTitle,
|
||||||
isFocused = focusedStreamId == ch.streamId,
|
isFocused = focusedStreamId == ch.streamId,
|
||||||
isLastWatched = ch.streamId == lastStreamId,
|
isLastWatched = ch.streamId == lastStreamId,
|
||||||
|
isFavorite = favoriteIds.contains(ch.streamId),
|
||||||
modifier = Modifier.focusRequester(fr),
|
modifier = Modifier.focusRequester(fr),
|
||||||
onClick = {
|
onClick = {
|
||||||
onLastStreamChange(ch.streamId)
|
onLastStreamChange(ch.streamId)
|
||||||
|
|
@ -303,6 +428,11 @@ fun TvScreen(
|
||||||
|
|
||||||
ChannelDetailPanel(
|
ChannelDetailPanel(
|
||||||
epg = focusedEpg,
|
epg = focusedEpg,
|
||||||
|
loading = epgLoading && focusedEpg == null,
|
||||||
|
isFavorite = focusedStreamId?.let { favoriteIds.contains(it) } == true,
|
||||||
|
onToggleFavorite = {
|
||||||
|
focusedStreamId?.let { onToggleFavorite(it) }
|
||||||
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(0.42f)
|
.weight(0.42f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
|
|
@ -354,6 +484,10 @@ private fun CategoryTabRow(
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
cat.name,
|
cat.name,
|
||||||
|
|
@ -363,11 +497,19 @@ private fun CategoryTabRow(
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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))
|
Spacer(Modifier.height(5.dp))
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.height(2.dp)
|
.height(2.dp)
|
||||||
.width(if (active) 24.dp else 0.dp)
|
.width(if (active) 28.dp else 0.dp)
|
||||||
.clip(RoundedCornerShape(1.dp))
|
.clip(RoundedCornerShape(1.dp))
|
||||||
.background(if (active) McColors.Brand else Color.Transparent),
|
.background(if (active) McColors.Brand else Color.Transparent),
|
||||||
)
|
)
|
||||||
|
|
@ -382,8 +524,10 @@ private fun CategoryTabRow(
|
||||||
private fun ChannelRow(
|
private fun ChannelRow(
|
||||||
channel: IptvChannel,
|
channel: IptvChannel,
|
||||||
nowTitle: String?,
|
nowTitle: String?,
|
||||||
|
nextTitle: String?,
|
||||||
isFocused: Boolean,
|
isFocused: Boolean,
|
||||||
isLastWatched: Boolean,
|
isLastWatched: Boolean,
|
||||||
|
isFavorite: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onFocused: () -> Unit,
|
onFocused: () -> Unit,
|
||||||
|
|
@ -413,7 +557,7 @@ private fun ChannelRow(
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(52.dp)
|
.height(56.dp)
|
||||||
.padding(horizontal = 8.dp),
|
.padding(horizontal = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
|
@ -466,6 +610,8 @@ private fun ChannelRow(
|
||||||
modifier = Modifier.width(36.dp),
|
modifier = Modifier.width(36.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Column(Modifier.width(140.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text(
|
Text(
|
||||||
channel.name,
|
channel.name,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
|
|
@ -473,23 +619,56 @@ private fun ChannelRow(
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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(
|
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,
|
color = if (isFocused) Color(0xFFCCCCCC) else McColors.TextDim,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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
|
@Composable
|
||||||
private fun ChannelDetailPanel(
|
private fun ChannelDetailPanel(
|
||||||
epg: IptvEpg?,
|
epg: IptvEpg?,
|
||||||
|
loading: Boolean,
|
||||||
|
isFavorite: Boolean,
|
||||||
|
onToggleFavorite: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -499,7 +678,11 @@ private fun ChannelDetailPanel(
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
if (epg == null) {
|
when {
|
||||||
|
epg == null && loading -> {
|
||||||
|
EpgSkeleton()
|
||||||
|
}
|
||||||
|
epg == null -> {
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Text(
|
Text(
|
||||||
"Selecteer een zender",
|
"Selecteer een zender",
|
||||||
|
|
@ -507,10 +690,15 @@ private fun ChannelDetailPanel(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
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))
|
Spacer(Modifier.weight(1f))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
else -> {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
|
@ -526,7 +714,7 @@ private fun ChannelDetailPanel(
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
epg.channelName,
|
epg.channelName,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
|
|
@ -543,35 +731,85 @@ private fun ChannelDetailPanel(
|
||||||
epg.now?.let { now ->
|
epg.now?.let { now ->
|
||||||
EpgBlock(label = "Nu", program = now, highlight = true)
|
EpgBlock(label = "Nu", program = now, highlight = true)
|
||||||
val progress = epgProgress(now)
|
val progress = epgProgress(now)
|
||||||
if (progress > 0f) {
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(2.dp)
|
.height(3.dp)
|
||||||
.clip(RoundedCornerShape(1.dp))
|
.clip(RoundedCornerShape(1.dp))
|
||||||
.background(Color(0xFF333333)),
|
.background(Color(0xFF333333)),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth(progress)
|
.fillMaxWidth(progress.coerceAtLeast(0.02f))
|
||||||
.height(2.dp)
|
.height(3.dp)
|
||||||
.background(McColors.Brand),
|
.background(McColors.Brand),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
epg.next?.let { next ->
|
epg.next?.let { next ->
|
||||||
EpgBlock(label = "Straks", program = next, highlight = false)
|
EpgBlock(label = "Straks", program = next, highlight = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (epg.now == null && epg.next == null) {
|
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))
|
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)
|
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
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
@ -30,6 +34,21 @@ import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
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) {
|
enum class MainTab(val label: String) {
|
||||||
Home("Home"),
|
Home("Home"),
|
||||||
Movies("Films"),
|
Movies("Films"),
|
||||||
|
|
@ -56,35 +75,81 @@ fun TopNavBar(
|
||||||
selectedSubId: String? = null,
|
selectedSubId: String? = null,
|
||||||
onSelectSub: (SubNavItem) -> Unit = {},
|
onSelectSub: (SubNavItem) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
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,
|
||||||
) {
|
) {
|
||||||
Column(modifier.fillMaxWidth()) {
|
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 },
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 40.dp, vertical = 14.dp),
|
.padding(horizontal = 40.dp, vertical = vPad),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"VMC",
|
"VMC",
|
||||||
color = McColors.Brand,
|
color = McColors.Brand,
|
||||||
fontSize = 28.sp,
|
fontSize = if (compact && !overlay) 22.sp else 28.sp,
|
||||||
fontWeight = FontWeight.Black,
|
fontWeight = FontWeight.Black,
|
||||||
modifier = Modifier.padding(end = 16.dp),
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
)
|
)
|
||||||
visibleTabs.forEach { tab ->
|
visibleTabs.forEach { tab ->
|
||||||
val active = selected == tab
|
val active = selected == tab
|
||||||
val color by animateColorAsState(
|
val color by animateColorAsState(
|
||||||
if (active) Color.White else Color(0xFF808080),
|
when {
|
||||||
|
active -> Color.White
|
||||||
|
overlay -> Color(0xFFE8E8E8)
|
||||||
|
else -> Color(0xFF808080)
|
||||||
|
},
|
||||||
label = "tabColor",
|
label = "tabColor",
|
||||||
)
|
)
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { onSelect(tab) },
|
onClick = { onSelect(tab) },
|
||||||
|
modifier = downLink,
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = if (active) Color(0x22FFFFFF) else Color.Transparent,
|
containerColor = if (active) Color(0x33FFFFFF) else Color.Transparent,
|
||||||
focusedContainerColor = Color(0x33FFFFFF),
|
focusedContainerColor = Color(0x44FFFFFF),
|
||||||
contentColor = color,
|
contentColor = color,
|
||||||
focusedContentColor = Color.White,
|
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()) {
|
if (subItems.isNotEmpty()) {
|
||||||
|
|
@ -122,6 +219,7 @@ fun TopNavBar(
|
||||||
val active = item.id == selectedSubId
|
val active = item.id == selectedSubId
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { onSelectSub(item) },
|
onClick = { onSelectSub(item) },
|
||||||
|
modifier = downLink,
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(20.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(20.dp)),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
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")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
devices ViewerDevice[]
|
devices ViewerDevice[]
|
||||||
progress WatchProgress[]
|
profiles ViewerProfile[]
|
||||||
shelfPrefs ViewerShelfPreference[]
|
|
||||||
linkCodes DeviceLinkCode[]
|
linkCodes DeviceLinkCode[]
|
||||||
googlePlayAuditLogs GooglePlayAccessAuditLog[]
|
googlePlayAuditLogs GooglePlayAccessAuditLog[]
|
||||||
iptvAddonToken AddonToken? @relation(fields: [iptvAddonTokenId], references: [id], onDelete: SetNull)
|
iptvAddonToken AddonToken? @relation(fields: [iptvAddonTokenId], references: [id], onDelete: SetNull)
|
||||||
|
|
@ -98,20 +97,45 @@ model ViewerUser {
|
||||||
@@map("viewer_users")
|
@@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 {
|
model ViewerDevice {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
viewerUserId String @map("viewer_user_id")
|
viewerUserId String @map("viewer_user_id")
|
||||||
name String
|
name String
|
||||||
platform String @default("android_tv")
|
platform String @default("android_tv")
|
||||||
refreshTokenHash String @unique @map("refresh_token_hash")
|
refreshTokenHash String @unique @map("refresh_token_hash")
|
||||||
|
activeProfileId String? @map("active_profile_id")
|
||||||
lastSeenAt DateTime? @map("last_seen_at")
|
lastSeenAt DateTime? @map("last_seen_at")
|
||||||
revoked Boolean @default(false)
|
revoked Boolean @default(false)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
|
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
|
||||||
|
activeProfile ViewerProfile? @relation(fields: [activeProfileId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([viewerUserId])
|
@@index([viewerUserId])
|
||||||
|
@@index([activeProfileId])
|
||||||
@@map("viewer_devices")
|
@@map("viewer_devices")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +167,7 @@ enum WatchMediaType {
|
||||||
|
|
||||||
model WatchProgress {
|
model WatchProgress {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
viewerUserId String @map("viewer_user_id")
|
profileId String @map("profile_id")
|
||||||
mediaType WatchMediaType @map("media_type")
|
mediaType WatchMediaType @map("media_type")
|
||||||
movieId String? @map("movie_id")
|
movieId String? @map("movie_id")
|
||||||
episodeId String? @map("episode_id")
|
episodeId String? @map("episode_id")
|
||||||
|
|
@ -155,26 +179,26 @@ model WatchProgress {
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
createdAt DateTime @default(now()) @map("created_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([profileId, movieId])
|
||||||
@@unique([viewerUserId, episodeId])
|
@@unique([profileId, episodeId])
|
||||||
@@index([viewerUserId, updatedAt])
|
@@index([profileId, updatedAt])
|
||||||
@@index([viewerUserId, hiddenFromContinue, completed])
|
@@index([profileId, hiddenFromContinue, completed])
|
||||||
@@map("watch_progress")
|
@@map("watch_progress")
|
||||||
}
|
}
|
||||||
|
|
||||||
model ViewerShelfPreference {
|
model ViewerShelfPreference {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
viewerUserId String @map("viewer_user_id")
|
profileId String @map("profile_id")
|
||||||
shelfId String @map("shelf_id")
|
shelfId String @map("shelf_id")
|
||||||
visible Boolean @default(true)
|
visible Boolean @default(true)
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
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])
|
@@unique([profileId, shelfId])
|
||||||
@@index([viewerUserId, sortOrder])
|
@@index([profileId, sortOrder])
|
||||||
@@map("viewer_shelf_preferences")
|
@@map("viewer_shelf_preferences")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -350,6 +374,8 @@ model Movie {
|
||||||
backdropUrl String? @map("backdrop_url")
|
backdropUrl String? @map("backdrop_url")
|
||||||
logoUrl String? @map("logo_url")
|
logoUrl String? @map("logo_url")
|
||||||
genres String[] @default([])
|
genres String[] @default([])
|
||||||
|
/** TMDB adult-flag; kids-profielen filteren hierop. */
|
||||||
|
adult Boolean @default(false)
|
||||||
runtime Int?
|
runtime Int?
|
||||||
imdbId String? @unique @map("imdb_id")
|
imdbId String? @unique @map("imdb_id")
|
||||||
tmdbId Int? @unique @map("tmdb_id")
|
tmdbId Int? @unique @map("tmdb_id")
|
||||||
|
|
@ -360,6 +386,7 @@ model Movie {
|
||||||
matches LibraryMatch[]
|
matches LibraryMatch[]
|
||||||
subtitles LibrarySubtitle[]
|
subtitles LibrarySubtitle[]
|
||||||
|
|
||||||
|
@@index([adult])
|
||||||
@@map("movies")
|
@@map("movies")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -372,6 +399,8 @@ model Series {
|
||||||
backdropUrl String? @map("backdrop_url")
|
backdropUrl String? @map("backdrop_url")
|
||||||
logoUrl String? @map("logo_url")
|
logoUrl String? @map("logo_url")
|
||||||
genres String[] @default([])
|
genres String[] @default([])
|
||||||
|
/** TMDB adult-flag; kids-profielen filteren hierop. */
|
||||||
|
adult Boolean @default(false)
|
||||||
imdbId String? @unique @map("imdb_id")
|
imdbId String? @unique @map("imdb_id")
|
||||||
tmdbId Int? @unique @map("tmdb_id")
|
tmdbId Int? @unique @map("tmdb_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
@ -381,6 +410,7 @@ model Series {
|
||||||
episodes Episode[]
|
episodes Episode[]
|
||||||
matches LibraryMatch[]
|
matches LibraryMatch[]
|
||||||
|
|
||||||
|
@@index([adult])
|
||||||
@@map("series")
|
@@map("series")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ type TmdbMeta = {
|
||||||
backdropUrl?: string;
|
backdropUrl?: string;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
imdbId?: string;
|
imdbId?: string;
|
||||||
|
adult?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Extra fields for Stremio meta detail pages. */
|
/** Extra fields for Stremio meta detail pages. */
|
||||||
|
|
@ -562,6 +563,7 @@ export class MetadataService {
|
||||||
posterUrl: tmdb.posterUrl,
|
posterUrl: tmdb.posterUrl,
|
||||||
backdropUrl: tmdb.backdropUrl,
|
backdropUrl: tmdb.backdropUrl,
|
||||||
tmdbId: tmdb.id,
|
tmdbId: tmdb.id,
|
||||||
|
adult: tmdb.adult === true,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
title: tmdb.title,
|
title: tmdb.title,
|
||||||
|
|
@ -569,6 +571,7 @@ export class MetadataService {
|
||||||
posterUrl: tmdb.posterUrl,
|
posterUrl: tmdb.posterUrl,
|
||||||
backdropUrl: tmdb.backdropUrl,
|
backdropUrl: tmdb.backdropUrl,
|
||||||
year: tmdb.year,
|
year: tmdb.year,
|
||||||
|
adult: tmdb.adult === true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (tmdb.imdbId) {
|
if (tmdb.imdbId) {
|
||||||
|
|
@ -1004,6 +1007,7 @@ export class MetadataService {
|
||||||
posterUrl: tmdb.posterUrl,
|
posterUrl: tmdb.posterUrl,
|
||||||
backdropUrl: tmdb.backdropUrl,
|
backdropUrl: tmdb.backdropUrl,
|
||||||
tmdbId: tmdb.id,
|
tmdbId: tmdb.id,
|
||||||
|
adult: tmdb.adult === true,
|
||||||
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
|
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
|
|
@ -1012,6 +1016,7 @@ export class MetadataService {
|
||||||
posterUrl: tmdb.posterUrl,
|
posterUrl: tmdb.posterUrl,
|
||||||
backdropUrl: tmdb.backdropUrl,
|
backdropUrl: tmdb.backdropUrl,
|
||||||
year: tmdb.year,
|
year: tmdb.year,
|
||||||
|
adult: tmdb.adult === true,
|
||||||
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
|
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -1764,6 +1769,7 @@ export class MetadataService {
|
||||||
posterUrl: tmdb.posterUrl,
|
posterUrl: tmdb.posterUrl,
|
||||||
backdropUrl: tmdb.backdropUrl,
|
backdropUrl: tmdb.backdropUrl,
|
||||||
tmdbId: tmdb.id,
|
tmdbId: tmdb.id,
|
||||||
|
adult: tmdb.adult === true,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
return await prisma.movie.upsert({
|
return await prisma.movie.upsert({
|
||||||
|
|
@ -1958,6 +1964,7 @@ export class MetadataService {
|
||||||
release_date?: string;
|
release_date?: string;
|
||||||
poster_path?: string;
|
poster_path?: string;
|
||||||
backdrop_path?: string;
|
backdrop_path?: string;
|
||||||
|
adult?: boolean;
|
||||||
};
|
};
|
||||||
const imdbId = await this.fetchTmdbMovieImdbId(tmdbId);
|
const imdbId = await this.fetchTmdbMovieImdbId(tmdbId);
|
||||||
const meta: TmdbMeta = {
|
const meta: TmdbMeta = {
|
||||||
|
|
@ -1972,6 +1979,7 @@ export class MetadataService {
|
||||||
? `${TMDB_BACKDROP_BASE}${item.backdrop_path}`
|
? `${TMDB_BACKDROP_BASE}${item.backdrop_path}`
|
||||||
: undefined,
|
: undefined,
|
||||||
imdbId,
|
imdbId,
|
||||||
|
adult: item.adult === true,
|
||||||
};
|
};
|
||||||
this.movieCache.set(tmdbId, meta);
|
this.movieCache.set(tmdbId, meta);
|
||||||
return meta;
|
return meta;
|
||||||
|
|
@ -2013,6 +2021,7 @@ export class MetadataService {
|
||||||
first_air_date?: string;
|
first_air_date?: string;
|
||||||
poster_path?: string;
|
poster_path?: string;
|
||||||
backdrop_path?: string;
|
backdrop_path?: string;
|
||||||
|
adult?: boolean;
|
||||||
};
|
};
|
||||||
const logoUrl = await this.fetchTmdbSeriesLogo(tmdbId);
|
const logoUrl = await this.fetchTmdbSeriesLogo(tmdbId);
|
||||||
const imdbId = await this.fetchTmdbSeriesImdbId(tmdbId);
|
const imdbId = await this.fetchTmdbSeriesImdbId(tmdbId);
|
||||||
|
|
@ -2029,6 +2038,7 @@ export class MetadataService {
|
||||||
: undefined,
|
: undefined,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
imdbId,
|
imdbId,
|
||||||
|
adult: item.adult === true,
|
||||||
};
|
};
|
||||||
this.seriesCache.set(tmdbId, meta);
|
this.seriesCache.set(tmdbId, meta);
|
||||||
return 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 { requireAdmin, type AuthenticatedRequest } from "../auth/routes";
|
||||||
import { PlaybackService } from "../playback/service";
|
import { PlaybackService } from "../playback/service";
|
||||||
import type { Config } from "../config";
|
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 { DownloadService } from "../downloads/service";
|
||||||
import type { SubtitleSource } from "../opensubtitles/client";
|
import type { SubtitleSource } from "../opensubtitles/client";
|
||||||
import { GooglePlayAccessService } from "../google-play/service";
|
import { GooglePlayAccessService } from "../google-play/service";
|
||||||
|
|
@ -15,6 +16,7 @@ export function registerViewerRoutes(
|
||||||
) {
|
) {
|
||||||
const playback = new PlaybackService(config);
|
const playback = new PlaybackService(config);
|
||||||
const viewers = new ViewerService(config, playback, downloads);
|
const viewers = new ViewerService(config, playback, downloads);
|
||||||
|
const profiles = new ViewerProfileService();
|
||||||
const googlePlay = new GooglePlayAccessService(config);
|
const googlePlay = new GooglePlayAccessService(config);
|
||||||
|
|
||||||
// ——— Admin: kijkers ———
|
// ——— Admin: kijkers ———
|
||||||
|
|
@ -125,29 +127,105 @@ export function registerViewerRoutes(
|
||||||
return {
|
return {
|
||||||
viewer: { id: auth.viewerId, email: auth.email, name: auth.name },
|
viewer: { id: auth.viewerId, email: auth.email, name: auth.name },
|
||||||
deviceId: auth.deviceId,
|
deviceId: auth.deviceId,
|
||||||
|
profile: auth.profile,
|
||||||
iptv: { enabled: iptv.enabled },
|
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) => {
|
app.get("/api/v1/client/home", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
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) => {
|
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 };
|
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) => {
|
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 };
|
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) => {
|
app.post("/api/v1/client/play", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
requireProfile(auth);
|
||||||
const body = request.body as { mediaFileId?: string };
|
const body = request.body as { mediaFileId?: string };
|
||||||
if (!body.mediaFileId) throw new AppError("INVALID_REQUEST", "mediaFileId verplicht", 400);
|
if (!body.mediaFileId) throw new AppError("INVALID_REQUEST", "mediaFileId verplicht", 400);
|
||||||
return viewers.play(body.mediaFileId, auth.viewerId);
|
return viewers.play(body.mediaFileId, auth.viewerId);
|
||||||
|
|
@ -155,6 +233,7 @@ export function registerViewerRoutes(
|
||||||
|
|
||||||
app.post("/api/v1/client/progress", async (request) => {
|
app.post("/api/v1/client/progress", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const profileId = requireProfile(auth);
|
||||||
const body = request.body as {
|
const body = request.body as {
|
||||||
mediaType?: "MOVIE" | "EPISODE";
|
mediaType?: "MOVIE" | "EPISODE";
|
||||||
movieId?: string;
|
movieId?: string;
|
||||||
|
|
@ -166,7 +245,7 @@ export function registerViewerRoutes(
|
||||||
hiddenFromContinue?: boolean;
|
hiddenFromContinue?: boolean;
|
||||||
};
|
};
|
||||||
if (!body.mediaType) throw new AppError("INVALID_REQUEST", "mediaType verplicht", 400);
|
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,
|
mediaType: body.mediaType,
|
||||||
movieId: body.movieId,
|
movieId: body.movieId,
|
||||||
episodeId: body.episodeId,
|
episodeId: body.episodeId,
|
||||||
|
|
@ -181,49 +260,63 @@ export function registerViewerRoutes(
|
||||||
|
|
||||||
app.post("/api/v1/client/progress/mark-watched", async (request) => {
|
app.post("/api/v1/client/progress/mark-watched", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const profileId = requireProfile(auth);
|
||||||
const body = request.body as { movieId?: string; episodeId?: string };
|
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 };
|
return { progress: row };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/v1/client/progress/:id/dismiss", async (request) => {
|
app.post("/api/v1/client/progress/:id/dismiss", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const profileId = requireProfile(auth);
|
||||||
const { id } = request.params as { id: string };
|
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) => {
|
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 q = request.query as { q?: string; take?: string };
|
||||||
const query = (q.q || "").trim();
|
const query = (q.q || "").trim();
|
||||||
if (query.length < 1) return { results: [] };
|
if (query.length < 1) return { results: [] };
|
||||||
const take = Math.min(parseInt(q.take || "48", 10) || 48, 100);
|
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) => {
|
app.get("/api/v1/client/catalog/:id", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const profileId = requireProfile(auth);
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const q = request.query as { skip?: string; take?: string };
|
const q = request.query as { skip?: string; take?: string };
|
||||||
const skip = Math.max(0, parseInt(q.skip || "0", 10) || 0);
|
const skip = Math.max(0, parseInt(q.skip || "0", 10) || 0);
|
||||||
const take = Math.min(parseInt(q.take || "60", 10) || 60, 100);
|
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) => {
|
app.get("/api/v1/client/shelves/prefs", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
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) => {
|
app.put("/api/v1/client/shelves/prefs", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
|
const profileId = requireProfile(auth);
|
||||||
const body = request.body as {
|
const body = request.body as {
|
||||||
shelves?: Array<{ shelfId: string; visible: boolean; sortOrder: number }>;
|
shelves?: Array<{ shelfId: string; visible: boolean; sortOrder: number }>;
|
||||||
};
|
};
|
||||||
if (!Array.isArray(body.shelves)) {
|
if (!Array.isArray(body.shelves)) {
|
||||||
throw new AppError("INVALID_REQUEST", "shelves array verplicht", 400);
|
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) => {
|
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 { DownloadService } from "../downloads/service";
|
||||||
import type { SubtitleSource } from "../opensubtitles/client";
|
import type { SubtitleSource } from "../opensubtitles/client";
|
||||||
import { MetadataService } from "../metadata/service";
|
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_TTL_MS = 10 * 60 * 1000;
|
||||||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
@ -27,8 +34,24 @@ export type AuthedViewer = {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string | null;
|
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 {
|
export function generateLinkCode(length = 8): string {
|
||||||
const bytes = randomBytes(length);
|
const bytes = randomBytes(length);
|
||||||
let out = "";
|
let out = "";
|
||||||
|
|
@ -69,7 +92,7 @@ export class ViewerService {
|
||||||
const existing = await prisma.viewerUser.findUnique({ where: { email } });
|
const existing = await prisma.viewerUser.findUnique({ where: { email } });
|
||||||
if (existing) throw new AppError("CONFLICT", "E-mail bestaat al", 409);
|
if (existing) throw new AppError("CONFLICT", "E-mail bestaat al", 409);
|
||||||
|
|
||||||
return prisma.viewerUser.create({
|
const viewer = await prisma.viewerUser.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
passwordHash: await hashPassword(input.password),
|
passwordHash: await hashPassword(input.password),
|
||||||
|
|
@ -85,6 +108,8 @@ export class ViewerService {
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await ensureOwnerProfile(viewer.id, viewer.name);
|
||||||
|
return viewer;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listViewers() {
|
async listViewers() {
|
||||||
|
|
@ -297,7 +322,10 @@ export class ViewerService {
|
||||||
|
|
||||||
const device = await prisma.viewerDevice.findUnique({
|
const device = await prisma.viewerDevice.findUnique({
|
||||||
where: { refreshTokenHash: hashToken(token) },
|
where: { refreshTokenHash: hashToken(token) },
|
||||||
include: { viewer: true },
|
include: {
|
||||||
|
viewer: true,
|
||||||
|
activeProfile: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!device || device.revoked) {
|
if (!device || device.revoked) {
|
||||||
throw new AppError("UNAUTHORIZED", "Ongeldig of ingetrokken apparaat", 401);
|
throw new AppError("UNAUTHORIZED", "Ongeldig of ingetrokken apparaat", 401);
|
||||||
|
|
@ -311,27 +339,46 @@ export class ViewerService {
|
||||||
data: { lastSeenAt: new Date() },
|
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 {
|
return {
|
||||||
viewerId: device.viewer.id,
|
viewerId: device.viewer.id,
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
email: device.viewer.email,
|
email: device.viewer.email,
|
||||||
name: device.viewer.name,
|
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] =
|
const [shelves, prefs, continueItems, recentMovies, recentSeries, fourK, spotlight] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.libraryShelf.findMany({
|
prisma.libraryShelf.findMany({
|
||||||
where: { enabled: true },
|
where: { enabled: true },
|
||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
}),
|
}),
|
||||||
prisma.viewerShelfPreference.findMany({ where: { viewerUserId: viewerId } }),
|
prisma.viewerShelfPreference.findMany({ where: { profileId } }),
|
||||||
this.continueWatching(viewerId),
|
this.continueWatching(profileId, kidsMode),
|
||||||
this.recentMovies(48),
|
this.recentMovies(48, 0, kidsMode),
|
||||||
this.recentSeries(36),
|
this.recentSeries(36, 0, kidsMode),
|
||||||
this.fourKMovies(36),
|
this.fourKMovies(36, 0, kidsMode),
|
||||||
this.spotlight(12),
|
this.spotlight(12, kidsMode),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
|
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
|
||||||
|
|
@ -401,7 +448,7 @@ export class ViewerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const shelf of ordered) {
|
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) {
|
if (items.length) {
|
||||||
const homepageLabel = shelf.homepageName?.trim();
|
const homepageLabel = shelf.homepageName?.trim();
|
||||||
rows.push({
|
rows.push({
|
||||||
|
|
@ -423,11 +470,14 @@ export class ViewerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Nieuwste titels (op bestand-mtime van de node) voor de home billboard. */
|
/** 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 }>>`
|
const movieIds = await prisma.$queryRaw<Array<{ movie_id: string; added_at: Date }>>`
|
||||||
SELECT mf.movie_id, MAX(mf.discovered_at) AS added_at
|
SELECT mf.movie_id, MAX(mf.discovered_at) AS added_at
|
||||||
FROM media_files mf
|
FROM media_files mf
|
||||||
WHERE mf.available = true AND mf.movie_id IS NOT NULL
|
WHERE mf.available = true AND mf.movie_id IS NOT NULL
|
||||||
|
${kidsMovie}
|
||||||
GROUP BY mf.movie_id
|
GROUP BY mf.movie_id
|
||||||
ORDER BY added_at DESC, MAX(mf.discovered_at) DESC
|
ORDER BY added_at DESC, MAX(mf.discovered_at) DESC
|
||||||
LIMIT ${take * 2}
|
LIMIT ${take * 2}
|
||||||
|
|
@ -437,6 +487,7 @@ export class ViewerService {
|
||||||
FROM media_files mf
|
FROM media_files mf
|
||||||
INNER JOIN episodes e ON e.id = mf.episode_id
|
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||||
WHERE mf.available = true AND mf.episode_id IS NOT NULL
|
WHERE mf.available = true AND mf.episode_id IS NOT NULL
|
||||||
|
${kidsSeries}
|
||||||
GROUP BY e.series_id
|
GROUP BY e.series_id
|
||||||
ORDER BY added_at DESC, MAX(mf.discovered_at) DESC
|
ORDER BY added_at DESC, MAX(mf.discovered_at) DESC
|
||||||
LIMIT ${take * 2}
|
LIMIT ${take * 2}
|
||||||
|
|
@ -517,13 +568,16 @@ export class ViewerService {
|
||||||
.map(({ addedAt: _a, ...rest }) => rest);
|
.map(({ addedAt: _a, ...rest }) => rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
async recentMovies(take: number, skip = 0) {
|
async recentMovies(take: number, skip = 0, kidsMode = false) {
|
||||||
const page = await this.moviesByRecency(skip, take, Prisma.empty, { cap: RECENT_CATALOG_CAP });
|
const page = await this.moviesByRecency(skip, take, Prisma.empty, {
|
||||||
|
cap: RECENT_CATALOG_CAP,
|
||||||
|
kidsMode,
|
||||||
|
});
|
||||||
return page.items;
|
return page.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
async recentSeries(take: number, skip = 0) {
|
async recentSeries(take: number, skip = 0, kidsMode = false) {
|
||||||
const page = await this.seriesByRecency(skip, take, { cap: RECENT_CATALOG_CAP });
|
const page = await this.seriesByRecency(skip, take, { cap: RECENT_CATALOG_CAP, kidsMode });
|
||||||
return page.items;
|
return page.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -535,15 +589,17 @@ export class ViewerService {
|
||||||
skip: number,
|
skip: number,
|
||||||
take: number,
|
take: number,
|
||||||
extraWhereSql: Prisma.Sql = Prisma.empty,
|
extraWhereSql: Prisma.Sql = Prisma.empty,
|
||||||
opts: { cap?: number } = {}
|
opts: { cap?: number; kidsMode?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
const cap = opts.cap;
|
const cap = opts.cap;
|
||||||
|
const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty;
|
||||||
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||||||
SELECT mf.movie_id
|
SELECT mf.movie_id
|
||||||
FROM media_files mf
|
FROM media_files mf
|
||||||
WHERE mf.available = true
|
WHERE mf.available = true
|
||||||
AND mf.movie_id IS NOT NULL
|
AND mf.movie_id IS NOT NULL
|
||||||
${extraWhereSql}
|
${extraWhereSql}
|
||||||
|
${kidsSql}
|
||||||
GROUP BY mf.movie_id
|
GROUP BY mf.movie_id
|
||||||
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC, MIN(mf.release_name) ASC
|
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}
|
${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty}
|
||||||
|
|
@ -595,14 +651,16 @@ export class ViewerService {
|
||||||
private async seriesByRecency(
|
private async seriesByRecency(
|
||||||
skip: number,
|
skip: number,
|
||||||
take: number,
|
take: number,
|
||||||
opts: { cap?: number } = {}
|
opts: { cap?: number; kidsMode?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
const cap = opts.cap;
|
const cap = opts.cap;
|
||||||
|
const kidsSql = opts.kidsMode ? kidsSeriesSql() : Prisma.empty;
|
||||||
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
|
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||||||
SELECT e.series_id
|
SELECT e.series_id
|
||||||
FROM media_files mf
|
FROM media_files mf
|
||||||
INNER JOIN episodes e ON e.id = mf.episode_id
|
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||||
WHERE mf.available = true AND mf.episode_id IS NOT NULL
|
WHERE mf.available = true AND mf.episode_id IS NOT NULL
|
||||||
|
${kidsSql}
|
||||||
GROUP BY e.series_id
|
GROUP BY e.series_id
|
||||||
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
|
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
|
||||||
${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty}
|
${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(
|
const page = await this.moviesByRecency(
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
|
|
@ -653,13 +711,13 @@ export class ViewerService {
|
||||||
OR mf.resolution ILIKE '%4K%'
|
OR mf.resolution ILIKE '%4K%'
|
||||||
OR mf.resolution ILIKE '%UHD%'
|
OR mf.resolution ILIKE '%UHD%'
|
||||||
)`,
|
)`,
|
||||||
{ cap: RECENT_CATALOG_CAP }
|
{ cap: RECENT_CATALOG_CAP, kidsMode }
|
||||||
);
|
);
|
||||||
return page.items;
|
return page.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
|
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number, kidsMode = false) {
|
||||||
const page = await this.shelfItemsPage(shelfId, kind, 0, take);
|
const page = await this.shelfItemsPage(shelfId, kind, 0, take, kidsMode);
|
||||||
return page.items;
|
return page.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -668,8 +726,11 @@ export class ViewerService {
|
||||||
shelfId: string,
|
shelfId: string,
|
||||||
kind: "MOVIE" | "SERIES",
|
kind: "MOVIE" | "SERIES",
|
||||||
skip: number,
|
skip: number,
|
||||||
take: number
|
take: number,
|
||||||
|
kidsMode = false
|
||||||
) {
|
) {
|
||||||
|
const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty;
|
||||||
|
const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty;
|
||||||
if (kind === "MOVIE") {
|
if (kind === "MOVIE") {
|
||||||
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||||
SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count
|
SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count
|
||||||
|
|
@ -677,6 +738,7 @@ export class ViewerService {
|
||||||
WHERE mf.shelf_id = ${shelfId}
|
WHERE mf.shelf_id = ${shelfId}
|
||||||
AND mf.available = true
|
AND mf.available = true
|
||||||
AND mf.movie_id IS NOT NULL
|
AND mf.movie_id IS NOT NULL
|
||||||
|
${kidsMovie}
|
||||||
`;
|
`;
|
||||||
const total = Number(countRow?.count ?? 0);
|
const total = Number(countRow?.count ?? 0);
|
||||||
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||||||
|
|
@ -685,6 +747,7 @@ export class ViewerService {
|
||||||
WHERE mf.shelf_id = ${shelfId}
|
WHERE mf.shelf_id = ${shelfId}
|
||||||
AND mf.available = true
|
AND mf.available = true
|
||||||
AND mf.movie_id IS NOT NULL
|
AND mf.movie_id IS NOT NULL
|
||||||
|
${kidsMovie}
|
||||||
GROUP BY mf.movie_id
|
GROUP BY mf.movie_id
|
||||||
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
|
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
|
||||||
OFFSET ${skip}
|
OFFSET ${skip}
|
||||||
|
|
@ -728,6 +791,7 @@ export class ViewerService {
|
||||||
WHERE mf.shelf_id = ${shelfId}
|
WHERE mf.shelf_id = ${shelfId}
|
||||||
AND mf.available = true
|
AND mf.available = true
|
||||||
AND mf.episode_id IS NOT NULL
|
AND mf.episode_id IS NOT NULL
|
||||||
|
${kidsSeries}
|
||||||
`;
|
`;
|
||||||
const total = Number(countRow?.count ?? 0);
|
const total = Number(countRow?.count ?? 0);
|
||||||
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
|
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||||||
|
|
@ -737,6 +801,7 @@ export class ViewerService {
|
||||||
WHERE mf.shelf_id = ${shelfId}
|
WHERE mf.shelf_id = ${shelfId}
|
||||||
AND mf.available = true
|
AND mf.available = true
|
||||||
AND mf.episode_id IS NOT NULL
|
AND mf.episode_id IS NOT NULL
|
||||||
|
${kidsSeries}
|
||||||
GROUP BY e.series_id
|
GROUP BY e.series_id
|
||||||
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
|
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC
|
||||||
OFFSET ${skip}
|
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({
|
const rows = await prisma.watchProgress.findMany({
|
||||||
where: {
|
where: {
|
||||||
viewerUserId: viewerId,
|
profileId,
|
||||||
hiddenFromContinue: false,
|
hiddenFromContinue: false,
|
||||||
completed: false,
|
completed: false,
|
||||||
positionMs: { gt: 30_000 },
|
positionMs: { gt: 30_000 },
|
||||||
|
|
@ -781,6 +846,7 @@ export class ViewerService {
|
||||||
if (p.mediaType === "MOVIE" && p.movieId) {
|
if (p.mediaType === "MOVIE" && p.movieId) {
|
||||||
const movie = await prisma.movie.findUnique({ where: { id: p.movieId } });
|
const movie = await prisma.movie.findUnique({ where: { id: p.movieId } });
|
||||||
if (!movie) continue;
|
if (!movie) continue;
|
||||||
|
if (kidsMode && isKidsBlockedTitle(movie)) continue;
|
||||||
const file = await prisma.mediaFile.findFirst({
|
const file = await prisma.mediaFile.findFirst({
|
||||||
where: { movieId: movie.id, available: true },
|
where: { movieId: movie.id, available: true },
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
|
|
@ -803,6 +869,7 @@ export class ViewerService {
|
||||||
include: { series: true },
|
include: { series: true },
|
||||||
});
|
});
|
||||||
if (!episode) continue;
|
if (!episode) continue;
|
||||||
|
if (kidsMode && isKidsBlockedTitle(episode.series)) continue;
|
||||||
const file = await prisma.mediaFile.findFirst({
|
const file = await prisma.mediaFile.findFirst({
|
||||||
where: { episodeId: episode.id, available: true },
|
where: { episodeId: episode.id, available: true },
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
|
|
@ -829,7 +896,7 @@ export class ViewerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertProgress(
|
async upsertProgress(
|
||||||
viewerId: string,
|
profileId: string,
|
||||||
body: {
|
body: {
|
||||||
mediaType: "MOVIE" | "EPISODE";
|
mediaType: "MOVIE" | "EPISODE";
|
||||||
movieId?: string;
|
movieId?: string;
|
||||||
|
|
@ -851,10 +918,10 @@ export class ViewerService {
|
||||||
if (!body.movieId) throw new AppError("INVALID_REQUEST", "movieId verplicht", 400);
|
if (!body.movieId) throw new AppError("INVALID_REQUEST", "movieId verplicht", 400);
|
||||||
return prisma.watchProgress.upsert({
|
return prisma.watchProgress.upsert({
|
||||||
where: {
|
where: {
|
||||||
viewerUserId_movieId: { viewerUserId: viewerId, movieId: body.movieId },
|
profileId_movieId: { profileId, movieId: body.movieId },
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
viewerUserId: viewerId,
|
profileId,
|
||||||
mediaType: "MOVIE",
|
mediaType: "MOVIE",
|
||||||
movieId: body.movieId,
|
movieId: body.movieId,
|
||||||
positionMs,
|
positionMs,
|
||||||
|
|
@ -885,10 +952,10 @@ export class ViewerService {
|
||||||
}
|
}
|
||||||
return prisma.watchProgress.upsert({
|
return prisma.watchProgress.upsert({
|
||||||
where: {
|
where: {
|
||||||
viewerUserId_episodeId: { viewerUserId: viewerId, episodeId: body.episodeId },
|
profileId_episodeId: { profileId, episodeId: body.episodeId },
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
viewerUserId: viewerId,
|
profileId,
|
||||||
mediaType: "EPISODE",
|
mediaType: "EPISODE",
|
||||||
episodeId: body.episodeId,
|
episodeId: body.episodeId,
|
||||||
seriesId,
|
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) {
|
if (opts.movieId) {
|
||||||
return this.upsertProgress(viewerId, {
|
return this.upsertProgress(profileId, {
|
||||||
mediaType: "MOVIE",
|
mediaType: "MOVIE",
|
||||||
movieId: opts.movieId,
|
movieId: opts.movieId,
|
||||||
positionMs: 1,
|
positionMs: 1,
|
||||||
|
|
@ -925,7 +992,7 @@ export class ViewerService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (opts.episodeId) {
|
if (opts.episodeId) {
|
||||||
return this.upsertProgress(viewerId, {
|
return this.upsertProgress(profileId, {
|
||||||
mediaType: "EPISODE",
|
mediaType: "EPISODE",
|
||||||
episodeId: opts.episodeId,
|
episodeId: opts.episodeId,
|
||||||
positionMs: 1,
|
positionMs: 1,
|
||||||
|
|
@ -937,9 +1004,9 @@ export class ViewerService {
|
||||||
throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400);
|
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({
|
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);
|
if (!row) throw new AppError("NOT_FOUND", "Niet gevonden", 404);
|
||||||
await prisma.watchProgress.update({
|
await prisma.watchProgress.update({
|
||||||
|
|
@ -949,14 +1016,16 @@ export class ViewerService {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, take: number) {
|
async search(query: string, take: number, kidsMode = false) {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return [];
|
if (!q) return [];
|
||||||
|
const kidsWhere = kidsMode ? kidsContentWhere() : {};
|
||||||
const [movies, series] = await Promise.all([
|
const [movies, series] = await Promise.all([
|
||||||
prisma.movie.findMany({
|
prisma.movie.findMany({
|
||||||
where: {
|
where: {
|
||||||
title: { contains: q, mode: "insensitive" },
|
title: { contains: q, mode: "insensitive" },
|
||||||
mediaFiles: { some: { available: true } },
|
mediaFiles: { some: { available: true } },
|
||||||
|
...kidsWhere,
|
||||||
},
|
},
|
||||||
orderBy: { title: "asc" },
|
orderBy: { title: "asc" },
|
||||||
take,
|
take,
|
||||||
|
|
@ -972,6 +1041,7 @@ export class ViewerService {
|
||||||
where: {
|
where: {
|
||||||
title: { contains: q, mode: "insensitive" },
|
title: { contains: q, mode: "insensitive" },
|
||||||
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
||||||
|
...kidsWhere,
|
||||||
},
|
},
|
||||||
orderBy: { title: "asc" },
|
orderBy: { title: "asc" },
|
||||||
take,
|
take,
|
||||||
|
|
@ -1000,12 +1070,18 @@ export class ViewerService {
|
||||||
.slice(0, take);
|
.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();
|
const id = catalogId.trim().toLowerCase();
|
||||||
|
|
||||||
if (id === "continue-watching" || id === "continue" || id === "verder-kijken") {
|
if (id === "continue-watching" || id === "continue" || id === "verder-kijken") {
|
||||||
if (!viewerId) throw new AppError("UNAUTHORIZED", "Niet ingelogd", 401);
|
if (!profileId) throw new AppError("UNAUTHORIZED", "Niet ingelogd", 401);
|
||||||
const all = await this.continueWatching(viewerId);
|
const all = await this.continueWatching(profileId, kidsMode);
|
||||||
return {
|
return {
|
||||||
id: "continue-watching",
|
id: "continue-watching",
|
||||||
title: "Verder kijken",
|
title: "Verder kijken",
|
||||||
|
|
@ -1016,7 +1092,7 @@ export class ViewerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id === "movies" || id === "films") {
|
if (id === "movies" || id === "films") {
|
||||||
const page = await this.moviesByRecency(skip, take);
|
const page = await this.moviesByRecency(skip, take, Prisma.empty, { kidsMode });
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: "Films",
|
title: "Films",
|
||||||
|
|
@ -1027,7 +1103,7 @@ export class ViewerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id === "series" || id === "shows") {
|
if (id === "series" || id === "shows") {
|
||||||
const page = await this.seriesByRecency(skip, take);
|
const page = await this.seriesByRecency(skip, take, { kidsMode });
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: "Series",
|
title: "Series",
|
||||||
|
|
@ -1040,6 +1116,7 @@ export class ViewerService {
|
||||||
if (id === "recent-movies") {
|
if (id === "recent-movies") {
|
||||||
const page = await this.moviesByRecency(skip, take, Prisma.empty, {
|
const page = await this.moviesByRecency(skip, take, Prisma.empty, {
|
||||||
cap: RECENT_CATALOG_CAP,
|
cap: RECENT_CATALOG_CAP,
|
||||||
|
kidsMode,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -1051,7 +1128,10 @@ export class ViewerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id === "recent-series") {
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
title: "Recent toegevoegde series",
|
title: "Recent toegevoegde series",
|
||||||
|
|
@ -1070,7 +1150,7 @@ export class ViewerService {
|
||||||
OR mf.resolution ILIKE '%4K%'
|
OR mf.resolution ILIKE '%4K%'
|
||||||
OR mf.resolution ILIKE '%UHD%'
|
OR mf.resolution ILIKE '%UHD%'
|
||||||
)`,
|
)`,
|
||||||
{ cap: RECENT_CATALOG_CAP }
|
{ cap: RECENT_CATALOG_CAP, kidsMode }
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -1095,7 +1175,8 @@ export class ViewerService {
|
||||||
shelf.id,
|
shelf.id,
|
||||||
shelf.kind as "MOVIE" | "SERIES",
|
shelf.kind as "MOVIE" | "SERIES",
|
||||||
skip,
|
skip,
|
||||||
take
|
take,
|
||||||
|
kidsMode
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
id: shelf.slug,
|
id: shelf.slug,
|
||||||
|
|
@ -1106,13 +1187,13 @@ export class ViewerService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getShelfPrefs(viewerId: string) {
|
async getShelfPrefs(profileId: string) {
|
||||||
const shelves = await prisma.libraryShelf.findMany({
|
const shelves = await prisma.libraryShelf.findMany({
|
||||||
where: { enabled: true },
|
where: { enabled: true },
|
||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
});
|
});
|
||||||
const prefs = await prisma.viewerShelfPreference.findMany({
|
const prefs = await prisma.viewerShelfPreference.findMany({
|
||||||
where: { viewerUserId: viewerId },
|
where: { profileId },
|
||||||
});
|
});
|
||||||
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
|
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
|
||||||
return shelves.map((s, idx) => {
|
return shelves.map((s, idx) => {
|
||||||
|
|
@ -1129,16 +1210,16 @@ export class ViewerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async setShelfPrefs(
|
async setShelfPrefs(
|
||||||
viewerId: string,
|
profileId: string,
|
||||||
items: Array<{ shelfId: string; visible: boolean; sortOrder: number }>
|
items: Array<{ shelfId: string; visible: boolean; sortOrder: number }>
|
||||||
) {
|
) {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
await prisma.viewerShelfPreference.upsert({
|
await prisma.viewerShelfPreference.upsert({
|
||||||
where: {
|
where: {
|
||||||
viewerUserId_shelfId: { viewerUserId: viewerId, shelfId: item.shelfId },
|
profileId_shelfId: { profileId, shelfId: item.shelfId },
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
viewerUserId: viewerId,
|
profileId,
|
||||||
shelfId: item.shelfId,
|
shelfId: item.shelfId,
|
||||||
visible: item.visible,
|
visible: item.visible,
|
||||||
sortOrder: item.sortOrder,
|
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({
|
const movie = await prisma.movie.findUnique({
|
||||||
where: { id: movieId },
|
where: { id: movieId },
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -1164,6 +1245,9 @@ export class ViewerService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!movie) throw new AppError("NOT_FOUND", "Film niet gevonden", 404);
|
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(
|
const extras = await this.metadata.getStremioMetaExtras(
|
||||||
"movie",
|
"movie",
|
||||||
|
|
@ -1217,7 +1301,7 @@ export class ViewerService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async seriesDetail(seriesId: string) {
|
async seriesDetail(seriesId: string, kidsMode = false) {
|
||||||
const series = await prisma.series.findUnique({
|
const series = await prisma.series.findUnique({
|
||||||
where: { id: seriesId },
|
where: { id: seriesId },
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -1239,6 +1323,9 @@ export class ViewerService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!series) throw new AppError("NOT_FOUND", "Serie niet gevonden", 404);
|
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(
|
const extras = await this.metadata.getStremioMetaExtras(
|
||||||
"series",
|
"series",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue