From f58a088156c019eba7b48c468f49ff4694d170e6 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sun, 6 Sep 2026 03:01:00 +0200 Subject: [PATCH] Add Netflix-style viewer profiles with kids filter on master, TV, and mobile. Co-authored-by: Cursor --- apps/android-tv/app-mobile/build.gradle.kts | 4 +- .../nl/vonas/mediacluster/tv/MainActivity.kt | 5 + .../vonas/mediacluster/tv/data/ApiClient.kt | 144 ++- .../mediacluster/tv/data/SessionStore.kt | 70 +- .../vonas/mediacluster/tv/ui/BrowseScreen.kt | 101 +- .../vonas/mediacluster/tv/ui/DetailScreens.kt | 95 +- .../mediacluster/tv/ui/LiveTvPlayerScreen.kt | 5 +- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 432 +++++++-- .../vonas/mediacluster/tv/ui/McTransitions.kt | 28 + .../vonas/mediacluster/tv/ui/PlayerScreen.kt | 46 +- .../vonas/mediacluster/tv/ui/SearchScreen.kt | 150 ++- .../nl/vonas/mediacluster/tv/ui/SystemUi.kt | 81 ++ .../nl/vonas/mediacluster/tv/ui/TvScreen.kt | 42 +- .../tv/ui/components/Components.kt | 41 +- .../mediacluster/tv/ui/components/Polish.kt | 192 ++++ .../tv/ui/profiles/ProfileAvatars.kt | 19 + .../tv/ui/profiles/ProfilesScreen.kt | 487 ++++++++++ .../mediacluster/tv/ui/theme/McDimens.kt | 11 +- .../vonas/mediacluster/tv/ui/theme/Theme.kt | 6 +- apps/android-tv/app/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 93 ++ .../vonas/mediacluster/tv/data/HomeCache.kt | 100 ++ .../mediacluster/tv/data/SessionStore.kt | 131 ++- .../vonas/mediacluster/tv/ui/BrowseScreen.kt | 91 +- .../vonas/mediacluster/tv/ui/DetailScreens.kt | 62 +- .../mediacluster/tv/ui/LiveTvPlayerScreen.kt | 52 ++ .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 863 +++++++++++++----- .../vonas/mediacluster/tv/ui/McTransitions.kt | 21 + .../vonas/mediacluster/tv/ui/PlayerScreen.kt | 46 +- .../vonas/mediacluster/tv/ui/SearchScreen.kt | 72 +- .../nl/vonas/mediacluster/tv/ui/TvScreen.kt | 502 +++++++--- .../mediacluster/tv/ui/components/Polish.kt | 188 ++++ .../mediacluster/tv/ui/components/TopNav.kt | 110 ++- .../tv/ui/profiles/ProfileAvatars.kt | 19 + .../tv/ui/profiles/ProfilesScreen.kt | 487 ++++++++++ .../migration.sql | 114 +++ apps/master-api/prisma/schema.prisma | 64 +- apps/master-api/src/metadata/service.ts | 10 + apps/master-api/src/viewer/kids-filter.ts | 74 ++ apps/master-api/src/viewer/profiles.ts | 313 +++++++ apps/master-api/src/viewer/routes.ts | 121 ++- apps/master-api/src/viewer/service.ts | 189 ++-- 42 files changed, 4940 insertions(+), 745 deletions(-) create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McTransitions.kt create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SystemUi.kt create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Polish.kt create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfileAvatars.kt create mode 100644 apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/HomeCache.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McTransitions.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Polish.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfileAvatars.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt create mode 100644 apps/master-api/prisma/migrations/20260906030000_viewer_profiles/migration.sql create mode 100644 apps/master-api/src/viewer/kids-filter.ts create mode 100644 apps/master-api/src/viewer/profiles.ts diff --git a/apps/android-tv/app-mobile/build.gradle.kts b/apps/android-tv/app-mobile/build.gradle.kts index b579e19..f8e1048 100644 --- a/apps/android-tv/app-mobile/build.gradle.kts +++ b/apps/android-tv/app-mobile/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster" minSdk = 24 targetSdk = 36 - versionCode = 4 - versionName = "1.0.3" + versionCode = 9 + versionName = "1.0.8" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/MainActivity.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/MainActivity.kt index aa6ea44..02f62e7 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/MainActivity.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/MainActivity.kt @@ -5,6 +5,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.core.view.WindowCompat import nl.vonas.mediacluster.tv.ui.McNav import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme @@ -13,6 +14,10 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) volumeControlStream = AudioManager.STREAM_MUSIC enableEdgeToEdge() + WindowCompat.getInsetsController(window, window.decorView).apply { + isAppearanceLightStatusBars = false + isAppearanceLightNavigationBars = false + } setContent { MediaClusterTheme { McNav() diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 6f55b88..7f8d29a 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -196,6 +196,60 @@ class ApiClient( Unit } + suspend fun listProfiles(): ProfilesPayload = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/profiles").get().build() + parseObject(execute(req)) { o -> + ProfilesPayload( + profiles = o.optJSONArray("profiles").toProfiles(), + activeProfileId = o.optString("activeProfileId").takeIf { it.isNotBlank() && it != "null" }, + canManage = o.optBoolean("canManage", false), + ) + } + } + + suspend fun selectProfile(profileId: String, pin: String? = null): ViewerProfile = + withContext(Dispatchers.IO) { + val body = JSONObject() + if (!pin.isNullOrBlank()) body.put("pin", pin) + val req = authed("$baseUrl/api/v1/client/profiles/$profileId/select") + .post(body.toString().toRequestBody(jsonMedia)) + .build() + parseObject(execute(req)) { o -> + o.getJSONObject("profile").toProfile() + } + } + + suspend fun clearProfile() = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/profiles/clear") + .post("{}".toRequestBody(jsonMedia)) + .build() + execute(req) + Unit + } + + suspend fun createProfile( + name: String, + avatarId: String, + isKids: Boolean, + pin: String?, + ): ViewerProfile = withContext(Dispatchers.IO) { + val body = JSONObject() + .put("name", name) + .put("avatarId", avatarId) + .put("isKids", isKids) + if (!pin.isNullOrBlank()) body.put("pin", pin) + val req = authed("$baseUrl/api/v1/client/profiles") + .post(body.toString().toRequestBody(jsonMedia)) + .build() + parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() } + } + + suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build() + execute(req) + Unit + } + suspend fun searchSubtitles(mediaFileId: String, languages: String = "nl,en"): SubtitleSearchResult = withContext(Dispatchers.IO) { val id = java.net.URLEncoder.encode(mediaFileId, "UTF-8") @@ -263,6 +317,7 @@ class ApiClient( parseObject(execute(req)) { o -> ClientMe( iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true, + profile = o.optJSONObject("profile")?.toProfile(), ) } } @@ -286,6 +341,15 @@ class ApiClient( } } + private fun JSONObject.optClean(key: String): String? { + if (!has(key) || isNull(key)) return null + val raw = optString(key).trim() + if (raw.isEmpty() || raw.equals("null", ignoreCase = true) || raw.equals("undefined", ignoreCase = true)) { + return null + } + return raw + } + suspend fun iptvChannels(categoryId: String? = null, includeEpg: Boolean = false): List = withContext(Dispatchers.IO) { val params = buildList { categoryId?.let { add("categoryId=${java.net.URLEncoder.encode(it, "UTF-8")}") } @@ -302,10 +366,10 @@ class ApiClient( IptvChannel( streamId = c.getInt("streamId"), name = c.getString("name"), - logoUrl = c.optString("logoUrl").ifBlank { null }, + logoUrl = c.optClean("logoUrl"), number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") }, - categoryId = c.optString("categoryId").ifBlank { null }, - nowTitle = c.optString("nowTitle").ifBlank { null }, + categoryId = c.optClean("categoryId"), + nowTitle = c.optClean("nowTitle"), ) ) } @@ -341,25 +405,47 @@ class ApiClient( } } - /** Vul ontbrekende now-titels aan (server-batch of parallel per zender). */ - suspend fun fetchMissingNowTitles(channels: List): Map { + /** Vul ontbrekende now-titels aan; [onBatch] voor live UI-updates. */ + suspend fun fetchMissingNowTitles( + channels: List, + onBatch: ((Map) -> Unit)? = null, + ): Map { + fun clean(title: String?) = title?.takeIf { + it.isNotBlank() && !it.equals("null", ignoreCase = true) + } + val result = linkedMapOf() channels.forEach { ch -> - ch.nowTitle?.takeIf { it.isNotBlank() }?.let { result[ch.streamId] = it } + clean(ch.nowTitle)?.let { result[ch.streamId] = it } } + if (result.isNotEmpty()) { + withContext(Dispatchers.Main.immediate) { + onBatch?.invoke(result.toMap()) + } + } + val missing = channels.filter { !result.containsKey(it.streamId) } if (missing.isEmpty()) return result coroutineScope { - missing.chunked(10).forEach { batch -> + missing.chunked(12).forEach { batch -> + val chunk = linkedMapOf() batch.map { ch -> async(Dispatchers.IO) { runCatching { - iptvEpg(ch.streamId).now?.title?.takeIf { it.isNotBlank() } + clean(iptvEpg(ch.streamId).now?.title) }.getOrNull()?.let { ch.streamId to it } } }.awaitAll().forEach { pair -> - pair?.let { (id, title) -> result[id] = title } + pair?.let { (id, title) -> + result[id] = title + chunk[id] = title + } + } + if (chunk.isNotEmpty()) { + withContext(Dispatchers.Main.immediate) { + onBatch?.invoke(chunk) + } } } } @@ -367,11 +453,11 @@ class ApiClient( } private fun JSONObject.toEpgProgram(): EpgProgram? { - val title = optString("title").ifBlank { return null } + val title = optClean("title") ?: return null return EpgProgram( title = title, - start = optString("start").ifBlank { null }, - end = optString("end").ifBlank { null }, + start = optClean("start"), + end = optClean("end"), ) } @@ -500,6 +586,22 @@ data class ShelfPref( data class ClientMe( val iptvEnabled: Boolean, + val profile: ViewerProfile? = null, +) + +data class ViewerProfile( + val id: String, + val name: String, + val avatarId: String, + val isOwner: Boolean, + val isKids: Boolean, + val hasPin: Boolean, +) + +data class ProfilesPayload( + val profiles: List, + val activeProfileId: String?, + val canManage: Boolean, ) data class IptvCategory( @@ -585,3 +687,21 @@ private fun JSONArray?.toShelves(): List { } } } + +private fun JSONArray?.toProfiles(): List { + 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"), +) diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt index 1664467..1813fa4 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt @@ -7,25 +7,93 @@ import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import nl.vonas.mediacluster.BuildConfig +import org.json.JSONArray private val Context.dataStore by preferencesDataStore("mc_mobile") class SessionStore(private val context: Context) { private val tokenKey = stringPreferencesKey("refresh_token") private val apiKey = stringPreferencesKey("api_base") + private val profileIdKey = stringPreferencesKey("profile_id") + private val profileNameKey = stringPreferencesKey("profile_name") + private val profileAvatarKey = stringPreferencesKey("profile_avatar") + private val recentSearchKey = stringPreferencesKey("recent_searches") val refreshToken: Flow = context.dataStore.data.map { it[tokenKey] } val apiBase: Flow = context.dataStore.data.map { it[apiKey] ?: BuildConfig.DEFAULT_API_BASE } + val profileId: Flow = context.dataStore.data.map { it[profileIdKey] } + val profileName: Flow = context.dataStore.data.map { it[profileNameKey] } + val profileAvatarId: Flow = context.dataStore.data.map { it[profileAvatarKey] } + + val recentSearches: Flow> = context.dataStore.data.map { prefs -> + val raw = prefs[recentSearchKey].orEmpty() + if (raw.isBlank()) emptyList() + else runCatching { + val arr = JSONArray(raw) + buildList { + for (i in 0 until arr.length()) { + val q = arr.optString(i).trim() + if (q.isNotBlank()) add(q) + } + } + }.getOrDefault(emptyList()) + } suspend fun setToken(token: String?) { context.dataStore.edit { prefs -> - if (token.isNullOrBlank()) prefs.remove(tokenKey) else prefs[tokenKey] = token + if (token.isNullOrBlank()) { + prefs.remove(tokenKey) + prefs.remove(profileIdKey) + prefs.remove(profileNameKey) + prefs.remove(profileAvatarKey) + } else { + prefs[tokenKey] = token + } + } + } + + suspend fun setProfile(id: String?, name: String? = null, avatarId: String? = null) { + context.dataStore.edit { prefs -> + if (id.isNullOrBlank()) { + prefs.remove(profileIdKey) + prefs.remove(profileNameKey) + prefs.remove(profileAvatarKey) + } else { + prefs[profileIdKey] = id + if (!name.isNullOrBlank()) prefs[profileNameKey] = name else prefs.remove(profileNameKey) + if (!avatarId.isNullOrBlank()) prefs[profileAvatarKey] = avatarId + else prefs.remove(profileAvatarKey) + } } } suspend fun setApiBase(url: String) { context.dataStore.edit { it[apiKey] = url.trim().trimEnd('/') } } + + suspend fun addRecentSearch(query: String) { + val q = query.trim() + if (q.length < 2) return + context.dataStore.edit { prefs -> + val existing = prefs[recentSearchKey].orEmpty().let { raw -> + if (raw.isBlank()) mutableListOf() + else runCatching { + val arr = JSONArray(raw) + MutableList(arr.length()) { i -> arr.optString(i) } + .filter { it.isNotBlank() } + .toMutableList() + }.getOrElse { mutableListOf() } + } + existing.removeAll { it.equals(q, ignoreCase = true) } + existing.add(0, q) + while (existing.size > 8) existing.removeAt(existing.lastIndex) + prefs[recentSearchKey] = JSONArray(existing).toString() + } + } + + suspend fun clearRecentSearches() { + context.dataStore.edit { it.remove(recentSearchKey) } + } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt index 0ce1123..77fa309 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -35,8 +36,10 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.MediaItem +import nl.vonas.mediacluster.tv.ui.components.EmptyState import nl.vonas.mediacluster.tv.ui.components.McButton import nl.vonas.mediacluster.tv.ui.components.PosterCard +import nl.vonas.mediacluster.tv.ui.components.SkeletonBox import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McDimens @@ -147,7 +150,11 @@ fun BrowseScreen( fontWeight = FontWeight.Bold, ) Text( - if (total > 0) "$total titels" else if (loading) "Laden…" else "Geen resultaten", + when { + total > 0 -> "$total titels" + loading -> "Laden…" + else -> "Geen titels" + }, color = McColors.TextDim, fontSize = McDimens.meta, ) @@ -159,42 +166,70 @@ fun BrowseScreen( Spacer(Modifier.height(12.dp)) if (error != null) { Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) + Spacer(Modifier.height(8.dp)) } - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = minCell), - state = gridState, - contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp), - horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap), - verticalArrangement = Arrangement.spacedBy(McDimens.gridGap), - modifier = Modifier.fillMaxSize(), - ) { - items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item -> - PosterCard( - item = item, - fillCell = true, - onClick = { - val canResume = onPlayContinue != null && - !item.mediaFileId.isNullOrBlank() && - (item.positionMs > 0 || catalogId == "continue-watching") - if (canResume) { - onPlayContinue!!( - item.mediaFileId!!, - item.id.takeIf { item.type == "movie" }, - item.id.takeIf { item.type == "episode" }, - item.seriesId, - if (item.type == "episode") "EPISODE" else "MOVIE", - item.positionMs, - ) - } else if (item.type == "movie") { - onOpenMovie(item.id) - } else if (item.type == "episode" && !item.seriesId.isNullOrBlank()) { - onOpenSeries(item.seriesId!!) - } else { - onOpenSeries(item.id) + when { + loading && items.isEmpty() -> { + Column(verticalArrangement = Arrangement.spacedBy(McDimens.gridGap)) { + repeat(3) { + Row(horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap)) { + repeat(4) { + SkeletonBox( + Modifier + .weight(1f) + .aspectRatio(2f / 3f), + ) + } } - }, + } + } + } + !loading && items.isEmpty() && error == null -> { + EmptyState( + title = "Geen titels gevonden", + subtitle = "Deze collectie is leeg of nog niet gesynchroniseerd.", + actionLabel = "Opnieuw proberen", + onAction = { scope.launch { load(reset = true) } }, ) } + else -> { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = minCell), + state = gridState, + contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap), + verticalArrangement = Arrangement.spacedBy(McDimens.gridGap), + modifier = Modifier.fillMaxSize(), + ) { + items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item -> + PosterCard( + item = item, + fillCell = true, + onClick = { + val canResume = onPlayContinue != null && + !item.mediaFileId.isNullOrBlank() && + (item.positionMs > 0 || catalogId == "continue-watching") + if (canResume) { + onPlayContinue!!( + item.mediaFileId!!, + item.id.takeIf { item.type == "movie" }, + item.id.takeIf { item.type == "episode" }, + item.seriesId, + if (item.type == "episode") "EPISODE" else "MOVIE", + item.positionMs, + ) + } else if (item.type == "movie") { + onOpenMovie(item.id) + } else if (item.type == "episode" && !item.seriesId.isNullOrBlank()) { + onOpenSeries(item.seriesId!!) + } else { + onOpenSeries(item.id) + } + }, + ) + } + } + } } } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt index a5d8082..08c3e07 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt @@ -368,40 +368,12 @@ fun MovieDetailScreen( ) { DetailLogoOrTitle(logoUrl, title) MetaChips(metaParts) - SelectedQualityLine(selected) + if (versions.size > 1) { + SelectedQualityLine(selected) + } } } - if (director.isNotEmpty()) { - Text( - "Regie: ${director.take(2).joinToString(", ")}", - color = Color(0xFF9A9A9A), - fontSize = McDimens.meta, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (overview.isNotBlank()) { - Text( - overview, - color = McColors.TextDim, - fontSize = McDimens.bodySmall, - maxLines = 6, - overflow = TextOverflow.Ellipsis, - lineHeight = 18.sp, - ) - } - if (cast.isNotEmpty()) { - Text( - "Met: ${cast.take(4).joinToString(", ")}", - color = Color(0xFF8A8A8A), - fontSize = McDimens.meta, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) - Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), @@ -416,13 +388,46 @@ fun MovieDetailScreen( openYoutubeTrailer(context, trailerKey!!) } } - DetailActionButton("Als gekeken") { + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DetailActionButton("Markeer als gezien") { scope.launch { runCatching { api.markWatched(movieId = movieId) } onBack() } } } + + if (overview.isNotBlank()) { + Text( + overview, + color = McColors.TextDim, + fontSize = McDimens.bodySmall, + maxLines = 6, + overflow = TextOverflow.Ellipsis, + lineHeight = 18.sp, + ) + } + if (director.isNotEmpty()) { + Text( + "Regie · ${director.take(2).joinToString(", ")}", + color = Color(0xFF9A9A9A), + fontSize = McDimens.meta, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (cast.isNotEmpty()) { + Text( + "Met · ${cast.take(4).joinToString(", ")}", + color = Color(0xFF8A8A8A), + fontSize = McDimens.meta, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) + if (versions.size > 1) { Text( "Kwaliteit", @@ -447,6 +452,7 @@ fun MovieDetailScreen( Spacer(Modifier.height(24.dp)) } } + StatusBarGlassOverlay(Modifier.align(Alignment.TopCenter)) } } @@ -569,18 +575,6 @@ fun SeriesDetailScreen( Spacer(Modifier.height(6.dp)) DetailLogoOrTitle(logoUrl, title) MetaChips(metaParts) - if (overview.isNotBlank()) { - Text( - overview, - color = McColors.TextDim, - fontSize = McDimens.bodySmall, - maxLines = 5, - overflow = TextOverflow.Ellipsis, - lineHeight = 18.sp, - modifier = Modifier.fillMaxWidth().padding(top = 2.dp), - ) - } - if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) Spacer(Modifier.height(4.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { episodes.firstOrNull()?.let { ep -> @@ -592,6 +586,18 @@ fun SeriesDetailScreen( DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) } } } + if (overview.isNotBlank()) { + Text( + overview, + color = McColors.TextDim, + fontSize = McDimens.bodySmall, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + lineHeight = 18.sp, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + ) + } + if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) if (seasons.size > 1) { Spacer(Modifier.height(6.dp)) Text("Seizoenen", color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.SemiBold) @@ -619,6 +625,7 @@ fun SeriesDetailScreen( ) } } + StatusBarGlassOverlay(Modifier.align(Alignment.TopCenter)) } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt index 9df48e8..e2e2491 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt @@ -100,6 +100,7 @@ fun LiveTvPlayerScreen( val player = remember { buildLiveTvPlayer(context) } val playerFocus = remember { FocusRequester() } val menuScope = rememberCoroutineScope() + RememberPlayerImmersiveMode() var currentStreamId by remember { mutableIntStateOf(streamId) } var loading by remember { mutableStateOf(true) } @@ -504,7 +505,7 @@ fun LiveTvPlayerScreen( ) { info.now?.let { now -> Text( - "Nu · ${now.title}", + "Nu · ${now.title}", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium, @@ -514,7 +515,7 @@ fun LiveTvPlayerScreen( } info.next?.let { next -> Text( - "Straks · ${next.title}", + "Straks · ${next.title}", color = McColors.TextDim, fontSize = 15.sp, maxLines = 1, diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 068b8af..6c9dff8 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -1,9 +1,15 @@ package nl.vonas.mediacluster.tv.ui +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.tween import android.app.Activity import android.os.Build import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -25,6 +32,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableIntStateOf @@ -33,6 +41,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.DisposableEffect +import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -55,20 +64,28 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Switch import androidx.tv.material3.Text import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import nl.vonas.mediacluster.BuildConfig import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.HomePayload import nl.vonas.mediacluster.tv.data.MediaItem import nl.vonas.mediacluster.tv.data.PollResult import nl.vonas.mediacluster.tv.data.SessionStore import nl.vonas.mediacluster.tv.data.ShelfPref +import nl.vonas.mediacluster.tv.ui.components.EmptyState +import nl.vonas.mediacluster.tv.ui.components.HomeSkeleton import nl.vonas.mediacluster.tv.ui.components.MainTab import nl.vonas.mediacluster.tv.ui.components.McButton import nl.vonas.mediacluster.tv.ui.components.PosterCard +import nl.vonas.mediacluster.tv.ui.components.SectionLabel import nl.vonas.mediacluster.tv.ui.components.SubNavItem import nl.vonas.mediacluster.tv.ui.components.TopNavBar +import nl.vonas.mediacluster.tv.ui.profiles.ProfilesScreen +import nl.vonas.mediacluster.tv.ui.profiles.avatarEmoji import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McDimens @@ -81,6 +98,9 @@ fun McNav() { val scope = rememberCoroutineScope() var ready by remember { mutableStateOf(false) } var authed by remember { mutableStateOf(false) } + var hasProfile by remember { mutableStateOf(false) } + val profileAvatarId by store.profileAvatarId.collectAsState(initial = null) + val profileName by store.profileName.collectAsState(initial = null) // Blijft behouden als main uit compositie gaat (bijv. live player) var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) } var tvCategoryId by rememberSaveable { mutableStateOf(null) } @@ -95,6 +115,18 @@ fun McNav() { val token = store.refreshToken.first() api.configure(base, token) authed = !token.isNullOrBlank() + if (authed) { + val me = runCatching { api.clientMe() }.getOrNull() + if (me?.profile != null) { + store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId) + hasProfile = true + } else { + store.setProfile(null) + hasProfile = false + } + } else { + hasProfile = false + } ready = true } @@ -103,11 +135,35 @@ fun McNav() { return } - NavHost(navController = nav, startDestination = if (authed) "main" else "link") { + NavHost( + navController = nav, + startDestination = when { + !authed -> "link" + !hasProfile -> "profiles" + else -> "main" + }, + enterTransition = { McTransitions.enter }, + exitTransition = { McTransitions.exit }, + popEnterTransition = { McTransitions.popEnter }, + popExitTransition = { McTransitions.popExit }, + ) { composable("link") { LinkScreen(api, store) { authed = true - nav.navigate("main") { popUpTo("link") { inclusive = true } } + hasProfile = false + nav.navigate("profiles") { popUpTo("link") { inclusive = true } } + } + } + composable("profiles") { + ProfilesScreen(api = api, store = store, switcherMode = true) { + hasProfile = true + nav.navigate("main") { popUpTo("profiles") { inclusive = true } } + } + } + composable("profiles_manage") { + ProfilesScreen(api = api, store = store, switcherMode = false) { + hasProfile = true + nav.popBackStack() } } composable("main") { @@ -121,6 +177,8 @@ fun McNav() { onTvLastStreamChange = { tvLastStreamId = it }, tvFocusNonce = tvFocusNonce, onTvFocusContent = { tvFocusNonce++ }, + profileAvatarId = profileAvatarId, + profileName = profileName, onOpenMovie = { nav.navigate("movie/$it") }, onOpenSeries = { nav.navigate("series/$it") }, onBrowse = { catalogId, _title -> @@ -137,11 +195,25 @@ fun McNav() { if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name nav.navigate("live/$streamId") }, + onSwitchProfile = { + scope.launch { + runCatching { api.clearProfile() } + store.setProfile(null) + hasProfile = false + nav.navigate("profiles") { + popUpTo("main") { inclusive = true } + } + } + }, + onManageProfiles = { + nav.navigate("profiles_manage") + }, onLogout = { scope.launch { store.setToken(null) api.configure(store.apiBase.first(), null) authed = false + hasProfile = false nav.navigate("link") { popUpTo(0) { inclusive = true } } } }, @@ -246,11 +318,15 @@ private fun MainHub( onTvLastStreamChange: (Int?) -> Unit, tvFocusNonce: Int, onTvFocusContent: () -> Unit, + profileAvatarId: String?, + profileName: String?, onOpenMovie: (String) -> Unit, onOpenSeries: (String) -> Unit, onBrowse: (catalogId: String, title: String) -> Unit, onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit, onPlayLive: (Int) -> Unit, + onSwitchProfile: () -> Unit, + onManageProfiles: () -> Unit, onLogout: () -> Unit, ) { var iptvEnabled by remember { mutableStateOf(false) } @@ -370,7 +446,14 @@ private fun MainHub( Box(Modifier.fillMaxSize().background(McColors.Bg)) { Column(Modifier.fillMaxSize()) { Box(Modifier.weight(1f).fillMaxWidth()) { - when (tab) { + AnimatedContent( + targetState = tab, + transitionSpec = { + fadeIn(tween(200)) togetherWith fadeOut(tween(160)) + }, + label = "hubTab", + ) { currentTab -> + when (currentTab) { MainTab.Home -> HomeScreen( api = api, listState = homeListState, @@ -419,10 +502,46 @@ private fun MainHub( onTabChange(MainTab.Home) focusTopNav() }, + onSwitchProfile = onSwitchProfile, + onManageProfiles = onManageProfiles, onLogout = onLogout, ) } } + StatusBarGlassOverlay(Modifier.align(Alignment.TopCenter)) + // Profiel-avatar rechtsboven → terug naar switcher + Row( + Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(top = 8.dp, end = 12.dp) + .clip(RoundedCornerShape(20.dp)) + .background(Color(0xCC1A1A22)) + .clickable(onClick = onSwitchProfile) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(0xFF2E2E3A)), + contentAlignment = Alignment.Center, + ) { + Text(avatarEmoji(profileAvatarId ?: "fox"), fontSize = 16.sp) + } + if (!profileName.isNullOrBlank()) { + Text( + profileName, + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + } + } + } Box(Modifier.focusRequester(navFocus)) { TopNavBar( selected = tab, @@ -611,20 +730,37 @@ private fun HomeScreen( ) { var home by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } var continueMenu by remember { mutableStateOf(null) } var menuReady by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val menuFocus = remember { FocusRequester() } + val context = LocalContext.current suspend fun reloadHome() { home = api.home() + // Prefetch posters voor snellere shelves + val urls = buildList { + home?.spotlight?.forEach { item -> + item.backdropUrl?.let(::add) + item.posterUrl?.let(::add) + } + home?.continueWatching?.take(8)?.forEach { it.posterUrl?.let(::add) } + home?.shelves?.flatMap { it.items }?.take(36)?.forEach { it.posterUrl?.let(::add) } + }.distinct().take(48) + val loader = context.imageLoader + urls.forEach { url -> + loader.enqueue(ImageRequest.Builder(context).data(url).build()) + } } LaunchedEffect(Unit) { try { reloadHome() } catch (e: Exception) { - error = e.message + error = e.message ?: "Kon Home niet laden" + } finally { + loading = false } } @@ -642,13 +778,17 @@ private fun HomeScreen( val spotlight = home?.spotlight.orEmpty().ifEmpty { home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty() } + val continueItems = home?.continueWatching.orEmpty() Box(Modifier.fillMaxSize()) { + if (loading && home == null) { + HomeSkeleton() + } else { LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 12.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(McDimens.shelfBlockGap), ) { item { SpotlightBillboard( @@ -684,7 +824,6 @@ private fun HomeScreen( ) } } - val continueItems = home?.continueWatching.orEmpty() if (continueItems.isNotEmpty()) { val preview = continueItems.take(5) item { @@ -731,6 +870,15 @@ private fun HomeScreen( }, ) } + if (home != null && home!!.shelves.isEmpty() && continueItems.isEmpty() && error == null) { + item { + EmptyState( + title = "Nog geen titels", + subtitle = "Zodra er content beschikbaar is, zie je die hier.", + ) + } + } + } } val menuItem = continueMenu @@ -755,7 +903,7 @@ private fun HomeScreen( Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = McDimens.bodySmall) } Spacer(Modifier.height(4.dp)) - McButton("Markeren als gezien") { + McButton("Markeer als gezien") { if (!menuReady) return@McButton scope.launch { runCatching { @@ -769,7 +917,7 @@ private fun HomeScreen( continueMenu = null } } - McButton("Verbergen") { + McButton("Verberg uit lijst") { if (!menuReady) return@McButton scope.launch { val pid = menuItem.progressId @@ -810,19 +958,29 @@ private fun SpotlightBillboard( } Box(Modifier.fillMaxWidth().height(billboardH)) { - AsyncImage( - model = current.backdropUrl ?: current.posterUrl, - contentDescription = current.title, + AnimatedContent( + targetState = current, + transitionSpec = { fadeIn(tween(500)) togetherWith fadeOut(tween(500)) }, + label = "spotlight", modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - Box( - Modifier.fillMaxSize().background( - Brush.verticalGradient( - listOf(Color(0x22000000), Color(0x66000000), Color(0xF20B0B0B)), - ), - ), - ) + contentKey = { "${it.type}:${it.id}" }, + ) { slide -> + Box(Modifier.fillMaxSize()) { + AsyncImage( + model = slide.backdropUrl ?: slide.posterUrl, + contentDescription = slide.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + Box( + Modifier.fillMaxSize().background( + Brush.verticalGradient( + listOf(Color(0x22000000), Color(0x66000000), Color(0xF20B0B0B)), + ), + ), + ) + } + } Column( Modifier @@ -859,7 +1017,7 @@ private fun SpotlightBillboard( fontWeight = FontWeight.Black, maxLines = 2, overflow = TextOverflow.Ellipsis, - lineHeight = 30.sp, + lineHeight = 32.sp, ) val meta = listOfNotNull( current.year?.toString(), @@ -871,7 +1029,7 @@ private fun SpotlightBillboard( } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (current.id.isNotBlank()) { - McButton("▶ Afspelen", primary = true, compact = true, onClick = { onPlay(current) }) + McButton("Afspelen", primary = true, compact = true, onClick = { onPlay(current) }) McButton("Meer info", compact = true, onClick = { onOpen(current) }) } } @@ -905,7 +1063,17 @@ private fun ShelfBlock( val posterH = McDimens.posterHeight(posterW) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row( - Modifier.fillMaxWidth().padding(horizontal = McDimens.screenPad), + Modifier + .fillMaxWidth() + .padding(horizontal = McDimens.screenPad) + .then( + if (onSeeAll != null) { + Modifier.clickable(onClick = onSeeAll) + } else { + Modifier + }, + ) + .padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -919,7 +1087,13 @@ private fun ShelfBlock( overflow = TextOverflow.Ellipsis, ) if (onSeeAll != null) { - McButton("Alles", compact = true) { onSeeAll() } + Text( + "›", + color = Color(0xFF888888), + fontSize = 22.sp, + fontWeight = FontWeight.Light, + modifier = Modifier.padding(start = 8.dp), + ) } } LazyRow( @@ -944,6 +1118,8 @@ private fun ShelfBlock( private fun SettingsScreen( api: ApiClient, onBack: () -> Unit, + onSwitchProfile: () -> Unit = {}, + onManageProfiles: () -> Unit = {}, onLogout: () -> Unit = {}, ) { var prefs by remember { mutableStateOf>(emptyList()) } @@ -973,16 +1149,106 @@ private fun SettingsScreen( Modifier .fillMaxSize() .background(McColors.Bg) - .statusBarsPadding() - .padding(horizontal = McDimens.screenPad, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .statusBarsPadding(), + contentPadding = PaddingValues( + start = McDimens.screenPad, + end = McDimens.screenPad, + top = 12.dp, + bottom = 28.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { - Text("Instellingen", color = Color.White, fontSize = McDimens.titleScreen, fontWeight = FontWeight.Bold) - Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = McDimens.bodySmall) - if (error != null) Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) + Text( + "Instellingen", + color = Color.White, + fontSize = McDimens.titleScreen, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(16.dp)) + SectionLabel("Account") Spacer(Modifier.height(8.dp)) - McButton("Uitloggen", compact = true, onClick = onLogout) + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(McColors.BgElevated) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + "Vonas Media Cluster", + color = Color.White, + fontSize = McDimens.body, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Versie ${BuildConfig.VERSION_NAME}", + color = McColors.TextDim, + fontSize = McDimens.meta, + ) + } + Spacer(Modifier.height(8.dp)) + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(McColors.BgElevated) + .clickable(onClick = onSwitchProfile) + .padding(horizontal = 16.dp, vertical = 16.dp), + ) { + Text( + "Profiel wisselen", + color = Color.White, + fontSize = McDimens.body, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.height(8.dp)) + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(McColors.BgElevated) + .clickable(onClick = onManageProfiles) + .padding(horizontal = 16.dp, vertical = 16.dp), + ) { + Text( + "Profielen beheren", + color = Color.White, + fontSize = McDimens.body, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.height(8.dp)) + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(McColors.BgElevated) + .clickable(onClick = onLogout) + .padding(horizontal = 16.dp, vertical = 16.dp), + ) { + Text( + "Uitloggen", + color = McColors.Danger, + fontSize = McDimens.body, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.height(20.dp)) + SectionLabel("Home-planken") + Spacer(Modifier.height(4.dp)) + Text( + "Kies welke rijen op Home staan en in welke volgorde.", + color = McColors.TextDim, + fontSize = McDimens.bodySmall, + lineHeight = 18.sp, + ) + if (error != null) { + Spacer(Modifier.height(8.dp)) + Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) + } Spacer(Modifier.height(4.dp)) } itemsIndexed(prefs) { index, p -> @@ -991,30 +1257,55 @@ private fun SettingsScreen( .fillMaxWidth() .clip(RoundedCornerShape(10.dp)) .background(McColors.BgElevated) - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, + .padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(p.name, color = Color.White, fontSize = McDimens.body, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - McButton("↑", compact = true) { - if (index > 0) { - val next = prefs.toMutableList() - val tmp = next[index - 1] - next[index - 1] = next[index] - next[index] = tmp - persist(next) - } - } - McButton("↓", compact = true) { - if (index < prefs.lastIndex) { - val next = prefs.toMutableList() - val tmp = next[index + 1] - next[index + 1] = next[index] - next[index] = tmp - persist(next) - } - } + Column(Modifier.weight(1f)) { + Text( + p.name, + color = Color.White, + fontSize = McDimens.body, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + if (p.visible) "Zichtbaar op Home" else "Verborgen", + color = if (p.visible) Color(0xFF888888) else Color(0xFF555555), + fontSize = McDimens.caption, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SettingsIconChip( + label = "↑", + enabled = index > 0, + onClick = { + if (index > 0) { + val next = prefs.toMutableList() + val tmp = next[index - 1] + next[index - 1] = next[index] + next[index] = tmp + persist(next) + } + }, + ) + SettingsIconChip( + label = "↓", + enabled = index < prefs.lastIndex, + onClick = { + if (index < prefs.lastIndex) { + val next = prefs.toMutableList() + val tmp = next[index + 1] + next[index + 1] = next[index] + next[index] = tmp + persist(next) + } + }, + ) + Spacer(Modifier.width(6.dp)) Switch( checked = p.visible, onCheckedChange = { checked -> @@ -1026,6 +1317,39 @@ private fun SettingsScreen( } } } + item { + Spacer(Modifier.height(12.dp)) + Text( + "Vonas Media Cluster · ${BuildConfig.VERSION_NAME}", + color = Color(0xFF555555), + fontSize = McDimens.caption, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun SettingsIconChip( + label: String, + enabled: Boolean, + onClick: () -> Unit, +) { + Box( + Modifier + .clip(RoundedCornerShape(6.dp)) + .background(if (enabled) Color(0x22FFFFFF) else Color(0x0AFFFFFF)) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + label, + color = if (enabled) Color.White else Color(0xFF444444), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McTransitions.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McTransitions.kt new file mode 100644 index 0000000..00b7a20 --- /dev/null +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/McTransitions.kt @@ -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.defaultEnter() = enter + fun AnimatedContentTransitionScope.defaultExit() = exit + fun AnimatedContentTransitionScope.defaultPopEnter() = popEnter + fun AnimatedContentTransitionScope.defaultPopExit() = popExit +} diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index 38f343f..251b81c 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -1,12 +1,12 @@ package nl.vonas.mediacluster.tv.ui +import android.util.Base64 import android.view.ViewGroup.LayoutParams import android.widget.FrameLayout import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import android.util.Base64 import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable @@ -27,6 +27,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -95,6 +97,7 @@ fun PlayerScreen( val controlsFocus = remember { FocusRequester() } val catcherFocus = remember { FocusRequester() } val scope = rememberCoroutineScope() + RememberPlayerImmersiveMode() var error by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } @@ -387,7 +390,7 @@ fun PlayerScreen( } if (loading && error == null) { - Text("Laden…", color = Color(0xFFB0B8C8), fontSize = 18.sp, modifier = Modifier.align(Alignment.Center)) + Text("Bezig met laden…", color = Color(0xFFB0B8C8), fontSize = 16.sp, modifier = Modifier.align(Alignment.Center)) } if (error != null) { Column( @@ -459,24 +462,31 @@ fun PlayerScreen( Modifier .align(Alignment.BottomStart) .fillMaxWidth() - .padding(horizontal = 48.dp, vertical = 28.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .padding(horizontal = 24.dp, vertical = 28.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Box( - Modifier - .fillMaxWidth() - .height(3.dp) - .clip(RoundedCornerShape(2.dp)) - .background(Color(0x44FFFFFF)), - ) { - val pct = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f - Box( - Modifier - .fillMaxWidth(pct) - .height(3.dp) - .background(McColors.Brand), - ) + val progress = if (durationMs > 0) { + (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) + } else { + 0f } + Slider( + value = progress, + onValueChange = { v -> + if (durationMs > 0) { + val seek = (v * durationMs).toLong() + player.seekTo(seek) + positionMs = seek + showControls() + } + }, + modifier = Modifier.fillMaxWidth().height(28.dp), + colors = SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = McColors.Brand, + inactiveTrackColor = Color(0x44FFFFFF), + ), + ) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt index 4e23fd8..ec309af 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt @@ -2,32 +2,41 @@ package nl.vonas.mediacluster.tv.ui import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -39,7 +48,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.MediaItem +import nl.vonas.mediacluster.tv.data.SessionStore +import nl.vonas.mediacluster.tv.ui.components.EmptyState import nl.vonas.mediacluster.tv.ui.components.PosterCard +import nl.vonas.mediacluster.tv.ui.components.SectionLabel +import nl.vonas.mediacluster.tv.ui.components.SkeletonBox import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McDimens @@ -51,6 +64,9 @@ fun SearchScreen( onOpenSeries: (String) -> Unit, onAtTopBack: (() -> Unit)? = null, ) { + val context = LocalContext.current + val store = remember { SessionStore(context) } + val recent by store.recentSearches.collectAsState(initial = emptyList()) var query by remember { mutableStateOf("") } var results by remember { mutableStateOf>(emptyList()) } var busy by remember { mutableStateOf(false) } @@ -73,21 +89,25 @@ fun SearchScreen( } } - fun scheduleSearch(next: String) { + fun runSearch(next: String, saveRecent: Boolean = true) { query = next searchJob?.cancel() searchJob = scope.launch { - delay(250) + delay(220) if (next.isBlank()) { results = emptyList() + busy = false return@launch } busy = true error = null try { results = api.search(next) + if (saveRecent && next.trim().length >= 2) { + store.addRecentSearch(next) + } } catch (e: Exception) { - error = e.message + error = e.message ?: "Zoeken mislukt" } finally { busy = false } @@ -111,15 +131,20 @@ fun SearchScreen( Spacer(Modifier.height(10.dp)) OutlinedTextField( value = query, - onValueChange = { scheduleSearch(it) }, + onValueChange = { runSearch(it) }, modifier = Modifier.fillMaxWidth(), singleLine = true, placeholder = { - androidx.compose.material3.Text("Typ een titel…", color = McColors.TextDim) + androidx.compose.material3.Text("Zoek films of series…", color = McColors.TextDim) }, shape = RoundedCornerShape(10.dp), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }), + keyboardActions = KeyboardActions( + onSearch = { + focusManager.clearFocus() + if (query.isNotBlank()) runSearch(query, saveRecent = true) + }, + ), colors = OutlinedTextFieldDefaults.colors( focusedTextColor = Color.White, unfocusedTextColor = Color.White, @@ -131,31 +156,102 @@ fun SearchScreen( ), ) Spacer(Modifier.height(12.dp)) + when { - error != null -> Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) - busy -> Text("Zoeken…", color = McColors.TextDim, fontSize = McDimens.bodySmall) - query.isNotBlank() && results.isEmpty() -> - Text("Geen resultaten", color = McColors.TextDim, fontSize = McDimens.bodySmall) - } - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = minCell), - state = gridState, - contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp), - horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap), - verticalArrangement = Arrangement.spacedBy(McDimens.gridGap), - modifier = Modifier.fillMaxSize(), - ) { - items(results, key = { "${it.type}-${it.id}" }) { item -> - PosterCard( - item = item, - fillCell = true, - onClick = { - when (item.type) { - "movie" -> onOpenMovie(item.id) - else -> onOpenSeries(item.id) + query.isBlank() -> { + if (recent.isNotEmpty()) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + SectionLabel("Recent") + Text( + "Wissen", + color = Color(0xFF888888), + fontSize = McDimens.caption, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable { scope.launch { store.clearRecentSearches() } } + .padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + Spacer(Modifier.height(8.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(recent) { q -> + Text( + q, + color = Color.White, + fontSize = McDimens.bodySmall, + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(McColors.BgElevated) + .clickable { runSearch(q) } + .padding(horizontal = 14.dp, vertical = 8.dp), + ) } + } + } else { + EmptyState( + title = "Zoek in je bibliotheek", + subtitle = "Typ een titel om films en series te vinden.", + ) + } + } + error != null -> Text(error!!, color = McColors.Danger, fontSize = McDimens.bodySmall) + busy && results.isEmpty() -> { + Column(verticalArrangement = Arrangement.spacedBy(McDimens.gridGap)) { + repeat(3) { + Row(horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap)) { + repeat(4) { + SkeletonBox( + Modifier + .weight(1f) + .aspectRatio(2f / 3f), + ) + } + } + } + } + } + !busy && results.isEmpty() -> EmptyState( + title = "Geen resultaten", + subtitle = "Probeer een andere zoekterm.", + ) + else -> { + val movies = results.count { it.type == "movie" } + val series = results.size - movies + Text( + buildString { + if (movies > 0) append("$movies film${if (movies == 1) "" else "s"}") + if (movies > 0 && series > 0) append(" · ") + if (series > 0) append("$series serie${if (series == 1) "" else "s"}") }, + color = McColors.TextDim, + fontSize = McDimens.meta, ) + Spacer(Modifier.height(8.dp)) + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = minCell), + state = gridState, + contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(McDimens.gridGap), + verticalArrangement = Arrangement.spacedBy(McDimens.gridGap), + modifier = Modifier.fillMaxSize(), + ) { + items(results, key = { "${it.type}-${it.id}" }) { item -> + PosterCard( + item = item, + fillCell = true, + onClick = { + when (item.type) { + "movie" -> onOpenMovie(item.id) + else -> onOpenSeries(item.id) + } + }, + ) + } + } } } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SystemUi.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SystemUi.kt new file mode 100644 index 0000000..ac0434f --- /dev/null +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/SystemUi.kt @@ -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 + } + } +} diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt index f916ac9..3dee137 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt @@ -53,7 +53,7 @@ import nl.vonas.mediacluster.tv.data.EpgProgram import nl.vonas.mediacluster.tv.data.IptvCategory import nl.vonas.mediacluster.tv.data.IptvChannel import nl.vonas.mediacluster.tv.data.IptvEpg -import nl.vonas.mediacluster.tv.ui.components.McButton +import nl.vonas.mediacluster.tv.ui.components.EmptyState import nl.vonas.mediacluster.tv.ui.theme.McColors import nl.vonas.mediacluster.tv.ui.theme.McDimens import java.time.Instant @@ -134,12 +134,19 @@ fun TvScreen( epgNowTitles.clear() try { val loaded = api.iptvChannels(catId, includeEpg = true) - val titles = api.fetchMissingNowTitles(loaded) channels = loaded - titles.forEach { (id, title) -> epgNowTitles[id] = title } + loaded.forEach { ch -> + ch.nowTitle?.takeIf { it.isNotBlank() }?.let { epgNowTitles[ch.streamId] = it } + } val focusId = lastStreamId?.takeIf { id -> loaded.any { it.streamId == id } } ?: loaded.firstOrNull()?.streamId focusId?.let { loadEpg(it) } + // EPG voor hele categorie op de achtergrond (niet blokkeren op eerste paint) + scope.launch { + api.fetchMissingNowTitles(loaded) { batch -> + batch.forEach { (id, title) -> epgNowTitles[id] = title } + } + } } catch (e: Exception) { error = e.message channels = emptyList() @@ -225,7 +232,7 @@ fun TvScreen( Spacer(Modifier.height(10.dp)) Text( - "Tik om te selecteren · nogmaals om te kijken", + "Tik nogmaals om te kijken", color = Color(0xFF777777), fontSize = McDimens.caption, ) @@ -239,7 +246,10 @@ fun TvScreen( } channels.isEmpty() -> { Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { - Text("Geen zenders", color = McColors.TextDim, fontSize = McDimens.bodySmall) + EmptyState( + title = "Geen zenders", + subtitle = "Deze categorie heeft geen kanalen.", + ) } } else -> { @@ -252,7 +262,8 @@ fun TvScreen( items(channels, key = { it.streamId }) { ch -> ChannelRow( channel = ch, - nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle, + nowTitle = (epgNowTitles[ch.streamId] ?: ch.nowTitle) + ?.takeUnless { it.equals("null", ignoreCase = true) }, isSelected = selectedStreamId == ch.streamId, isLastWatched = ch.streamId == lastStreamId, onClick = { @@ -440,7 +451,7 @@ private fun MobileEpgCard( ) { if (epg == null) { Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) { - Text("Selecteer een zender", color = McColors.TextDim, fontSize = McDimens.bodySmall) + Text("Kies een zender", color = McColors.TextDim, fontSize = McDimens.bodySmall) } return } @@ -469,7 +480,20 @@ private fun MobileEpgCard( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - McButton("▶ Kijk", primary = true, compact = true, onClick = onPlay) + Box( + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color.White) + .clickable(onClick = onPlay) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text( + "Afspelen", + color = Color.Black, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + ) + } } Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider)) @@ -500,7 +524,7 @@ private fun MobileEpgCard( } if (epg.now == null && epg.next == null) { - Text("Geen programma-info", color = McColors.TextDim, fontSize = McDimens.meta) + Text("Geen programmagids", color = McColors.TextDim, fontSize = McDimens.meta) } } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt index ff6d941..d68b434 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Components.kt @@ -1,9 +1,13 @@ package nl.vonas.mediacluster.tv.ui.components +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -13,11 +17,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -41,15 +48,26 @@ fun McButton( focusRequester: FocusRequester? = null, onClick: () -> Unit, ) { + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed) 0.96f else 1f, + animationSpec = tween(100), + label = "btnScale", + ) val bg = if (primary) Color.White else Color(0x33FFFFFF) val fg = if (primary) Color.Black else Color.White val hPad = if (compact) 12.dp else 16.dp val vPad = if (compact) 8.dp else 10.dp Box( modifier = modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + } .clip(RoundedCornerShape(4.dp)) .background(bg) - .clickable(onClick = onClick) + .clickable(interactionSource = interaction, indication = null, onClick = onClick) .padding(horizontal = hPad, vertical = vPad), contentAlignment = Alignment.Center, ) { @@ -98,12 +116,26 @@ fun PosterCard( .aspectRatio(2f / 3f) } + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed) 0.96f else 1f, + animationSpec = tween(120), + label = "posterScale", + ) + Box( modifier = modifier .then(sizeModifier) + .graphicsLayer { + scaleX = scale + scaleY = scale + } .clip(RoundedCornerShape(4.dp)) .background(Color(0xFF1A1A1A)) .combinedClickable( + interactionSource = interaction, + indication = null, onClick = onClick, onLongClick = onLongClick, ), @@ -115,6 +147,13 @@ fun PosterCard( contentScale = ContentScale.Crop, ) if (progress > 0.02f) { + Box( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(3.dp) + .background(Color(0x66FFFFFF)), + ) Box( Modifier .align(Alignment.BottomStart) diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Polish.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Polish.kt new file mode 100644 index 0000000..6fbc82a --- /dev/null +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/components/Polish.kt @@ -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, + ) +} diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfileAvatars.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfileAvatars.kt new file mode 100644 index 0000000..96d5e35 --- /dev/null +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfileAvatars.kt @@ -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 ?: "🙂" diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt new file mode 100644 index 0000000..1c720f5 --- /dev/null +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt @@ -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>(emptyList()) } + var canManage by remember { mutableStateOf(false) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var mode by remember { mutableStateOf(ProfilesMode.Pick) } + var pending by remember { mutableStateOf(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, + ) + } +} diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/McDimens.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/McDimens.kt index a5b5a89..dae45e2 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/McDimens.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/McDimens.kt @@ -16,18 +16,19 @@ object McDimens { val screenPad: Dp = 16.dp val screenPadTight: Dp = 12.dp val shelfGap: Dp = 8.dp + val shelfBlockGap: Dp = 14.dp val gridGap: Dp = 8.dp - val titleHero: TextUnit = 26.sp - val titleScreen: TextUnit = 20.sp + val titleHero: TextUnit = 28.sp + val titleScreen: TextUnit = 22.sp val titleShelf: TextUnit = 15.sp val body: TextUnit = 14.sp val bodySmall: TextUnit = 13.sp val meta: TextUnit = 12.sp val caption: TextUnit = 11.sp - val detailPosterW: Dp = 110.dp - val detailPosterH: Dp = 165.dp + val detailPosterW: Dp = 118.dp + val detailPosterH: Dp = 177.dp val episodeStillW: Dp = 112.dp val episodeStillH: Dp = 63.dp @@ -71,7 +72,7 @@ object McDimens { fun billboardHeight(): Dp { val h = screenHeight() return remember(h) { - (h * 0.36f).coerceIn(200.dp, 280.dp) + (h * 0.42f).coerceIn(220.dp, 320.dp) } } } diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Theme.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Theme.kt index 0fc94a0..259e765 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Theme.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Theme.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.darkColorScheme +import nl.vonas.mediacluster.tv.ui.ApplyDarkSystemBars private val Scheme = darkColorScheme( primary = McColors.Accent, @@ -16,5 +17,8 @@ private val Scheme = darkColorScheme( @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun MediaClusterTheme(content: @Composable () -> Unit) { - MaterialTheme(colorScheme = Scheme, content = content) + MaterialTheme(colorScheme = Scheme) { + ApplyDarkSystemBars() + content() + } } diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 905e4f2..a79ff4f 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 36 - versionCode = 68 - versionName = "0.14.5" + versionCode = 71 + versionName = "0.14.8" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 8c2f81a..627e6aa 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -196,6 +196,60 @@ class ApiClient( Unit } + suspend fun listProfiles(): ProfilesPayload = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/profiles").get().build() + parseObject(execute(req)) { o -> + ProfilesPayload( + profiles = o.optJSONArray("profiles").toProfiles(), + activeProfileId = o.optString("activeProfileId").takeIf { it.isNotBlank() && it != "null" }, + canManage = o.optBoolean("canManage", false), + ) + } + } + + suspend fun selectProfile(profileId: String, pin: String? = null): ViewerProfile = + withContext(Dispatchers.IO) { + val body = JSONObject() + if (!pin.isNullOrBlank()) body.put("pin", pin) + val req = authed("$baseUrl/api/v1/client/profiles/$profileId/select") + .post(body.toString().toRequestBody(jsonMedia)) + .build() + parseObject(execute(req)) { o -> + o.getJSONObject("profile").toProfile() + } + } + + suspend fun clearProfile() = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/profiles/clear") + .post("{}".toRequestBody(jsonMedia)) + .build() + execute(req) + Unit + } + + suspend fun createProfile( + name: String, + avatarId: String, + isKids: Boolean, + pin: String?, + ): ViewerProfile = withContext(Dispatchers.IO) { + val body = JSONObject() + .put("name", name) + .put("avatarId", avatarId) + .put("isKids", isKids) + if (!pin.isNullOrBlank()) body.put("pin", pin) + val req = authed("$baseUrl/api/v1/client/profiles") + .post(body.toString().toRequestBody(jsonMedia)) + .build() + parseObject(execute(req)) { o -> o.getJSONObject("profile").toProfile() } + } + + suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) { + val req = authed("$baseUrl/api/v1/client/profiles/$profileId").delete().build() + execute(req) + Unit + } + suspend fun searchSubtitles(mediaFileId: String, languages: String = "nl,en"): SubtitleSearchResult = withContext(Dispatchers.IO) { val id = java.net.URLEncoder.encode(mediaFileId, "UTF-8") @@ -263,6 +317,7 @@ class ApiClient( parseObject(execute(req)) { o -> ClientMe( iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true, + profile = o.optJSONObject("profile")?.toProfile(), ) } } @@ -306,6 +361,7 @@ class ApiClient( number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") }, categoryId = c.optString("categoryId").ifBlank { null }, nowTitle = c.optString("nowTitle").ifBlank { null }, + nextTitle = c.optString("nextTitle").ifBlank { null }, ) ) } @@ -449,6 +505,7 @@ data class MediaItem( val year: Int? = null, val posterUrl: String? = null, val backdropUrl: String? = null, + val logoUrl: String? = null, val mediaFileId: String? = null, val seriesId: String? = null, val positionMs: Long = 0, @@ -500,6 +557,22 @@ data class ShelfPref( data class ClientMe( val iptvEnabled: Boolean, + val profile: ViewerProfile? = null, +) + +data class ViewerProfile( + val id: String, + val name: String, + val avatarId: String, + val isOwner: Boolean, + val isKids: Boolean, + val hasPin: Boolean, +) + +data class ProfilesPayload( + val profiles: List, + val activeProfileId: String?, + val canManage: Boolean, ) data class IptvCategory( @@ -515,6 +588,7 @@ data class IptvChannel( val number: Int? = null, val categoryId: String? = null, val nowTitle: String? = null, + val nextTitle: String? = null, ) data class IptvPlayInfo( @@ -555,6 +629,7 @@ private fun JSONArray?.toItems(): List { year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") }, posterUrl = o.optString("posterUrl").ifBlank { null }, backdropUrl = o.optString("backdropUrl").ifBlank { null }, + logoUrl = o.optString("logoUrl").ifBlank { null }, mediaFileId = if (o.isNull("mediaFileId")) null else o.optString("mediaFileId").ifBlank { null }, seriesId = if (o.isNull("seriesId")) null else o.optString("seriesId").ifBlank { null }, positionMs = o.optLong("positionMs"), @@ -585,3 +660,21 @@ private fun JSONArray?.toShelves(): List { } } } + +private fun JSONArray?.toProfiles(): List { + 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"), +) diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/HomeCache.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/HomeCache.kt new file mode 100644 index 0000000..e2626e9 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/HomeCache.kt @@ -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.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 { + 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() diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt index ce64380..4ef78a2 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt @@ -2,30 +2,159 @@ package nl.vonas.mediacluster.tv.data import android.content.Context import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import nl.vonas.mediacluster.tv.BuildConfig +import org.json.JSONArray private val Context.dataStore by preferencesDataStore("mc_tv") class SessionStore(private val context: Context) { private val tokenKey = stringPreferencesKey("refresh_token") private val apiKey = stringPreferencesKey("api_base") + private val profileIdKey = stringPreferencesKey("profile_id") + private val profileNameKey = stringPreferencesKey("profile_name") + private val profileAvatarKey = stringPreferencesKey("profile_avatar") + private val recentSearchKey = stringPreferencesKey("recent_searches") + private val homeCacheKey = stringPreferencesKey("home_cache_json") + private val tvFavoritesKey = stringPreferencesKey("tv_favorites") + private val tvLastCategoryKey = stringPreferencesKey("tv_last_category") + private val tvLastStreamKey = intPreferencesKey("tv_last_stream") val refreshToken: Flow = context.dataStore.data.map { it[tokenKey] } val apiBase: Flow = context.dataStore.data.map { it[apiKey] ?: BuildConfig.DEFAULT_API_BASE } + val profileId: Flow = context.dataStore.data.map { it[profileIdKey] } + val profileName: Flow = context.dataStore.data.map { it[profileNameKey] } + val profileAvatarId: Flow = context.dataStore.data.map { it[profileAvatarKey] } + + val recentSearches: Flow> = 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 = context.dataStore.data.map { it[homeCacheKey] } + + val favoriteStreamIds: Flow> = context.dataStore.data.map { prefs -> + parseIntSet(prefs[tvFavoritesKey]) + } + + val tvLastCategoryId: Flow = context.dataStore.data.map { prefs -> + prefs[tvLastCategoryKey]?.takeIf { it.isNotBlank() } + } + + val tvLastStreamId: Flow = context.dataStore.data.map { prefs -> + prefs[tvLastStreamKey] + } suspend fun setToken(token: String?) { context.dataStore.edit { prefs -> - if (token.isNullOrBlank()) prefs.remove(tokenKey) else prefs[tokenKey] = token + if (token.isNullOrBlank()) { + prefs.remove(tokenKey) + prefs.remove(profileIdKey) + prefs.remove(profileNameKey) + prefs.remove(profileAvatarKey) + } else { + prefs[tokenKey] = token + } + } + } + + suspend fun setProfile(id: String?, name: String? = null, avatarId: String? = null) { + context.dataStore.edit { prefs -> + if (id.isNullOrBlank()) { + prefs.remove(profileIdKey) + prefs.remove(profileNameKey) + prefs.remove(profileAvatarKey) + } else { + prefs[profileIdKey] = id + if (!name.isNullOrBlank()) prefs[profileNameKey] = name else prefs.remove(profileNameKey) + if (!avatarId.isNullOrBlank()) prefs[profileAvatarKey] = avatarId + else prefs.remove(profileAvatarKey) + } } } suspend fun setApiBase(url: String) { context.dataStore.edit { it[apiKey] = url.trim().trimEnd('/') } } + + suspend fun saveHomeCache(json: String) { + context.dataStore.edit { it[homeCacheKey] = json } + } + + suspend fun addRecentSearch(query: String) { + val q = query.trim() + if (q.length < 2) return + context.dataStore.edit { prefs -> + val existing = prefs[recentSearchKey].orEmpty().let { raw -> + if (raw.isBlank()) mutableListOf() + else runCatching { + val arr = JSONArray(raw) + MutableList(arr.length()) { i -> arr.optString(i) } + .filter { it.isNotBlank() } + .toMutableList() + }.getOrElse { mutableListOf() } + } + existing.removeAll { it.equals(q, ignoreCase = true) } + existing.add(0, q) + while (existing.size > 8) existing.removeAt(existing.lastIndex) + prefs[recentSearchKey] = JSONArray(existing).toString() + } + } + + suspend fun clearRecentSearches() { + context.dataStore.edit { it.remove(recentSearchKey) } + } + + suspend fun toggleFavorite(streamId: Int): Boolean { + var nowFavorite = false + context.dataStore.edit { prefs -> + val set = parseIntSet(prefs[tvFavoritesKey]).toMutableSet() + if (set.contains(streamId)) { + set.remove(streamId) + nowFavorite = false + } else { + set.add(streamId) + nowFavorite = true + } + prefs[tvFavoritesKey] = JSONArray(set.sorted()).toString() + } + return nowFavorite + } + + suspend fun setLastTv(categoryId: String?, streamId: Int?) { + context.dataStore.edit { prefs -> + if (categoryId.isNullOrBlank()) prefs.remove(tvLastCategoryKey) + else prefs[tvLastCategoryKey] = categoryId + if (streamId == null) prefs.remove(tvLastStreamKey) + else prefs[tvLastStreamKey] = streamId + } + } + + private fun parseIntSet(raw: String?): Set { + 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()) + } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt index 512c23e..4c9a464 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/BrowseScreen.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.MediaItem +import nl.vonas.mediacluster.tv.ui.components.EmptyState import nl.vonas.mediacluster.tv.ui.components.McButton import nl.vonas.mediacluster.tv.ui.components.PosterCard import nl.vonas.mediacluster.tv.ui.theme.McColors @@ -138,7 +139,11 @@ fun BrowseScreen( Column { Text(title, color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold) Text( - if (total > 0) "$total titels" else if (loading) "Laden…" else "Geen resultaten", + when { + total > 0 -> "$total titels" + loading -> "Laden…" + else -> "Geen titels" + }, color = McColors.TextDim, fontSize = 14.sp, ) @@ -151,43 +156,57 @@ fun BrowseScreen( if (error != null) { Text(error!!, color = McColors.Danger) } - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 140.dp), - state = gridState, - contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxSize(), - ) { - items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item -> - PosterCard( - item = item, - width = 140.dp, - height = 210.dp, - modifier = Modifier.padding(6.dp), - onClick = { - val canResume = onPlayContinue != null && - !item.mediaFileId.isNullOrBlank() && - (item.positionMs > 0 || catalogId == "continue-watching") - if (canResume) { - onPlayContinue!!( - item.mediaFileId!!, - item.id.takeIf { item.type == "movie" }, - item.id.takeIf { item.type == "episode" }, - item.seriesId, - if (item.type == "episode") "EPISODE" else "MOVIE", - item.positionMs, - ) - } else if (item.type == "movie") { - onOpenMovie(item.id) - } else if (item.type == "episode" && !item.seriesId.isNullOrBlank()) { - onOpenSeries(item.seriesId!!) - } else { - onOpenSeries(item.id) - } - }, + when { + loading && items.isEmpty() -> { + EmptyState(title = "Laden…", subtitle = "Even geduld.") + } + !loading && items.isEmpty() && error == null -> { + EmptyState( + title = "Geen titels gevonden", + subtitle = "Deze collectie is leeg of nog niet gesynchroniseerd.", + actionLabel = "Opnieuw proberen", + onAction = { scope.launch { load(reset = true) } }, ) } + else -> { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 140.dp), + state = gridState, + contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize(), + ) { + items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item -> + PosterCard( + item = item, + fillWidth = true, + modifier = Modifier.padding(6.dp), + onClick = { + val canResume = onPlayContinue != null && + !item.mediaFileId.isNullOrBlank() && + (item.positionMs > 0 || catalogId == "continue-watching") + if (canResume) { + onPlayContinue!!( + item.mediaFileId!!, + item.id.takeIf { item.type == "movie" }, + item.id.takeIf { item.type == "episode" }, + item.seriesId, + if (item.type == "episode") "EPISODE" else "MOVIE", + item.positionMs, + ) + } else if (item.type == "movie") { + onOpenMovie(item.id) + } else if (item.type == "episode" && !item.seriesId.isNullOrBlank()) { + onOpenSeries(item.seriesId!!) + } else { + onOpenSeries(item.id) + } + }, + ) + } + } + } } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt index ca315bd..54b4c43 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/DetailScreens.kt @@ -382,36 +382,9 @@ fun MovieDetailScreen( Spacer(Modifier.height(4.dp)) DetailLogoOrTitle(logoUrl, title) MetaChips(metaParts) - SelectedQualityLine(selected) - if (director.isNotEmpty()) { - Text( - "Regie: ${director.take(2).joinToString(", ")}", - color = Color(0xFF9A9A9A), - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (versions.size > 1) { + SelectedQualityLine(selected) } - if (overview.isNotBlank()) { - Text( - overview, - color = McColors.TextDim, - fontSize = 15.sp, - maxLines = 4, - overflow = TextOverflow.Ellipsis, - lineHeight = 21.sp, - ) - } - if (cast.isNotEmpty()) { - Text( - "Met: ${cast.take(4).joinToString(", ")}", - color = Color(0xFF8A8A8A), - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (error != null) Text(error!!, color = McColors.Danger) Spacer(Modifier.height(4.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { selected?.let { ver -> @@ -424,13 +397,42 @@ fun MovieDetailScreen( openYoutubeTrailer(context, trailerKey!!) } } - DetailActionButton("Als gekeken") { + DetailActionButton("Markeer als gezien") { scope.launch { runCatching { api.markWatched(movieId = movieId) } onBack() } } } + if (overview.isNotBlank()) { + Text( + overview, + color = McColors.TextDim, + fontSize = 15.sp, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + lineHeight = 21.sp, + ) + } + if (director.isNotEmpty()) { + Text( + "Regie · ${director.take(2).joinToString(", ")}", + color = Color(0xFF9A9A9A), + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (cast.isNotEmpty()) { + Text( + "Met · ${cast.take(4).joinToString(", ")}", + color = Color(0xFF8A8A8A), + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (error != null) Text(error!!, color = McColors.Danger) if (versions.size > 1) { Spacer(Modifier.height(2.dp)) Text( diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt index abe2ed8..3fa1984 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt @@ -120,6 +120,8 @@ fun LiveTvPlayerScreen( var menuCategoryName by remember { mutableStateOf("Zenders") } var menuRefreshing by remember { mutableStateOf(false) } var lastCategoryShiftAt by remember { mutableLongStateOf(0L) } + var lastZapAt by remember { mutableLongStateOf(0L) } + var zapChannels by remember { mutableStateOf>(emptyList()) } var menuLoadGen by remember { mutableIntStateOf(0) } val menuCache = remember { mutableStateMapOf() } val menuEpgNow = remember { mutableStateMapOf() } @@ -203,6 +205,23 @@ fun LiveTvPlayerScreen( fun cacheKey(catId: String?) = catId?.takeIf { it.isNotBlank() } ?: "__all__" + // Vooraf laden voor snel zappen zonder menu + LaunchedEffect(categoryId) { + runCatching { + val loaded = if (!categoryId.isNullOrBlank()) { + api.iptvChannels(categoryId, includeEpg = false) + } else { + api.iptvChannels(includeEpg = false) + } + zapChannels = loaded + menuChannels = loaded + val key = cacheKey(categoryId) + if (!menuCache.containsKey(key)) { + menuCache[key] = MenuCategoryCache(loaded, emptyMap(), System.currentTimeMillis()) + } + } + } + suspend fun loadMenuEpgInBackground(catId: String?, gen: Int) { val key = cacheKey(catId) try { @@ -269,6 +288,7 @@ fun LiveTvPlayerScreen( } if (gen != menuLoadGen) return@LaunchedEffect menuChannels = loaded + zapChannels = loaded val staleTitles = cached?.epgTitles.orEmpty() if (staleTitles.isNotEmpty()) applyMenuEpg(staleTitles) menuCache[key] = MenuCategoryCache(loaded, staleTitles, System.currentTimeMillis()) @@ -352,6 +372,20 @@ fun LiveTvPlayerScreen( infoOverlay = true } + fun zapChannel(delta: Int) { + val now = System.currentTimeMillis() + if (now - lastZapAt < 280) return + lastZapAt = now + val list = when { + zapChannels.isNotEmpty() -> zapChannels + menuChannels.isNotEmpty() -> menuChannels + else -> return + } + val idx = list.indexOfFirst { it.streamId == currentStreamId }.let { if (it < 0) 0 else it } + val next = list[(idx + delta).mod(list.size)] + selectChannel(next.streamId) + } + Box( Modifier .fillMaxSize() @@ -367,6 +401,18 @@ fun LiveTvPlayerScreen( true } Key.DirectionUp -> { + zapChannel(-1) + true + } + Key.DirectionDown -> { + zapChannel(1) + true + } + Key.DirectionRight -> { + zapChannel(1) + true + } + Key.DirectionLeft, Key.Menu, Key.Guide -> { channelMenuOpen = true true } @@ -479,6 +525,12 @@ fun LiveTvPlayerScreen( overflow = TextOverflow.Ellipsis, ) } + Spacer(Modifier.height(8.dp)) + Text( + "↑↓ zappen · ← zenderlijst · OK info", + color = Color(0xFF888888), + fontSize = 12.sp, + ) } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index 107ddd3..d064942 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -3,6 +3,11 @@ package nl.vonas.mediacluster.tv.ui import android.app.Activity import android.os.Build import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -14,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -23,27 +29,36 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavType @@ -51,24 +66,37 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Surface import androidx.tv.material3.Switch import androidx.tv.material3.Text import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import nl.vonas.mediacluster.tv.BuildConfig import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.HomePayload import nl.vonas.mediacluster.tv.data.MediaItem import nl.vonas.mediacluster.tv.data.PollResult import nl.vonas.mediacluster.tv.data.SessionStore import nl.vonas.mediacluster.tv.data.ShelfPref +import nl.vonas.mediacluster.tv.data.parseHomeCacheJson +import nl.vonas.mediacluster.tv.data.toCacheJson +import nl.vonas.mediacluster.tv.ui.components.EmptyState +import nl.vonas.mediacluster.tv.ui.components.HomeChrome +import nl.vonas.mediacluster.tv.ui.components.HomeSkeleton import nl.vonas.mediacluster.tv.ui.components.MainTab import nl.vonas.mediacluster.tv.ui.components.McButton import nl.vonas.mediacluster.tv.ui.components.PosterCard +import nl.vonas.mediacluster.tv.ui.components.SectionLabel import nl.vonas.mediacluster.tv.ui.components.SubNavItem import nl.vonas.mediacluster.tv.ui.components.TopNavBar +import nl.vonas.mediacluster.tv.ui.profiles.ProfilesScreen +import nl.vonas.mediacluster.tv.ui.profiles.avatarEmoji import nl.vonas.mediacluster.tv.ui.theme.McColors @Composable @@ -80,11 +108,15 @@ fun McNav() { val scope = rememberCoroutineScope() var ready by remember { mutableStateOf(false) } var authed by remember { mutableStateOf(false) } + var hasProfile by remember { mutableStateOf(false) } + val profileAvatarId by store.profileAvatarId.collectAsState(initial = null) + val profileName by store.profileName.collectAsState(initial = null) // Blijft behouden als main uit compositie gaat (bijv. live player) var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) } var tvCategoryId by rememberSaveable { mutableStateOf(null) } var tvLastStreamId by rememberSaveable { mutableStateOf(null) } var tvFocusNonce by remember { mutableIntStateOf(0) } + val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet()) val hubTab = remember(hubTabName) { MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home } @@ -94,19 +126,77 @@ fun McNav() { val token = store.refreshToken.first() api.configure(base, token) authed = !token.isNullOrBlank() + if (authed) { + val localProfile = store.profileId.first() + val me = runCatching { api.clientMe() }.getOrNull() + when { + me?.profile != null -> { + store.setProfile(me.profile.id, me.profile.name, me.profile.avatarId) + hasProfile = true + } + !localProfile.isNullOrBlank() -> { + // Lokaal bekend, server nog niet — switcher afdwingen + store.setProfile(null) + hasProfile = false + } + else -> hasProfile = false + } + } else { + hasProfile = false + } + if (tvCategoryId == null) { + store.tvLastCategoryId.first()?.let { tvCategoryId = it } + } + if (tvLastStreamId == null) { + store.tvLastStreamId.first()?.let { tvLastStreamId = it } + } ready = true } + LaunchedEffect(tvCategoryId, tvLastStreamId) { + if (!ready) return@LaunchedEffect + store.setLastTv(tvCategoryId, tvLastStreamId) + } + if (!ready) { Splash() return } - NavHost(navController = nav, startDestination = if (authed) "main" else "link") { + NavHost( + navController = nav, + startDestination = when { + !authed -> "link" + !hasProfile -> "profiles" + else -> "main" + }, + enterTransition = { McTransitions.enter }, + exitTransition = { McTransitions.exit }, + popEnterTransition = { McTransitions.popEnter }, + popExitTransition = { McTransitions.popExit }, + ) { composable("link") { LinkScreen(api, store) { authed = true - nav.navigate("main") { popUpTo("link") { inclusive = true } } + hasProfile = false + nav.navigate("profiles") { popUpTo("link") { inclusive = true } } + } + } + composable("profiles") { + ProfilesScreen(api = api, store = store, switcherMode = true) { + hasProfile = true + scope.launch { store.saveHomeCache("") } + nav.navigate("main") { popUpTo("profiles") { inclusive = true } } + } + } + composable("profiles_manage") { + ProfilesScreen(api = api, store = store, switcherMode = false) { + hasProfile = true + scope.launch { store.saveHomeCache("") } + nav.navigate("main") { + popUpTo("main") { inclusive = false } + launchSingleTop = true + } } } composable("main") { @@ -118,8 +208,12 @@ fun McNav() { onTvCategoryChange = { tvCategoryId = it }, tvLastStreamId = tvLastStreamId, onTvLastStreamChange = { tvLastStreamId = it }, + favoriteIds = favoriteIds, + onToggleFavorite = { id -> scope.launch { store.toggleFavorite(id) } }, tvFocusNonce = tvFocusNonce, onTvFocusContent = { tvFocusNonce++ }, + profileAvatarId = profileAvatarId, + profileName = profileName, onOpenMovie = { nav.navigate("movie/$it") }, onOpenSeries = { nav.navigate("series/$it") }, onBrowse = { catalogId, _title -> @@ -136,11 +230,26 @@ fun McNav() { if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name nav.navigate("live/$streamId") }, + onSwitchProfile = { + scope.launch { + runCatching { api.clearProfile() } + store.setProfile(null) + store.saveHomeCache("") + hasProfile = false + nav.navigate("profiles") { + popUpTo("main") { inclusive = true } + } + } + }, + onManageProfiles = { + nav.navigate("profiles_manage") + }, onLogout = { scope.launch { store.setToken(null) api.configure(store.apiBase.first(), null) authed = false + hasProfile = false nav.navigate("link") { popUpTo(0) { inclusive = true } } } }, @@ -243,19 +352,27 @@ private fun MainHub( onTvCategoryChange: (String?) -> Unit, tvLastStreamId: Int?, onTvLastStreamChange: (Int?) -> Unit, + favoriteIds: Set, + onToggleFavorite: (Int) -> Unit, tvFocusNonce: Int, onTvFocusContent: () -> Unit, + profileAvatarId: String?, + profileName: String?, onOpenMovie: (String) -> Unit, onOpenSeries: (String) -> Unit, onBrowse: (catalogId: String, title: String) -> Unit, onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit, onPlayLive: (Int) -> Unit, + onSwitchProfile: () -> Unit, + onManageProfiles: () -> Unit, onLogout: () -> Unit, ) { var iptvEnabled by remember { mutableStateOf(false) } var navFocused by remember { mutableStateOf(false) } var exitConfirm by remember { mutableStateOf(false) } + var homeChromeCompact by remember { mutableStateOf(false) } val navFocus = remember { FocusRequester() } + val homeContentFocus = remember { FocusRequester() } val homeListState = rememberLazyListState() val hubScope = rememberCoroutineScope() val activity = LocalContext.current as? Activity @@ -366,65 +483,109 @@ private fun MainHub( } } + // Reset scroll wanneer je terugkomt op Home (tab-wissel) + LaunchedEffect(tab) { + if (tab == MainTab.Home) { + homeListState.scrollToItem(0) + homeChromeCompact = false + } + } + + val homeOverlay = tab == MainTab.Home + + @Composable + fun HubTopNav(overlay: Boolean) { + TopNavBar( + selected = tab, + visibleTabs = visibleTabs, + compact = tab == MainTab.Home && homeChromeCompact, + overlay = overlay, + profileAvatarEmoji = avatarEmoji(profileAvatarId ?: "fox"), + profileLabel = profileName, + onProfileClick = onSwitchProfile, + onSelect = { + if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar + onTabChange(it) + exitConfirm = false + homeChromeCompact = false + when (it) { + MainTab.Tv -> { + navFocused = false + onTvFocusContent() + } + MainTab.Home -> { + navFocused = false + hubScope.launch { + delay(80) + homeListState.scrollToItem(0) + runCatching { homeContentFocus.requestFocus() } + } + } + else -> focusTopNav() + } + when (it) { + MainTab.Movies -> { + if (movieSubId == "__search__") { + movieSubId = "movies" + movieCatalog = "movies" + } + } + MainTab.Series -> { + if (seriesSubId == "__search__") { + seriesSubId = "series" + seriesCatalog = "series" + } + } + else -> Unit + } + }, + contentDownFocus = if (tab == MainTab.Home) homeContentFocus else null, + subItems = subItems, + selectedSubId = selectedSubId, + onSelectSub = { item -> + if (item.id == "__search__") { + onTabChange(MainTab.Search) + focusTopNav() + } else { + when (tab) { + MainTab.Movies -> { + movieSubId = item.id + movieCatalog = item.catalogId + } + MainTab.Series -> { + seriesSubId = item.id + seriesCatalog = item.catalogId + } + else -> Unit + } + } + }, + ) + } + Box(Modifier.fillMaxSize().background(McColors.Bg)) { Column(Modifier.fillMaxSize()) { - Box(Modifier.focusRequester(navFocus)) { - TopNavBar( - selected = tab, - visibleTabs = visibleTabs, - onSelect = { - if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar - onTabChange(it) - exitConfirm = false - if (it == MainTab.Tv) { - navFocused = false - onTvFocusContent() - } else { - focusTopNav() - } - when (it) { - MainTab.Movies -> { - if (movieSubId == "__search__") { - movieSubId = "movies" - movieCatalog = "movies" - } - } - MainTab.Series -> { - if (seriesSubId == "__search__") { - seriesSubId = "series" - seriesCatalog = "series" - } - } - else -> Unit - } - }, - subItems = subItems, - selectedSubId = selectedSubId, - onSelectSub = { item -> - if (item.id == "__search__") { - onTabChange(MainTab.Search) - focusTopNav() - } else { - when (tab) { - MainTab.Movies -> { - movieSubId = item.id - movieCatalog = item.catalogId - } - MainTab.Series -> { - seriesSubId = item.id - seriesCatalog = item.catalogId - } - else -> Unit - } - } - }, - ) + if (!homeOverlay) { + Box(Modifier.focusRequester(navFocus)) { + HubTopNav(overlay = false) + } } - when (tab) { + AnimatedContent( + targetState = tab, + transitionSpec = { + fadeIn(tween(200)) togetherWith fadeOut(tween(160)) + }, + label = "hubTab", + modifier = Modifier.weight(1f), + ) { currentTab -> + when (currentTab) { MainTab.Home -> HomeScreen( api = api, listState = homeListState, + contentFocusRequester = homeContentFocus, + navFocusRequester = navFocus, onContentFocused = { navFocused = false }, + onChromeCompact = { homeChromeCompact = it }, onOpenMovie = onOpenMovie, onOpenSeries = onOpenSeries, onSeeAll = onBrowse, @@ -452,6 +613,8 @@ private fun MainHub( onCategoryChange = onTvCategoryChange, lastStreamId = tvLastStreamId, onLastStreamChange = onTvLastStreamChange, + favoriteIds = favoriteIds, + onToggleFavorite = onToggleFavorite, focusContentNonce = tvFocusNonce, onAtTopBack = { focusTopNav() }, onPlayChannel = onPlayLive, @@ -469,9 +632,23 @@ private fun MainHub( onTabChange(MainTab.Home) focusTopNav() }, + onSwitchProfile = onSwitchProfile, + onManageProfiles = onManageProfiles, onLogout = onLogout, ) } + } + } + + if (homeOverlay) { + Box( + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .focusRequester(navFocus), + ) { + HubTopNav(overlay = true) + } } if (exitConfirm) { @@ -595,7 +772,10 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit private fun HomeScreen( api: ApiClient, listState: androidx.compose.foundation.lazy.LazyListState, + contentFocusRequester: FocusRequester, + navFocusRequester: FocusRequester, onContentFocused: () -> Unit, + onChromeCompact: (Boolean) -> Unit = {}, onOpenMovie: (String) -> Unit, onOpenSeries: (String) -> Unit, onSeeAll: (catalogId: String, title: String) -> Unit, @@ -603,23 +783,98 @@ private fun HomeScreen( ) { var home by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } var continueMenu by remember { mutableStateOf(null) } var menuReady by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val menuFocus = remember { FocusRequester() } + val context = LocalContext.current + val store = remember { SessionStore(context) } + val density = LocalDensity.current suspend fun reloadHome() { - home = api.home() + val fresh = api.home() + home = fresh + runCatching { store.saveHomeCache(fresh.toCacheJson()) } + val urls = buildList { + home?.spotlight?.forEach { item -> + item.backdropUrl?.let(::add) + item.posterUrl?.let(::add) + item.logoUrl?.let(::add) + } + home?.continueWatching?.take(8)?.forEach { it.posterUrl?.let(::add) } + home?.shelves?.flatMap { it.items }?.take(40)?.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) { + store.homeCacheJson.first()?.let { raw -> + parseHomeCacheJson(raw)?.let { + home = it + loading = false + } + } try { reloadHome() } catch (e: Exception) { - error = e.message + if (home == null) error = e.message ?: "Kon Home niet laden" + } finally { + loading = false } } + // Focus op Afspelen zit BUITEN de LazyColumn → geen bring-into-view scroll-jump + LaunchedEffect(home) { + if (home == null) return@LaunchedEffect + delay(120) + runCatching { contentFocusRequester.requestFocus() } + onContentFocused() + } + + val spotlight = home?.spotlight.orEmpty().ifEmpty { + home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty() + } + val continueItems = home?.continueWatching.orEmpty() + val screenH = LocalConfiguration.current.screenHeightDp.dp + + // Hero vult ~78% scherm; planken starten op ~52% → overlappen de fade + val heroH = remember(screenH) { (screenH.value * 0.78f).dp.coerceIn(420.dp, screenH) } + val shelfStart = remember(screenH) { (screenH.value * 0.52f).dp.coerceIn(300.dp, screenH * 0.58f) } + val heroHpx = with(density) { heroH.toPx() } + val shelfStartPx = with(density) { shelfStart.toPx() } + + // Scroll van spacer-item = hoeveel hero omhoog moet (1:1, geen hacks) + val collapsePx = if (listState.firstVisibleItemIndex == 0) { + listState.firstVisibleItemScrollOffset.toFloat().coerceIn(0f, shelfStartPx) + } else { + shelfStartPx + } + + // Als scroll stilvalt dicht bij de top met rest-offset: snap naar 0 + LaunchedEffect(listState) { + snapshotFlow { + Triple( + listState.firstVisibleItemIndex, + listState.firstVisibleItemScrollOffset, + listState.isScrollInProgress, + ) + }.collect { (index, offset, scrolling) -> + if (!scrolling && index == 0 && offset in 1..160) { + listState.scrollToItem(0) + } + } + } + + LaunchedEffect(listState) { + snapshotFlow { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 80 + }.collect { compact -> onChromeCompact(compact) } + } + LaunchedEffect(continueMenu) { if (continueMenu != null) { menuReady = false @@ -631,20 +886,43 @@ private fun HomeScreen( } } - val spotlight = home?.spotlight.orEmpty().ifEmpty { - home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty() + var spotlightIndex by remember { mutableStateOf(0) } + LaunchedEffect(spotlight.size) { + if (spotlight.size <= 1) return@LaunchedEffect + while (true) { + delay(7000) + spotlightIndex = (spotlightIndex + 1) % spotlight.size + } } + val firstShelfFocus = remember { FocusRequester() } + Box(Modifier.fillMaxSize()) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 56.dp), - verticalArrangement = Arrangement.spacedBy(28.dp), - ) { - item { - SpotlightBillboard( + if (loading && home == null) { + HomeSkeleton() + } else { + // Hero + badge/meta als ÉÉN laag (niet in LazyColumn) → badge kan niet onder menu scrollen + Box( + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(heroH) + .graphicsLayer { + translationY = -collapsePx + alpha = (1f - (collapsePx / heroHpx).coerceIn(0f, 1f) * 0.85f) + }, + ) { + SpotlightHero( items = spotlight, + index = spotlightIndex, + playFocusRequester = contentFocusRequester, + navFocusRequester = navFocusRequester, + shelvesFocusRequester = firstShelfFocus, + onContentFocused = { + onContentFocused() + // Rest-offset na terugscrollen: hero weer volledig onder menu + scope.launch { listState.scrollToItem(0) } + }, onPlay = { item -> onContentFocused() when { @@ -666,55 +944,92 @@ private fun HomeScreen( }, ) } - if (error != null) { - item { Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = 40.dp)) } - } - val continueItems = home?.continueWatching.orEmpty() - if (continueItems.isNotEmpty()) { - val preview = continueItems.take(5) - item { + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 56.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Spacer i.p.v. hero in de lijst: scrollOffset koppelt 1:1 aan hero-collapse + item(key = "hero-spacer") { + Spacer(Modifier.height(shelfStart)) + } + if (error != null) { + item { Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = 40.dp)) } + } + if (continueItems.isNotEmpty()) { + val preview = continueItems.take(5) + item { + ShelfBlock( + title = "Verder kijken", + items = preview, + showCaptions = true, + firstItemFocusRequester = firstShelfFocus, + focusUpTo = contentFocusRequester, + onRowFocused = onContentFocused, + onSeeAll = if (continueItems.size > 5) { + { onSeeAll("continue-watching", "Verder kijken") } + } else { + null + }, + onClick = { item -> + onContentFocused() + when { + !item.mediaFileId.isNullOrBlank() -> onPlay( + item.mediaFileId!!, + item.id.takeIf { item.type == "movie" }, + item.id.takeIf { item.type == "episode" }, + item.seriesId, + if (item.type == "episode") "EPISODE" else "MOVIE", + item.positionMs, + ) + item.type == "movie" -> onOpenMovie(item.id) + item.type == "episode" && !item.seriesId.isNullOrBlank() -> onOpenSeries(item.seriesId!!) + else -> onOpenSeries(item.id) + } + }, + onLongClick = { item -> continueMenu = item }, + ) + } + } + items( + home?.shelves.orEmpty().filter { it.items.size >= 3 }, + key = { it.id }, + ) { shelf -> + val catalogId = when (shelf.slug) { + "recent-movies", "recent-series", "4k-movies" -> shelf.slug + else -> shelf.slug.ifBlank { shelf.id } + } + val firstVisibleShelfId = home?.shelves.orEmpty() + .firstOrNull { it.items.size >= 3 }?.id + val isFirstShelf = continueItems.isEmpty() && shelf.id == firstVisibleShelfId ShelfBlock( - title = "Verder kijken", - items = preview, - onSeeAll = if (continueItems.size > 5) { - { onSeeAll("continue-watching", "Verder kijken") } - } else { - null - }, + title = shelf.title, + items = shelf.items, + firstItemFocusRequester = if (isFirstShelf) firstShelfFocus else null, + focusUpTo = if (isFirstShelf) contentFocusRequester else null, + onRowFocused = onContentFocused, + onSeeAll = { onSeeAll(catalogId, shelf.browseTitle) }, onClick = { item -> onContentFocused() - when { - !item.mediaFileId.isNullOrBlank() -> onPlay( - item.mediaFileId!!, - item.id.takeIf { item.type == "movie" }, - item.id.takeIf { item.type == "episode" }, - item.seriesId, - if (item.type == "episode") "EPISODE" else "MOVIE", - item.positionMs, - ) - item.type == "movie" -> onOpenMovie(item.id) - item.type == "episode" && !item.seriesId.isNullOrBlank() -> onOpenSeries(item.seriesId!!) - else -> onOpenSeries(item.id) - } + if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id) }, - onLongClick = { item -> continueMenu = item }, ) } - } - items(home?.shelves.orEmpty()) { shelf -> - val catalogId = when (shelf.slug) { - "recent-movies", "recent-series", "4k-movies" -> shelf.slug - else -> shelf.slug.ifBlank { shelf.id } + if ( + home != null && + home!!.shelves.none { it.items.size >= 3 } && + continueItems.isEmpty() && + error == null + ) { + item { + EmptyState( + title = "Nog geen titels", + subtitle = "Zodra er content beschikbaar is, zie je die hier.", + ) + } } - ShelfBlock( - title = shelf.title, - items = shelf.items, - onSeeAll = { onSeeAll(catalogId, shelf.browseTitle) }, - onClick = { item -> - onContentFocused() - if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id) - }, - ) } } @@ -740,7 +1055,7 @@ private fun HomeScreen( Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp) } Spacer(Modifier.height(4.dp)) - McButton("Markeren als gezien") { + McButton("Markeer als gezien") { if (!menuReady) return@McButton scope.launch { runCatching { @@ -754,7 +1069,7 @@ private fun HomeScreen( continueMenu = null } } - McButton("Verbergen") { + McButton("Verberg uit lijst") { if (!menuReady) return@McButton scope.launch { val pid = menuItem.progressId @@ -774,89 +1089,74 @@ private fun HomeScreen( } } +/** + * Volledige hero: backdrop full-bleed vanaf y=0 (onder semi-transparant menu), + * badge/meta starten op [HomeChrome.BadgeTop] — berekend t.o.v. TopNavHeight. + */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun SpotlightBillboard( +private fun SpotlightHero( items: List, + index: Int, onPlay: (MediaItem) -> Unit, onOpen: (MediaItem) -> Unit, + playFocusRequester: FocusRequester? = null, + navFocusRequester: FocusRequester? = null, + shelvesFocusRequester: FocusRequester? = null, + onContentFocused: () -> Unit = {}, ) { - var index by remember { mutableStateOf(0) } val safeItems = items.ifEmpty { listOf(MediaItem(type = "movie", id = "", title = "Welkom bij VMC")) } val current = safeItems[index.coerceIn(0, safeItems.lastIndex)] - LaunchedEffect(safeItems.size) { - if (safeItems.size <= 1) return@LaunchedEffect - while (true) { - delay(6500) - index = (index + 1) % safeItems.size + Box( + Modifier + .fillMaxSize() + .clip(RectangleShape) + .background(Color(0xFF0B0B0B)), + ) { + key("${current.type}:${current.id}") { + AsyncImage( + model = current.backdropUrl ?: current.posterUrl, + contentDescription = current.title, + modifier = Modifier.fillMaxSize(), + // Top van de artwork zichtbaar onder het menu; geen extra translationY-giswerk + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, + ) } - } - - Box(Modifier.fillMaxWidth().height(480.dp)) { - AsyncImage( - model = current.backdropUrl ?: current.posterUrl, - contentDescription = current.title, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, + Box( + Modifier.fillMaxSize().background( + Brush.horizontalGradient( + 0.0f to Color(0xCC0B0B0B), + 0.42f to Color(0x660B0B0B), + 0.78f to Color(0x180B0B0B), + 1.0f to Color.Transparent, + ), + ), ) Box( Modifier.fillMaxSize().background( Brush.verticalGradient( - listOf(Color(0x33000000), Color(0x66000000), Color(0xF00B0B0B)), + 0.0f to Color(0x44000000), + 0.10f to Color.Transparent, + 0.50f to Color.Transparent, + 0.78f to Color(0x990B0B0B), + 1.0f to Color(0xFF0B0B0B), ), ), ) - Box( - Modifier.fillMaxSize().background( - Brush.horizontalGradient( - listOf(Color(0xE60B0B0B), Color(0x880B0B0B), Color.Transparent), - ), - ), - ) - - // Peek van volgende posters rechts - if (safeItems.size > 1) { - Row( - Modifier - .align(Alignment.CenterEnd) - .padding(end = 36.dp), - horizontalArrangement = Arrangement.spacedBy((-28).dp), - ) { - val peek = listOf(1, 2).mapNotNull { off -> - safeItems.getOrNull((index + off) % safeItems.size) - } - peek.forEachIndexed { i, item -> - AsyncImage( - model = item.posterUrl ?: item.backdropUrl, - contentDescription = null, - modifier = Modifier - .width(110.dp) - .height(165.dp) - .clip(RoundedCornerShape(6.dp)) - .background(Color(0xFF1A1A1A)) - .graphicsLayer { alpha = if (i == 0) 0.9f else 0.5f }, - contentScale = ContentScale.Crop, - ) - } - } - } Column( Modifier - .align(Alignment.BottomStart) - .fillMaxWidth(0.55f) - .padding(start = 48.dp, end = 24.dp, bottom = 36.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .fillMaxSize() + .padding(top = HomeChrome.BadgeTop) + .padding(start = 48.dp, end = 20.dp, bottom = 36.dp), + verticalArrangement = Arrangement.Top, ) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { - Text( - "VMC", - color = McColors.Brand, - fontSize = 13.sp, - fontWeight = FontWeight.Black, - letterSpacing = 2.sp, - ) + Column( + Modifier.fillMaxWidth(0.52f), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { if (!current.badge.isNullOrBlank()) { Text( current.badge!!.uppercase(), @@ -869,48 +1169,71 @@ private fun SpotlightBillboard( .padding(horizontal = 8.dp, vertical = 3.dp), ) } - } - Text( - current.title, - color = Color.White, - fontSize = 46.sp, - fontWeight = FontWeight.Black, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - lineHeight = 50.sp, - ) - val meta = listOfNotNull( - current.year?.toString(), - if (current.type == "series") "Serie" else "Film", - current.subtitle?.takeIf { it.isNotBlank() }, - ).joinToString(" · ") - if (meta.isNotBlank()) { - Text(meta, color = Color(0xFFCFCFCF), fontSize = 15.sp) - } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - if (current.id.isNotBlank()) { - McButton("▶ Afspelen", primary = true, onClick = { onPlay(current) }) - McButton("Meer info", onClick = { onOpen(current) }) + if (!current.logoUrl.isNullOrBlank()) { + AsyncImage( + model = current.logoUrl, + contentDescription = current.title, + modifier = Modifier + .height(64.dp) + .fillMaxWidth(0.9f), + contentScale = ContentScale.Fit, + alignment = Alignment.CenterStart, + ) + } else { + Text( + current.title, + color = Color.White, + fontSize = 38.sp, + fontWeight = FontWeight.Black, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + lineHeight = 42.sp, + ) } - } - if (safeItems.size > 1) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 4.dp)) { - safeItems.indices.forEach { i -> - Box( - Modifier - .height(4.dp) - .width(if (i == index) 22.dp else 8.dp) - .clip(RoundedCornerShape(2.dp)) - .background(if (i == index) Color.White else Color(0x55FFFFFF)), + val meta = listOfNotNull( + current.year?.toString(), + if (current.type == "series") "Serie" else "Film", + ).joinToString(" · ") + if (meta.isNotBlank()) { + Text(meta, color = Color(0xFFCFCFCF), fontSize = 15.sp) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (current.id.isNotBlank()) { + McButton( + "Afspelen", + primary = true, + focusRequester = playFocusRequester, + focusUp = navFocusRequester, + focusDown = shelvesFocusRequester, + onFocused = onContentFocused, + onClick = { onPlay(current) }, ) + McButton( + "Meer info", + focusUp = navFocusRequester, + focusDown = shelvesFocusRequester, + onFocused = onContentFocused, + onClick = { onOpen(current) }, + ) + } + } + if (safeItems.size > 1) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + safeItems.indices.forEach { i -> + Box( + Modifier + .height(3.dp) + .width(if (i == index) 22.dp else 8.dp) + .clip(RoundedCornerShape(2.dp)) + .background(if (i == index) Color.White else Color(0x44FFFFFF)), + ) + } } } } } } } - -@OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun ShelfBlock( title: String, @@ -918,33 +1241,84 @@ private fun ShelfBlock( onSeeAll: (() -> Unit)?, onClick: (MediaItem) -> Unit, onLongClick: ((MediaItem) -> Unit)? = null, + showCaptions: Boolean = false, + onRowFocused: () -> Unit = {}, + firstItemFocusRequester: FocusRequester? = null, + focusUpTo: FocusRequester? = null, + modifier: Modifier = Modifier, ) { - Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + var rowFocused by remember { mutableStateOf(false) } + Column( + modifier + .onFocusChanged { + rowFocused = it.hasFocus + if (it.hasFocus) onRowFocused() + } + .graphicsLayer { alpha = if (rowFocused) 1f else 0.78f }, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { Row( - Modifier.fillMaxWidth().padding(horizontal = 48.dp), + Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( title, - color = Color(0xFFE5E5E5), - fontSize = 20.sp, + color = Color(0xFFE8E8E8), + fontSize = 19.sp, fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), ) if (onSeeAll != null) { - McButton("Alles") { onSeeAll() } + Surface( + onClick = onSeeAll, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color(0x22FFFFFF), + contentColor = Color(0xFFAAAAAA), + focusedContentColor = Color.White, + ), + ) { + Text( + "Alles ›", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + color = Color.Unspecified, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + } } } - // contentPadding i.p.v. Column-startpadding: focus-scale mag in de marge tekenen LazyRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 18.dp, bottom = 18.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + // Extra ruimte zodat focusedScale (1.10) posters niet afknipt + contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 28.dp, bottom = 28.dp), ) { - items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item -> + itemsIndexed(items, key = { _, it -> "${it.type}:${it.id}:${it.progressId}" }) { index, item -> PosterCard( item = item, onClick = { onClick(item) }, onLongClick = onLongClick?.let { handler -> { handler(item) } }, + showCaption = showCaptions, + modifier = Modifier.then( + if (index == 0 && firstItemFocusRequester != null) { + Modifier + .focusRequester(firstItemFocusRequester) + .then( + if (focusUpTo != null) { + Modifier.focusProperties { up = focusUpTo } + } else { + Modifier + }, + ) + } else { + Modifier + }, + ), ) } } @@ -956,6 +1330,8 @@ private fun ShelfBlock( private fun SettingsScreen( api: ApiClient, onBack: () -> Unit, + onSwitchProfile: () -> Unit = {}, + onManageProfiles: () -> Unit = {}, onLogout: () -> Unit = {}, ) { var prefs by remember { mutableStateOf>(emptyList()) } @@ -986,12 +1362,40 @@ private fun SettingsScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { - Text("VMC · Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold) - Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = 15.sp) - if (error != null) Text(error!!, color = McColors.Danger) + Text("Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(16.dp)) + SectionLabel("Account") Spacer(Modifier.height(8.dp)) + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(McColors.BgElevated) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("Vonas Media Cluster", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Text("Versie ${BuildConfig.VERSION_NAME}", color = McColors.TextDim, fontSize = 14.sp) + } + Spacer(Modifier.height(8.dp)) + McButton("Profiel wisselen", onClick = onSwitchProfile) + Spacer(Modifier.height(12.dp)) + McButton("Profielen beheren", onClick = onManageProfiles) + Spacer(Modifier.height(12.dp)) McButton("Uitloggen", onClick = onLogout) - Spacer(Modifier.height(8.dp)) + 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 = 15.sp, + ) + if (error != null) { + Spacer(Modifier.height(8.dp)) + Text(error!!, color = McColors.Danger) + } + Spacer(Modifier.height(4.dp)) } itemsIndexed(prefs) { index, p -> Row( @@ -1003,7 +1407,14 @@ private fun SettingsScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(p.name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + Column(Modifier.weight(1f)) { + Text(p.name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium) + Text( + if (p.visible) "Zichtbaar op Home" else "Verborgen", + color = if (p.visible) Color(0xFF888888) else Color(0xFF555555), + fontSize = 13.sp, + ) + } Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { McButton("↑") { if (index > 0) { @@ -1034,6 +1445,14 @@ private fun SettingsScreen( } } } + item { + Spacer(Modifier.height(12.dp)) + Text( + "Vonas Media Cluster · ${BuildConfig.VERSION_NAME}", + color = Color(0xFF555555), + fontSize = 12.sp, + ) + } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McTransitions.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McTransitions.kt new file mode 100644 index 0000000..d336c47 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McTransitions.kt @@ -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 } +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt index 95a3ce2..6905e97 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/PlayerScreen.kt @@ -324,16 +324,37 @@ fun PlayerScreen( false } } - Key.DirectionDown -> { + Key.MediaPlayPause -> { if (menu != null) return@onPreviewKeyEvent false - if (!controls) { - showControls() - true - } else { - false - } + togglePlay() + true + } + Key.MediaPlay -> { + if (menu != null) return@onPreviewKeyEvent false + player.play() + showControls() + true + } + Key.MediaPause -> { + if (menu != null) return@onPreviewKeyEvent false + player.pause() + showControls() + true + } + Key.MediaRewind -> { + if (menu != null) return@onPreviewKeyEvent false + player.seekBack() + showControls() + true + } + Key.MediaFastForward -> { + if (menu != null) return@onPreviewKeyEvent false + player.seekForward() + showControls() + true } Key.DirectionLeft -> { + // Alleen seeken zonder controls; mét controls = navigatie in menu if (menu != null || controls) return@onPreviewKeyEvent false player.seekBack() showControls() @@ -345,6 +366,15 @@ fun PlayerScreen( showControls() true } + Key.DirectionDown -> { + if (menu != null) return@onPreviewKeyEvent false + if (!controls) { + showControls() + true + } else { + false + } + } Key.DirectionUp, Key.Menu -> { if (menu != null) return@onPreviewKeyEvent false showControls() @@ -383,7 +413,7 @@ fun PlayerScreen( } if (loading && error == null) { - Text("Laden…", color = Color(0xFFB0B8C8), fontSize = 18.sp, modifier = Modifier.align(Alignment.Center)) + Text("Bezig met laden…", color = Color(0xFFB0B8C8), fontSize = 18.sp, modifier = Modifier.align(Alignment.Center)) } if (error != null) { Column( diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt index 5c3e786..edac0d1 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/SearchScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -29,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -45,7 +47,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.MediaItem +import nl.vonas.mediacluster.tv.data.SessionStore +import nl.vonas.mediacluster.tv.ui.components.EmptyState +import nl.vonas.mediacluster.tv.ui.components.McButton import nl.vonas.mediacluster.tv.ui.components.PosterCard +import nl.vonas.mediacluster.tv.ui.components.SectionLabel import nl.vonas.mediacluster.tv.ui.theme.McColors private val KEYBOARD_CHARS = listOf( @@ -65,6 +71,9 @@ fun SearchScreen( onOpenSeries: (String) -> Unit, onAtTopBack: (() -> Unit)? = null, ) { + val context = LocalContext.current + val store = remember { SessionStore(context) } + val recent by store.recentSearches.collectAsState(initial = emptyList()) var query by remember { mutableStateOf("") } var results by remember { mutableStateOf>(emptyList()) } var busy by remember { mutableStateOf(false) } @@ -85,21 +94,25 @@ fun SearchScreen( } } - fun scheduleSearch(next: String) { + fun scheduleSearch(next: String, saveRecent: Boolean = true) { query = next searchJob?.cancel() searchJob = scope.launch { delay(220) if (next.isBlank()) { results = emptyList() + busy = false return@launch } busy = true error = null try { results = api.search(next) + if (saveRecent && next.trim().length >= 2) { + store.addRecentSearch(next) + } } catch (e: Exception) { - error = e.message + error = e.message ?: "Zoeken mislukt" } finally { busy = false } @@ -214,9 +227,17 @@ fun SearchScreen( when { busy -> "Zoeken…" error != null -> "Fout bij zoeken" - query.isBlank() -> "Resultaten" - results.isEmpty() -> "Niets gevonden" - else -> "${results.size} resultaten" + query.isBlank() -> if (recent.isNotEmpty()) "Recent" else "Resultaten" + results.isEmpty() -> "Geen resultaten" + else -> { + val movies = results.count { it.type == "movie" } + val series = results.size - movies + buildString { + if (movies > 0) append("$movies film${if (movies == 1) "" else "s"}") + if (movies > 0 && series > 0) append(" · ") + if (series > 0) append("$series serie${if (series == 1) "" else "s"}") + } + } }, color = if (error != null) McColors.Danger else McColors.TextDim, fontSize = 14.sp, @@ -245,18 +266,35 @@ fun SearchScreen( } Spacer(Modifier.height(10.dp)) - if (query.isBlank() && results.isEmpty()) { - Box( - Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - "Begin links met typen", - color = Color(0xFF5A5A5A), - fontSize = 15.sp, + when { + query.isBlank() && recent.isNotEmpty() -> { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + SectionLabel("Recent gezocht") + McButton("Wissen") { scope.launch { store.clearRecentSearches() } } + } + recent.forEach { q -> + McButton(q) { scheduleSearch(q) } + } + } + } + query.isBlank() -> { + EmptyState( + title = "Zoek in je bibliotheek", + subtitle = "Typ links een titel om films en series te vinden.", ) } - } else { + !busy && results.isEmpty() && error == null -> { + EmptyState( + title = "Geen resultaten", + subtitle = "Probeer een andere zoekterm.", + ) + } + else -> { LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 110.dp), state = gridState, @@ -268,8 +306,7 @@ fun SearchScreen( items(results, key = { "${it.type}:${it.id}" }) { item -> PosterCard( item = item, - width = 110.dp, - height = 165.dp, + fillWidth = true, modifier = Modifier.padding(6.dp), onClick = { if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id) @@ -277,6 +314,7 @@ fun SearchScreen( ) } } + } } } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt index 13e2f54..04d6f4c 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt @@ -54,6 +54,7 @@ import nl.vonas.mediacluster.tv.data.EpgProgram import nl.vonas.mediacluster.tv.data.IptvCategory import nl.vonas.mediacluster.tv.data.IptvChannel import nl.vonas.mediacluster.tv.data.IptvEpg +import nl.vonas.mediacluster.tv.ui.components.EmptyState import nl.vonas.mediacluster.tv.ui.theme.McColors import java.time.Instant import java.time.ZoneId @@ -65,6 +66,8 @@ private val TvRowActive = Color(0xFF1C1C1C) private val TvDivider = Color(0xFF2A2A2A) private val TvPanelBg = Color(0xFF101010) +const val TV_FAV_CATEGORY_ID = "__fav__" + @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvScreen( @@ -73,6 +76,8 @@ fun TvScreen( onCategoryChange: (String?) -> Unit, lastStreamId: Int? = null, onLastStreamChange: (Int?) -> Unit = {}, + favoriteIds: Set = emptySet(), + onToggleFavorite: (Int) -> Unit = {}, focusContentNonce: Int = 0, onAtTopBack: () -> Unit, onPlayChannel: (streamId: Int) -> Unit, @@ -84,15 +89,43 @@ fun TvScreen( var loadingChannels by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } var focusedEpg by remember { mutableStateOf(null) } + var epgLoading by remember { mutableStateOf(false) } var focusedStreamId by remember { mutableStateOf(null) } var epgJob by remember { mutableStateOf(null) } var focusInList by remember { mutableStateOf(false) } + val channelCache = remember { mutableStateMapOf>() } val channelListState = rememberLazyListState() val categoryRowState = rememberLazyListState() val scope = rememberCoroutineScope() val categoryFocus = remember { mutableStateMapOf() } val channelFocus = remember { mutableStateMapOf() } val epgNowTitles = remember { mutableStateMapOf() } + val epgNextTitles = remember { mutableStateMapOf() } + + 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) { + list.forEach { ch -> + ch.nowTitle?.takeIf { it.isNotBlank() }?.let { epgNowTitles[ch.streamId] = it } + ch.nextTitle?.takeIf { it.isNotBlank() }?.let { epgNextTitles[ch.streamId] = it } + } + } LaunchedEffect(Unit) { loading = true @@ -100,7 +133,9 @@ fun TvScreen( try { val cats = api.iptvCategories() categories = cats - val restored = selectedCategoryId?.takeIf { id -> cats.any { it.id == id } } + val restored = selectedCategoryId?.takeIf { id -> + id == TV_FAV_CATEGORY_ID || cats.any { it.id == id } + } if (restored != null) { onCategoryChange(restored) } else { @@ -113,69 +148,143 @@ fun TvScreen( } } - LaunchedEffect(selectedCategoryId) { + LaunchedEffect(selectedCategoryId, favoriteIds) { val catId = selectedCategoryId ?: return@LaunchedEffect - loadingChannels = true - focusedEpg = null focusedStreamId = null - epgNowTitles.clear() + + if (catId == TV_FAV_CATEGORY_ID) { + loadingChannels = channelCache["__all__"] == null && channels.isEmpty() + try { + val all = channelCache["__all__"] ?: api.iptvChannels("__all__", includeEpg = false).also { + channelCache["__all__"] = it + applyEpgTitles(it) + } + val favList = all.filter { favoriteIds.contains(it.streamId) } + channels = favList + loadingChannels = false + applyEpgTitles(favList) + + val focusId = lastStreamId?.takeIf { id -> favList.any { it.streamId == id } } + ?: favList.firstOrNull()?.streamId + focusId?.let { id -> + focusedStreamId = id + epgLoading = focusedEpg?.streamId != id + runCatching { + val epg = api.iptvEpg(id) + focusedEpg = epg + epg.now?.title?.let { epgNowTitles[id] = it } + epg.next?.title?.let { epgNextTitles[id] = it } + } + epgLoading = false + } + if (favList.isEmpty()) focusedEpg = null + + scope.launch { + // XMLTV kan even duren; daarna short_epg-fill — opnieuw pollen + repeat(5) { attempt -> + runCatching { + val withEpg = api.iptvChannels("__all__", includeEpg = true) + channelCache["__all__"] = withEpg + applyEpgTitles(withEpg) + if (selectedCategoryId == TV_FAV_CATEGORY_ID) { + channels = withEpg.filter { favoriteIds.contains(it.streamId) } + } + val covered = withEpg.count { !it.nowTitle.isNullOrBlank() || !it.nextTitle.isNullOrBlank() } + if (covered >= (withEpg.size * 0.5).toInt() || attempt == 4) return@repeat + } + delay(2_500L * (attempt + 1)) + } + } + } catch (e: Exception) { + if (channels.isEmpty()) error = e.message + loadingChannels = false + } + return@LaunchedEffect + } + + val cached = channelCache[catId] + if (cached != null) { + channels = cached + loadingChannels = false + applyEpgTitles(cached) + } else { + loadingChannels = true + channels = emptyList() + } + try { - val loaded = api.iptvChannels(catId, includeEpg = true) - val titles = api.fetchMissingNowTitles(loaded) + val loaded = api.iptvChannels(catId, includeEpg = false) channels = loaded - titles.forEach { (id, title) -> epgNowTitles[id] = title } + channelCache[catId] = loaded + applyEpgTitles(loaded) + loadingChannels = false + val focusId = lastStreamId?.takeIf { id -> loaded.any { it.streamId == id } } ?: loaded.firstOrNull()?.streamId focusId?.let { id -> focusedStreamId = id - runCatching { focusedEpg = api.iptvEpg(id) } + epgLoading = focusedEpg?.streamId != id + runCatching { + val epg = api.iptvEpg(id) + focusedEpg = epg + epg.now?.title?.let { epgNowTitles[id] = it } + epg.next?.title?.let { epgNextTitles[id] = it } + } + epgLoading = false + } + + // Achtergrond: XMLTV + missing short_epg; pollen tot dekking goed is + scope.launch { + repeat(5) { attempt -> + runCatching { + val withEpg = api.iptvChannels(catId, includeEpg = true) + applyEpgTitles(withEpg) + if (selectedCategoryId == catId) { + channelCache[catId] = withEpg + channels = withEpg + } + val covered = withEpg.count { !it.nowTitle.isNullOrBlank() || !it.nextTitle.isNullOrBlank() } + if (withEpg.isEmpty() || covered >= (withEpg.size * 0.5).toInt() || attempt == 4) { + return@repeat + } + } + delay(2_500L * (attempt + 1)) + } } } catch (e: Exception) { - error = e.message - channels = emptyList() - } finally { + if (channels.isEmpty()) { + error = e.message + channels = emptyList() + } loadingChannels = false } } - LaunchedEffect(selectedCategoryId, channels, loadingChannels, focusInList) { - if (!focusInList || loadingChannels || channels.isEmpty()) return@LaunchedEffect + LaunchedEffect(selectedCategoryId, filteredChannels, loadingChannels, focusInList) { + if (!focusInList || loadingChannels || filteredChannels.isEmpty()) return@LaunchedEffect delay(100) - val targetId = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } } - ?: channels.first().streamId - val idx = channels.indexOfFirst { it.streamId == targetId }.coerceAtLeast(0) + val targetId = lastStreamId?.takeIf { id -> filteredChannels.any { it.streamId == id } } + ?: filteredChannels.first().streamId + val idx = filteredChannels.indexOfFirst { it.streamId == targetId }.coerceAtLeast(0) channelListState.scrollToItem(idx) delay(80) channelFocus[targetId]?.let { runCatching { it.requestFocus() } } focusedStreamId = targetId } - LaunchedEffect(focusContentNonce, loading, categories, channels, selectedCategoryId, lastStreamId) { - if (loading || categories.isEmpty()) return@LaunchedEffect + // TV-tab / omlaag vanaf nav → altijd eerst categorieën (niet meteen zenderlijst of toetsenbord) + LaunchedEffect(focusContentNonce, loading, displayCategories, selectedCategoryId) { + if (loading || displayCategories.isEmpty()) return@LaunchedEffect delay(120) - val channelTarget = lastStreamId?.takeIf { id -> channels.any { it.streamId == id } } - if (channelTarget != null) { - val idx = channels.indexOfFirst { it.streamId == channelTarget } - if (idx >= 0) channelListState.scrollToItem(idx) - channelFocus[channelTarget]?.let { runCatching { it.requestFocus() } } - focusInList = true - focusedStreamId = channelTarget - runCatching { - val epg = api.iptvEpg(channelTarget) - focusedEpg = epg - epg.now?.title?.let { epgNowTitles[channelTarget] = it } - } - } else { - val catId = selectedCategoryId ?: categories.firstOrNull()?.id ?: return@LaunchedEffect - val catIdx = categories.indexOfFirst { it.id == catId }.coerceAtLeast(0) - categoryRowState.scrollToItem(catIdx) - categoryFocus[catId]?.let { runCatching { it.requestFocus() } } - focusInList = false - } + val catId = selectedCategoryId ?: displayCategories.firstOrNull()?.id ?: return@LaunchedEffect + val catIdx = displayCategories.indexOfFirst { it.id == catId }.coerceAtLeast(0) + categoryRowState.scrollToItem(catIdx) + categoryFocus[catId]?.let { runCatching { it.requestFocus() } } + focusInList = false } BackHandler(enabled = focusInList) { - val catId = selectedCategoryId ?: categories.firstOrNull()?.id + val catId = selectedCategoryId ?: displayCategories.firstOrNull()?.id if (catId != null) { categoryFocus[catId]?.let { runCatching { it.requestFocus() } } focusInList = false @@ -189,12 +298,15 @@ fun TvScreen( focusedStreamId = streamId epgJob?.cancel() epgJob = scope.launch { - delay(200) + delay(180) + if (focusedEpg?.streamId != streamId) epgLoading = true runCatching { val epg = api.iptvEpg(streamId) focusedEpg = epg epg.now?.title?.let { epgNowTitles[streamId] = it } + epg.next?.title?.let { epgNextTitles[streamId] = it } } + epgLoading = false } } @@ -216,7 +328,7 @@ fun TvScreen( else -> { Column(Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 8.dp)) { CategoryTabRow( - categories = categories, + categories = displayCategories, selectedId = selectedCategoryId, listState = categoryRowState, categoryFocus = categoryFocus, @@ -248,17 +360,28 @@ fun TvScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { Text("Zender", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium) - Text("Nu op TV", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium) + Text("Nu · Straks", color = McColors.TextDim, fontSize = 11.sp, fontWeight = FontWeight.Medium) } when { - loadingChannels -> { + loadingChannels && filteredChannels.isEmpty() -> { Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { - Text("Zenders en gids laden…", color = McColors.TextDim, fontSize = 14.sp) + Text("Zenders laden…", color = McColors.TextDim, fontSize = 14.sp) } } - channels.isEmpty() -> { + filteredChannels.isEmpty() -> { Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { - Text("Geen zenders", color = McColors.TextDim, fontSize = 14.sp) + EmptyState( + title = if (selectedCategoryId == TV_FAV_CATEGORY_ID) { + "Nog geen favorieten" + } else { + "Geen zenders" + }, + subtitle = if (selectedCategoryId == TV_FAV_CATEGORY_ID) { + "Markeer een zender met ★ in het detailpaneel." + } else { + "Deze categorie heeft geen kanalen." + }, + ) } } else -> { @@ -268,13 +391,15 @@ fun TvScreen( contentPadding = PaddingValues(bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(1.dp), ) { - items(channels, key = { it.streamId }) { ch -> + items(filteredChannels, key = { it.streamId }) { ch -> val fr = channelFocus.getOrPut(ch.streamId) { FocusRequester() } ChannelRow( channel = ch, nowTitle = epgNowTitles[ch.streamId] ?: ch.nowTitle, + nextTitle = epgNextTitles[ch.streamId] ?: ch.nextTitle, isFocused = focusedStreamId == ch.streamId, isLastWatched = ch.streamId == lastStreamId, + isFavorite = favoriteIds.contains(ch.streamId), modifier = Modifier.focusRequester(fr), onClick = { onLastStreamChange(ch.streamId) @@ -303,6 +428,11 @@ fun TvScreen( ChannelDetailPanel( epg = focusedEpg, + loading = epgLoading && focusedEpg == null, + isFavorite = focusedStreamId?.let { favoriteIds.contains(it) } == true, + onToggleFavorite = { + focusedStreamId?.let { onToggleFavorite(it) } + }, modifier = Modifier .weight(0.42f) .fillMaxHeight() @@ -355,19 +485,31 @@ private fun CategoryTabRow( Modifier.padding(horizontal = 14.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - cat.name, - color = Color.Unspecified, - fontSize = 14.sp, - fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + cat.name, + color = Color.Unspecified, + fontSize = 14.sp, + fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (cat.channelCount > 0) { + Text( + "${cat.channelCount}", + color = if (active) Color(0xFFAAAAAA) else Color(0xFF555555), + fontSize = 11.sp, + ) + } + } Spacer(Modifier.height(5.dp)) Box( Modifier .height(2.dp) - .width(if (active) 24.dp else 0.dp) + .width(if (active) 28.dp else 0.dp) .clip(RoundedCornerShape(1.dp)) .background(if (active) McColors.Brand else Color.Transparent), ) @@ -382,8 +524,10 @@ private fun CategoryTabRow( private fun ChannelRow( channel: IptvChannel, nowTitle: String?, + nextTitle: String?, isFocused: Boolean, isLastWatched: Boolean, + isFavorite: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit, onFocused: () -> Unit, @@ -413,7 +557,7 @@ private fun ChannelRow( Row( Modifier .fillMaxWidth() - .height(52.dp) + .height(56.dp) .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -466,30 +610,65 @@ private fun ChannelRow( modifier = Modifier.width(36.dp), ) } - Text( - channel.name, - color = Color.White, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(140.dp), - ) - Text( - nowTitle ?: "", - color = if (isFocused) Color(0xFFCCCCCC) else McColors.TextDim, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) + Column(Modifier.width(140.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + channel.name, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + 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( + "LIVE", + color = McColors.Brand, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + ) + } + Text( + nowTitle ?: if (isFocused) "Programma laden…" else "", + color = if (isFocused) Color(0xFFCCCCCC) else McColors.TextDim, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (!nextTitle.isNullOrBlank()) { + Text( + "Straks · $nextTitle", + color = Color(0xFF666666), + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } } } +@OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun ChannelDetailPanel( epg: IptvEpg?, + loading: Boolean, + isFavorite: Boolean, + onToggleFavorite: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -499,78 +678,137 @@ private fun ChannelDetailPanel( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - if (epg == null) { - Spacer(Modifier.weight(1f)) - Text( - "Selecteer een zender", - color = McColors.TextDim, - fontSize = 14.sp, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - Spacer(Modifier.weight(1f)) - return - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (!epg.logoUrl.isNullOrBlank()) { - AsyncImage( - model = epg.logoUrl, - contentDescription = null, - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(4.dp)) - .background(Color(0xFF0A0A0A)), - contentScale = ContentScale.Fit, - ) + when { + epg == null && loading -> { + EpgSkeleton() } - Column { + epg == null -> { + Spacer(Modifier.weight(1f)) Text( - epg.channelName, - color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + "Selecteer een zender", + color = McColors.TextDim, + fontSize = 14.sp, + modifier = Modifier.align(Alignment.CenterHorizontally), ) + Text( + "Programma-info verschijnt hier", + color = Color(0xFF555555), + fontSize = 12.sp, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(Modifier.weight(1f)) } - } - - Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider)) - - epg.now?.let { now -> - EpgBlock(label = "Nu", program = now, highlight = true) - val progress = epgProgress(now) - if (progress > 0f) { - Box( - Modifier - .fillMaxWidth() - .height(2.dp) - .clip(RoundedCornerShape(1.dp)) - .background(Color(0xFF333333)), + else -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { + if (!epg.logoUrl.isNullOrBlank()) { + AsyncImage( + model = epg.logoUrl, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFF0A0A0A)), + contentScale = ContentScale.Fit, + ) + } + Column(Modifier.weight(1f)) { + Text( + epg.channelName, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Box(Modifier.fillMaxWidth().height(1.dp).background(TvDivider)) + + epg.now?.let { now -> + EpgBlock(label = "Nu", program = now, highlight = true) + val progress = epgProgress(now) Box( Modifier - .fillMaxWidth(progress) - .height(2.dp) - .background(McColors.Brand), + .fillMaxWidth() + .height(3.dp) + .clip(RoundedCornerShape(1.dp)) + .background(Color(0xFF333333)), + ) { + Box( + Modifier + .fillMaxWidth(progress.coerceAtLeast(0.02f)) + .height(3.dp) + .background(McColors.Brand), + ) + } + } + + epg.next?.let { next -> + EpgBlock(label = "Straks", program = next, highlight = false) + } + + if (epg.now == null && epg.next == null) { + Text( + "Geen programmagids voor deze zender", + color = McColors.TextDim, + fontSize = 13.sp, + ) + Text( + "Je kunt gewoon kijken — de gids is niet altijd beschikbaar.", + color = Color(0xFF555555), + fontSize = 12.sp, + lineHeight = 16.sp, ) } + + Spacer(Modifier.weight(1f)) + + Surface( + onClick = onToggleFavorite, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color(0xFF1A1A1A), + focusedContainerColor = Color(0xFF2A2A2A), + contentColor = Color.White, + focusedContentColor = Color.White, + ), + ) { + Text( + if (isFavorite) "★ Favoriet" else "☆ Favoriet", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + color = if (isFavorite) McColors.Brand else Color(0xFFAAAAAA), + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ) + } + + Text("OK om te kijken", color = Color(0xFF666666), fontSize = 12.sp) } } + } +} - epg.next?.let { next -> - EpgBlock(label = "Straks", program = next, highlight = false) +@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))) + } } - - if (epg.now == null && epg.next == null) { - Text("Geen programma-info beschikbaar", color = McColors.TextDim, fontSize = 13.sp) - } - + 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("OK om te kijken", color = Color(0xFF666666), fontSize = 12.sp) + Text("Programma laden…", color = Color(0xFF555555), fontSize = 12.sp) } } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Polish.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Polish.kt new file mode 100644 index 0000000..e289702 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/Polish.kt @@ -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, + ) +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt index 7360f14..e4f3d25 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt @@ -20,7 +20,11 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -30,6 +34,21 @@ import androidx.tv.material3.Surface import androidx.tv.material3.Text import nl.vonas.mediacluster.tv.ui.theme.McColors +/** + * Berekende hoogte van de Home overlay-topnav (geen subtabs). + * + * TopNavBar overlay-row: + * - vertical padding: 14.dp + 14.dp + * - tab-inhoud: 8.dp + ~21.dp tekst + 4.dp + 3.dp underline + 8.dp ≈ 44.dp + * - totaal ≈ 72.dp; TV Surface min. interactive ≈ 48.dp → veilig 76.dp + */ +object HomeChrome { + val TopNavHeight = 76.dp + /** Lucht tussen onderkant menu en badge. */ + val BadgeGap = 12.dp + val BadgeTop = TopNavHeight + BadgeGap // 88.dp +} + enum class MainTab(val label: String) { Home("Home"), Movies("Films"), @@ -56,35 +75,81 @@ fun TopNavBar( selectedSubId: String? = null, onSelectSub: (SubNavItem) -> Unit = {}, modifier: Modifier = Modifier, + /** Home gescrolld: zachtere chrome. */ + compact: Boolean = false, + /** Netflix-stijl: zweeft over hero met scrim. */ + overlay: Boolean = false, + /** Omlaag vanuit menu → Home-content (Afspelen / planken). */ + contentDownFocus: FocusRequester? = null, + profileAvatarEmoji: String? = null, + profileLabel: String? = null, + onProfileClick: (() -> Unit)? = null, ) { - 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( Modifier .fillMaxWidth() - .padding(horizontal = 40.dp, vertical = 14.dp), + .padding(horizontal = 40.dp, vertical = vPad), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( "VMC", color = McColors.Brand, - fontSize = 28.sp, + fontSize = if (compact && !overlay) 22.sp else 28.sp, fontWeight = FontWeight.Black, modifier = Modifier.padding(end = 16.dp), ) visibleTabs.forEach { tab -> val active = selected == tab val color by animateColorAsState( - if (active) Color.White else Color(0xFF808080), + when { + active -> Color.White + overlay -> Color(0xFFE8E8E8) + else -> Color(0xFF808080) + }, label = "tabColor", ) Surface( onClick = { onSelect(tab) }, + modifier = downLink, shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), colors = ClickableSurfaceDefaults.colors( - containerColor = if (active) Color(0x22FFFFFF) else Color.Transparent, - focusedContainerColor = Color(0x33FFFFFF), + containerColor = if (active) Color(0x33FFFFFF) else Color.Transparent, + focusedContainerColor = Color(0x44FFFFFF), contentColor = color, focusedContentColor = Color.White, ), @@ -110,6 +175,38 @@ fun TopNavBar( } } } + Spacer(Modifier.weight(1f)) + if (onProfileClick != null && profileAvatarEmoji != null) { + Surface( + onClick = onProfileClick, + modifier = downLink, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(50)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color(0x33FFFFFF), + focusedContainerColor = Color(0x55FFFFFF), + contentColor = Color.White, + focusedContentColor = Color.White, + ), + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(profileAvatarEmoji, fontSize = 20.sp) + if (!profileLabel.isNullOrBlank()) { + Text( + profileLabel, + color = Color.Unspecified, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + } + } + } + } } if (subItems.isNotEmpty()) { @@ -122,6 +219,7 @@ fun TopNavBar( val active = item.id == selectedSubId Surface( onClick = { onSelectSub(item) }, + modifier = downLink, shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(20.dp)), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), colors = ClickableSurfaceDefaults.colors( diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfileAvatars.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfileAvatars.kt new file mode 100644 index 0000000..96d5e35 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfileAvatars.kt @@ -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 ?: "🙂" diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt new file mode 100644 index 0000000..1c720f5 --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/profiles/ProfilesScreen.kt @@ -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>(emptyList()) } + var canManage by remember { mutableStateOf(false) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var mode by remember { mutableStateOf(ProfilesMode.Pick) } + var pending by remember { mutableStateOf(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, + ) + } +} diff --git a/apps/master-api/prisma/migrations/20260906030000_viewer_profiles/migration.sql b/apps/master-api/prisma/migrations/20260906030000_viewer_profiles/migration.sql new file mode 100644 index 0000000..bdaf785 --- /dev/null +++ b/apps/master-api/prisma/migrations/20260906030000_viewer_profiles/migration.sql @@ -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"); diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index 1ada130..60a78d0 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -89,8 +89,7 @@ model ViewerUser { updatedAt DateTime @updatedAt @map("updated_at") devices ViewerDevice[] - progress WatchProgress[] - shelfPrefs ViewerShelfPreference[] + profiles ViewerProfile[] linkCodes DeviceLinkCode[] googlePlayAuditLogs GooglePlayAccessAuditLog[] iptvAddonToken AddonToken? @relation(fields: [iptvAddonTokenId], references: [id], onDelete: SetNull) @@ -98,20 +97,45 @@ model ViewerUser { @@map("viewer_users") } +/** Netflix-stijl kijkprofiel onder één ViewerUser (voortgang/prefs per profiel). */ +model ViewerProfile { + id String @id @default(uuid()) + viewerUserId String @map("viewer_user_id") + name String + avatarId String @default("fox") @map("avatar_id") + isOwner Boolean @default(false) @map("is_owner") + isKids Boolean @default(false) @map("is_kids") + pinHash String? @map("pin_hash") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade) + devices ViewerDevice[] + progress WatchProgress[] + shelfPrefs ViewerShelfPreference[] + + @@unique([viewerUserId, name]) + @@index([viewerUserId]) + @@map("viewer_profiles") +} + model ViewerDevice { id String @id @default(uuid()) viewerUserId String @map("viewer_user_id") name String platform String @default("android_tv") refreshTokenHash String @unique @map("refresh_token_hash") + activeProfileId String? @map("active_profile_id") lastSeenAt DateTime? @map("last_seen_at") revoked Boolean @default(false) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade) + viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade) + activeProfile ViewerProfile? @relation(fields: [activeProfileId], references: [id], onDelete: SetNull) @@index([viewerUserId]) + @@index([activeProfileId]) @@map("viewer_devices") } @@ -143,7 +167,7 @@ enum WatchMediaType { model WatchProgress { id String @id @default(uuid()) - viewerUserId String @map("viewer_user_id") + profileId String @map("profile_id") mediaType WatchMediaType @map("media_type") movieId String? @map("movie_id") episodeId String? @map("episode_id") @@ -155,26 +179,26 @@ model WatchProgress { updatedAt DateTime @updatedAt @map("updated_at") createdAt DateTime @default(now()) @map("created_at") - viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade) + profile ViewerProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) - @@unique([viewerUserId, movieId]) - @@unique([viewerUserId, episodeId]) - @@index([viewerUserId, updatedAt]) - @@index([viewerUserId, hiddenFromContinue, completed]) + @@unique([profileId, movieId]) + @@unique([profileId, episodeId]) + @@index([profileId, updatedAt]) + @@index([profileId, hiddenFromContinue, completed]) @@map("watch_progress") } model ViewerShelfPreference { - id String @id @default(uuid()) - viewerUserId String @map("viewer_user_id") - shelfId String @map("shelf_id") - visible Boolean @default(true) - sortOrder Int @default(0) @map("sort_order") + id String @id @default(uuid()) + profileId String @map("profile_id") + shelfId String @map("shelf_id") + visible Boolean @default(true) + sortOrder Int @default(0) @map("sort_order") - viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade) + profile ViewerProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) - @@unique([viewerUserId, shelfId]) - @@index([viewerUserId, sortOrder]) + @@unique([profileId, shelfId]) + @@index([profileId, sortOrder]) @@map("viewer_shelf_preferences") } @@ -350,6 +374,8 @@ model Movie { backdropUrl String? @map("backdrop_url") logoUrl String? @map("logo_url") genres String[] @default([]) + /** TMDB adult-flag; kids-profielen filteren hierop. */ + adult Boolean @default(false) runtime Int? imdbId String? @unique @map("imdb_id") tmdbId Int? @unique @map("tmdb_id") @@ -360,6 +386,7 @@ model Movie { matches LibraryMatch[] subtitles LibrarySubtitle[] + @@index([adult]) @@map("movies") } @@ -372,6 +399,8 @@ model Series { backdropUrl String? @map("backdrop_url") logoUrl String? @map("logo_url") genres String[] @default([]) + /** TMDB adult-flag; kids-profielen filteren hierop. */ + adult Boolean @default(false) imdbId String? @unique @map("imdb_id") tmdbId Int? @unique @map("tmdb_id") createdAt DateTime @default(now()) @map("created_at") @@ -381,6 +410,7 @@ model Series { episodes Episode[] matches LibraryMatch[] + @@index([adult]) @@map("series") } diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index 5855a88..5e162ea 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -49,6 +49,7 @@ type TmdbMeta = { backdropUrl?: string; logoUrl?: string; imdbId?: string; + adult?: boolean; }; /** Extra fields for Stremio meta detail pages. */ @@ -562,6 +563,7 @@ export class MetadataService { posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, tmdbId: tmdb.id, + adult: tmdb.adult === true, }, update: { title: tmdb.title, @@ -569,6 +571,7 @@ export class MetadataService { posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, year: tmdb.year, + adult: tmdb.adult === true, }, }); if (tmdb.imdbId) { @@ -1004,6 +1007,7 @@ export class MetadataService { posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, tmdbId: tmdb.id, + adult: tmdb.adult === true, ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}), }, update: { @@ -1012,6 +1016,7 @@ export class MetadataService { posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, year: tmdb.year, + adult: tmdb.adult === true, ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}), }, }); @@ -1764,6 +1769,7 @@ export class MetadataService { posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, tmdbId: tmdb.id, + adult: tmdb.adult === true, }; try { return await prisma.movie.upsert({ @@ -1958,6 +1964,7 @@ export class MetadataService { release_date?: string; poster_path?: string; backdrop_path?: string; + adult?: boolean; }; const imdbId = await this.fetchTmdbMovieImdbId(tmdbId); const meta: TmdbMeta = { @@ -1972,6 +1979,7 @@ export class MetadataService { ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined, imdbId, + adult: item.adult === true, }; this.movieCache.set(tmdbId, meta); return meta; @@ -2013,6 +2021,7 @@ export class MetadataService { first_air_date?: string; poster_path?: string; backdrop_path?: string; + adult?: boolean; }; const logoUrl = await this.fetchTmdbSeriesLogo(tmdbId); const imdbId = await this.fetchTmdbSeriesImdbId(tmdbId); @@ -2029,6 +2038,7 @@ export class MetadataService { : undefined, logoUrl, imdbId, + adult: item.adult === true, }; this.seriesCache.set(tmdbId, meta); return meta; diff --git a/apps/master-api/src/viewer/kids-filter.ts b/apps/master-api/src/viewer/kids-filter.ts new file mode 100644 index 0000000..efd0dda --- /dev/null +++ b/apps/master-api/src/viewer/kids-filter.ts @@ -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())); +} diff --git a/apps/master-api/src/viewer/profiles.ts b/apps/master-api/src/viewer/profiles.ts new file mode 100644 index 0000000..c33fef6 --- /dev/null +++ b/apps/master-api/src/viewer/profiles.ts @@ -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(); + +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 }; + } +} diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 732eb70..bcaffcd 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -3,7 +3,8 @@ import { AppError } from "../security/errors"; import { requireAdmin, type AuthenticatedRequest } from "../auth/routes"; import { PlaybackService } from "../playback/service"; import type { Config } from "../config"; -import { ViewerService } from "./service"; +import { ViewerService, requireProfile } from "./service"; +import { ViewerProfileService } from "./profiles"; import type { DownloadService } from "../downloads/service"; import type { SubtitleSource } from "../opensubtitles/client"; import { GooglePlayAccessService } from "../google-play/service"; @@ -15,6 +16,7 @@ export function registerViewerRoutes( ) { const playback = new PlaybackService(config); const viewers = new ViewerService(config, playback, downloads); + const profiles = new ViewerProfileService(); const googlePlay = new GooglePlayAccessService(config); // ——— Admin: kijkers ——— @@ -125,29 +127,105 @@ export function registerViewerRoutes( return { viewer: { id: auth.viewerId, email: auth.email, name: auth.name }, deviceId: auth.deviceId, + profile: auth.profile, iptv: { enabled: iptv.enabled }, }; }); + app.get("/api/v1/client/profiles/avatars", async (request) => { + await viewers.authFromBearer(request.headers.authorization); + return { avatars: profiles.avatars() }; + }); + + app.get("/api/v1/client/profiles", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + return { + profiles: await profiles.list(auth.viewerId), + activeProfileId: auth.profileId, + // Alleen als het hoofdprofiel actief is (niet vanaf kale switcher) + canManage: auth.profile?.isOwner === true, + }; + }); + + app.post( + "/api/v1/client/profiles", + { + config: { rateLimit: { max: 20, timeWindow: "1 minute" } }, + }, + async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const body = (request.body || {}) as { + name?: string; + avatarId?: string; + isKids?: boolean; + pin?: string | null; + }; + const profile = await profiles.create(auth.viewerId, auth.profileId, body); + return { profile }; + } + ); + + app.patch("/api/v1/client/profiles/:id", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const { id } = request.params as { id: string }; + const body = (request.body || {}) as { + name?: string; + avatarId?: string; + isKids?: boolean; + pin?: string | null; + clearPin?: boolean; + }; + const profile = await profiles.update(auth.viewerId, auth.profileId, id, body); + return { profile }; + }); + + app.delete("/api/v1/client/profiles/:id", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const { id } = request.params as { id: string }; + return profiles.remove(auth.viewerId, auth.profileId, id); + }); + + app.post( + "/api/v1/client/profiles/:id/select", + { + config: { rateLimit: { max: 30, timeWindow: "1 minute" } }, + }, + async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + const { id } = request.params as { id: string }; + const body = (request.body || {}) as { pin?: string }; + return profiles.select(auth.viewerId, auth.deviceId, id, body.pin); + } + ); + + app.post("/api/v1/client/profiles/clear", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + return profiles.clearActive(auth.deviceId); + }); + app.get("/api/v1/client/home", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); - return viewers.home(auth.viewerId); + const profileId = requireProfile(auth); + return viewers.home(profileId, auth.profile?.isKids === true); }); app.get("/api/v1/client/movies/:id", async (request) => { - await viewers.authFromBearer(request.headers.authorization); + const auth = await viewers.authFromBearer(request.headers.authorization); + requireProfile(auth); const { id } = request.params as { id: string }; - return viewers.movieDetail(id); + return viewers.movieDetail(id, auth.profile?.isKids === true); }); app.get("/api/v1/client/series/:id", async (request) => { - await viewers.authFromBearer(request.headers.authorization); + const auth = await viewers.authFromBearer(request.headers.authorization); + requireProfile(auth); const { id } = request.params as { id: string }; - return viewers.seriesDetail(id); + return viewers.seriesDetail(id, auth.profile?.isKids === true); }); app.post("/api/v1/client/play", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); + requireProfile(auth); const body = request.body as { mediaFileId?: string }; if (!body.mediaFileId) throw new AppError("INVALID_REQUEST", "mediaFileId verplicht", 400); return viewers.play(body.mediaFileId, auth.viewerId); @@ -155,6 +233,7 @@ export function registerViewerRoutes( app.post("/api/v1/client/progress", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); + const profileId = requireProfile(auth); const body = request.body as { mediaType?: "MOVIE" | "EPISODE"; movieId?: string; @@ -166,7 +245,7 @@ export function registerViewerRoutes( hiddenFromContinue?: boolean; }; if (!body.mediaType) throw new AppError("INVALID_REQUEST", "mediaType verplicht", 400); - const row = await viewers.upsertProgress(auth.viewerId, { + const row = await viewers.upsertProgress(profileId, { mediaType: body.mediaType, movieId: body.movieId, episodeId: body.episodeId, @@ -181,49 +260,63 @@ export function registerViewerRoutes( app.post("/api/v1/client/progress/mark-watched", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); + const profileId = requireProfile(auth); const body = request.body as { movieId?: string; episodeId?: string }; - const row = await viewers.markWatched(auth.viewerId, body); + const row = await viewers.markWatched(profileId, body); return { progress: row }; }); app.post("/api/v1/client/progress/:id/dismiss", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); + const profileId = requireProfile(auth); const { id } = request.params as { id: string }; - return viewers.dismissContinue(auth.viewerId, id); + return viewers.dismissContinue(profileId, id); }); app.get("/api/v1/client/search", async (request) => { - await viewers.authFromBearer(request.headers.authorization); + const auth = await viewers.authFromBearer(request.headers.authorization); + requireProfile(auth); const q = request.query as { q?: string; take?: string }; const query = (q.q || "").trim(); if (query.length < 1) return { results: [] }; const take = Math.min(parseInt(q.take || "48", 10) || 48, 100); - return { results: await viewers.search(query, take) }; + return { + results: await viewers.search(query, take, auth.profile?.isKids === true), + }; }); app.get("/api/v1/client/catalog/:id", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); + const profileId = requireProfile(auth); const { id } = request.params as { id: string }; const q = request.query as { skip?: string; take?: string }; const skip = Math.max(0, parseInt(q.skip || "0", 10) || 0); const take = Math.min(parseInt(q.take || "60", 10) || 60, 100); - return viewers.browseCatalog(id, skip, take, auth.viewerId); + return viewers.browseCatalog( + id, + skip, + take, + profileId, + auth.profile?.isKids === true + ); }); app.get("/api/v1/client/shelves/prefs", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); - return { shelves: await viewers.getShelfPrefs(auth.viewerId) }; + const profileId = requireProfile(auth); + return { shelves: await viewers.getShelfPrefs(profileId) }; }); app.put("/api/v1/client/shelves/prefs", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); + const profileId = requireProfile(auth); const body = request.body as { shelves?: Array<{ shelfId: string; visible: boolean; sortOrder: number }>; }; if (!Array.isArray(body.shelves)) { throw new AppError("INVALID_REQUEST", "shelves array verplicht", 400); } - return { shelves: await viewers.setShelfPrefs(auth.viewerId, body.shelves) }; + return { shelves: await viewers.setShelfPrefs(profileId, body.shelves) }; }); app.get("/api/v1/client/iptv/status", async (request) => { diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index d75bdea..e415435 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -15,6 +15,13 @@ import { cleanMeta, formatMediaFileLabel } from "./media-label"; import type { DownloadService } from "../downloads/service"; import type { SubtitleSource } from "../opensubtitles/client"; import { MetadataService } from "../metadata/service"; +import { ensureOwnerProfile } from "./profiles"; +import { + kidsContentWhere, + kidsMovieSql, + kidsSeriesSql, + isKidsBlockedTitle, +} from "./kids-filter"; const CODE_TTL_MS = 10 * 60 * 1000; const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; @@ -27,8 +34,24 @@ export type AuthedViewer = { deviceId: string; email: string; name: string | null; + profileId: string | null; + profile: { + id: string; + name: string; + avatarId: string; + isOwner: boolean; + isKids: boolean; + hasPin: boolean; + } | null; }; +export function requireProfile(auth: AuthedViewer): string { + if (!auth.profileId) { + throw new AppError("PROFILE_REQUIRED", "Selecteer eerst een profiel", 428); + } + return auth.profileId; +} + export function generateLinkCode(length = 8): string { const bytes = randomBytes(length); let out = ""; @@ -69,7 +92,7 @@ export class ViewerService { const existing = await prisma.viewerUser.findUnique({ where: { email } }); if (existing) throw new AppError("CONFLICT", "E-mail bestaat al", 409); - return prisma.viewerUser.create({ + const viewer = await prisma.viewerUser.create({ data: { email, passwordHash: await hashPassword(input.password), @@ -85,6 +108,8 @@ export class ViewerService { createdAt: true, }, }); + await ensureOwnerProfile(viewer.id, viewer.name); + return viewer; } async listViewers() { @@ -297,7 +322,10 @@ export class ViewerService { const device = await prisma.viewerDevice.findUnique({ where: { refreshTokenHash: hashToken(token) }, - include: { viewer: true }, + include: { + viewer: true, + activeProfile: true, + }, }); if (!device || device.revoked) { throw new AppError("UNAUTHORIZED", "Ongeldig of ingetrokken apparaat", 401); @@ -311,27 +339,46 @@ export class ViewerService { data: { lastSeenAt: new Date() }, }); + // Zorg dat oudere accounts altijd een hoofdprofiel hebben + await ensureOwnerProfile(device.viewer.id, device.viewer.name); + + const profile = + device.activeProfile && device.activeProfile.viewerUserId === device.viewer.id + ? device.activeProfile + : null; + return { viewerId: device.viewer.id, deviceId: device.id, email: device.viewer.email, name: device.viewer.name, + profileId: profile?.id ?? null, + profile: profile + ? { + id: profile.id, + name: profile.name, + avatarId: profile.avatarId, + isOwner: profile.isOwner, + isKids: profile.isKids, + hasPin: !!profile.pinHash, + } + : null, }; } - async home(viewerId: string) { + async home(profileId: string, kidsMode: boolean) { const [shelves, prefs, continueItems, recentMovies, recentSeries, fourK, spotlight] = await Promise.all([ prisma.libraryShelf.findMany({ where: { enabled: true }, orderBy: { sortOrder: "asc" }, }), - prisma.viewerShelfPreference.findMany({ where: { viewerUserId: viewerId } }), - this.continueWatching(viewerId), - this.recentMovies(48), - this.recentSeries(36), - this.fourKMovies(36), - this.spotlight(12), + prisma.viewerShelfPreference.findMany({ where: { profileId } }), + this.continueWatching(profileId, kidsMode), + this.recentMovies(48, 0, kidsMode), + this.recentSeries(36, 0, kidsMode), + this.fourKMovies(36, 0, kidsMode), + this.spotlight(12, kidsMode), ]); const prefMap = new Map(prefs.map((p) => [p.shelfId, p])); @@ -401,7 +448,7 @@ export class ViewerService { } for (const shelf of ordered) { - const items = await this.shelfItems(shelf.id, shelf.kind, 48); + const items = await this.shelfItems(shelf.id, shelf.kind, 48, kidsMode); if (items.length) { const homepageLabel = shelf.homepageName?.trim(); rows.push({ @@ -423,11 +470,14 @@ export class ViewerService { } /** Nieuwste titels (op bestand-mtime van de node) voor de home billboard. */ - async spotlight(take: number) { + async spotlight(take: number, kidsMode = false) { + const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty; + const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty; const movieIds = await prisma.$queryRaw>` SELECT mf.movie_id, MAX(mf.discovered_at) AS added_at FROM media_files mf WHERE mf.available = true AND mf.movie_id IS NOT NULL + ${kidsMovie} GROUP BY mf.movie_id ORDER BY added_at DESC, MAX(mf.discovered_at) DESC LIMIT ${take * 2} @@ -437,6 +487,7 @@ export class ViewerService { FROM media_files mf INNER JOIN episodes e ON e.id = mf.episode_id WHERE mf.available = true AND mf.episode_id IS NOT NULL + ${kidsSeries} GROUP BY e.series_id ORDER BY added_at DESC, MAX(mf.discovered_at) DESC LIMIT ${take * 2} @@ -517,13 +568,16 @@ export class ViewerService { .map(({ addedAt: _a, ...rest }) => rest); } - async recentMovies(take: number, skip = 0) { - const page = await this.moviesByRecency(skip, take, Prisma.empty, { cap: RECENT_CATALOG_CAP }); + async recentMovies(take: number, skip = 0, kidsMode = false) { + const page = await this.moviesByRecency(skip, take, Prisma.empty, { + cap: RECENT_CATALOG_CAP, + kidsMode, + }); return page.items; } - async recentSeries(take: number, skip = 0) { - const page = await this.seriesByRecency(skip, take, { cap: RECENT_CATALOG_CAP }); + async recentSeries(take: number, skip = 0, kidsMode = false) { + const page = await this.seriesByRecency(skip, take, { cap: RECENT_CATALOG_CAP, kidsMode }); return page.items; } @@ -535,15 +589,17 @@ export class ViewerService { skip: number, take: number, extraWhereSql: Prisma.Sql = Prisma.empty, - opts: { cap?: number } = {} + opts: { cap?: number; kidsMode?: boolean } = {} ) { const cap = opts.cap; + const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty; const idRows = await prisma.$queryRaw>` SELECT mf.movie_id FROM media_files mf WHERE mf.available = true AND mf.movie_id IS NOT NULL ${extraWhereSql} + ${kidsSql} GROUP BY mf.movie_id ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC, MIN(mf.release_name) ASC ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty} @@ -595,14 +651,16 @@ export class ViewerService { private async seriesByRecency( skip: number, take: number, - opts: { cap?: number } = {} + opts: { cap?: number; kidsMode?: boolean } = {} ) { const cap = opts.cap; + const kidsSql = opts.kidsMode ? kidsSeriesSql() : Prisma.empty; const idRows = await prisma.$queryRaw>` SELECT e.series_id FROM media_files mf INNER JOIN episodes e ON e.id = mf.episode_id WHERE mf.available = true AND mf.episode_id IS NOT NULL + ${kidsSql} GROUP BY e.series_id ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty} @@ -644,7 +702,7 @@ export class ViewerService { }; } - async fourKMovies(take: number, skip = 0) { + async fourKMovies(take: number, skip = 0, kidsMode = false) { const page = await this.moviesByRecency( skip, take, @@ -653,13 +711,13 @@ export class ViewerService { OR mf.resolution ILIKE '%4K%' OR mf.resolution ILIKE '%UHD%' )`, - { cap: RECENT_CATALOG_CAP } + { cap: RECENT_CATALOG_CAP, kidsMode } ); return page.items; } - async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) { - const page = await this.shelfItemsPage(shelfId, kind, 0, take); + async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number, kidsMode = false) { + const page = await this.shelfItemsPage(shelfId, kind, 0, take, kidsMode); return page.items; } @@ -668,8 +726,11 @@ export class ViewerService { shelfId: string, kind: "MOVIE" | "SERIES", skip: number, - take: number + take: number, + kidsMode = false ) { + const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty; + const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty; if (kind === "MOVIE") { const [countRow] = await prisma.$queryRaw>` SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count @@ -677,6 +738,7 @@ export class ViewerService { WHERE mf.shelf_id = ${shelfId} AND mf.available = true AND mf.movie_id IS NOT NULL + ${kidsMovie} `; const total = Number(countRow?.count ?? 0); const idRows = await prisma.$queryRaw>` @@ -685,6 +747,7 @@ export class ViewerService { WHERE mf.shelf_id = ${shelfId} AND mf.available = true AND mf.movie_id IS NOT NULL + ${kidsMovie} GROUP BY mf.movie_id ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC OFFSET ${skip} @@ -728,6 +791,7 @@ export class ViewerService { WHERE mf.shelf_id = ${shelfId} AND mf.available = true AND mf.episode_id IS NOT NULL + ${kidsSeries} `; const total = Number(countRow?.count ?? 0); const idRows = await prisma.$queryRaw>` @@ -737,6 +801,7 @@ export class ViewerService { WHERE mf.shelf_id = ${shelfId} AND mf.available = true AND mf.episode_id IS NOT NULL + ${kidsSeries} GROUP BY e.series_id ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC OFFSET ${skip} @@ -764,10 +829,10 @@ export class ViewerService { }; } - async continueWatching(viewerId: string) { + async continueWatching(profileId: string, kidsMode = false) { const rows = await prisma.watchProgress.findMany({ where: { - viewerUserId: viewerId, + profileId, hiddenFromContinue: false, completed: false, positionMs: { gt: 30_000 }, @@ -781,6 +846,7 @@ export class ViewerService { if (p.mediaType === "MOVIE" && p.movieId) { const movie = await prisma.movie.findUnique({ where: { id: p.movieId } }); if (!movie) continue; + if (kidsMode && isKidsBlockedTitle(movie)) continue; const file = await prisma.mediaFile.findFirst({ where: { movieId: movie.id, available: true }, orderBy: { updatedAt: "desc" }, @@ -803,6 +869,7 @@ export class ViewerService { include: { series: true }, }); if (!episode) continue; + if (kidsMode && isKidsBlockedTitle(episode.series)) continue; const file = await prisma.mediaFile.findFirst({ where: { episodeId: episode.id, available: true }, orderBy: { updatedAt: "desc" }, @@ -829,7 +896,7 @@ export class ViewerService { } async upsertProgress( - viewerId: string, + profileId: string, body: { mediaType: "MOVIE" | "EPISODE"; movieId?: string; @@ -851,10 +918,10 @@ export class ViewerService { if (!body.movieId) throw new AppError("INVALID_REQUEST", "movieId verplicht", 400); return prisma.watchProgress.upsert({ where: { - viewerUserId_movieId: { viewerUserId: viewerId, movieId: body.movieId }, + profileId_movieId: { profileId, movieId: body.movieId }, }, create: { - viewerUserId: viewerId, + profileId, mediaType: "MOVIE", movieId: body.movieId, positionMs, @@ -885,10 +952,10 @@ export class ViewerService { } return prisma.watchProgress.upsert({ where: { - viewerUserId_episodeId: { viewerUserId: viewerId, episodeId: body.episodeId }, + profileId_episodeId: { profileId, episodeId: body.episodeId }, }, create: { - viewerUserId: viewerId, + profileId, mediaType: "EPISODE", episodeId: body.episodeId, seriesId, @@ -913,9 +980,9 @@ export class ViewerService { }); } - async markWatched(viewerId: string, opts: { movieId?: string; episodeId?: string }) { + async markWatched(profileId: string, opts: { movieId?: string; episodeId?: string }) { if (opts.movieId) { - return this.upsertProgress(viewerId, { + return this.upsertProgress(profileId, { mediaType: "MOVIE", movieId: opts.movieId, positionMs: 1, @@ -925,7 +992,7 @@ export class ViewerService { }); } if (opts.episodeId) { - return this.upsertProgress(viewerId, { + return this.upsertProgress(profileId, { mediaType: "EPISODE", episodeId: opts.episodeId, positionMs: 1, @@ -937,9 +1004,9 @@ export class ViewerService { throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400); } - async dismissContinue(viewerId: string, progressId: string) { + async dismissContinue(profileId: string, progressId: string) { const row = await prisma.watchProgress.findFirst({ - where: { id: progressId, viewerUserId: viewerId }, + where: { id: progressId, profileId }, }); if (!row) throw new AppError("NOT_FOUND", "Niet gevonden", 404); await prisma.watchProgress.update({ @@ -949,14 +1016,16 @@ export class ViewerService { return { ok: true }; } - async search(query: string, take: number) { + async search(query: string, take: number, kidsMode = false) { const q = query.trim(); if (!q) return []; + const kidsWhere = kidsMode ? kidsContentWhere() : {}; const [movies, series] = await Promise.all([ prisma.movie.findMany({ where: { title: { contains: q, mode: "insensitive" }, mediaFiles: { some: { available: true } }, + ...kidsWhere, }, orderBy: { title: "asc" }, take, @@ -972,6 +1041,7 @@ export class ViewerService { where: { title: { contains: q, mode: "insensitive" }, episodes: { some: { mediaFiles: { some: { available: true } } } }, + ...kidsWhere, }, orderBy: { title: "asc" }, take, @@ -1000,12 +1070,18 @@ export class ViewerService { .slice(0, take); } - async browseCatalog(catalogId: string, skip: number, take: number, viewerId?: string) { + async browseCatalog( + catalogId: string, + skip: number, + take: number, + profileId?: string, + kidsMode = false + ) { const id = catalogId.trim().toLowerCase(); if (id === "continue-watching" || id === "continue" || id === "verder-kijken") { - if (!viewerId) throw new AppError("UNAUTHORIZED", "Niet ingelogd", 401); - const all = await this.continueWatching(viewerId); + if (!profileId) throw new AppError("UNAUTHORIZED", "Niet ingelogd", 401); + const all = await this.continueWatching(profileId, kidsMode); return { id: "continue-watching", title: "Verder kijken", @@ -1016,7 +1092,7 @@ export class ViewerService { } if (id === "movies" || id === "films") { - const page = await this.moviesByRecency(skip, take); + const page = await this.moviesByRecency(skip, take, Prisma.empty, { kidsMode }); return { id, title: "Films", @@ -1027,7 +1103,7 @@ export class ViewerService { } if (id === "series" || id === "shows") { - const page = await this.seriesByRecency(skip, take); + const page = await this.seriesByRecency(skip, take, { kidsMode }); return { id, title: "Series", @@ -1040,6 +1116,7 @@ export class ViewerService { if (id === "recent-movies") { const page = await this.moviesByRecency(skip, take, Prisma.empty, { cap: RECENT_CATALOG_CAP, + kidsMode, }); return { id, @@ -1051,7 +1128,10 @@ export class ViewerService { } if (id === "recent-series") { - const page = await this.seriesByRecency(skip, take, { cap: RECENT_CATALOG_CAP }); + const page = await this.seriesByRecency(skip, take, { + cap: RECENT_CATALOG_CAP, + kidsMode, + }); return { id, title: "Recent toegevoegde series", @@ -1070,7 +1150,7 @@ export class ViewerService { OR mf.resolution ILIKE '%4K%' OR mf.resolution ILIKE '%UHD%' )`, - { cap: RECENT_CATALOG_CAP } + { cap: RECENT_CATALOG_CAP, kidsMode } ); return { id, @@ -1095,7 +1175,8 @@ export class ViewerService { shelf.id, shelf.kind as "MOVIE" | "SERIES", skip, - take + take, + kidsMode ); return { id: shelf.slug, @@ -1106,13 +1187,13 @@ export class ViewerService { }; } - async getShelfPrefs(viewerId: string) { + async getShelfPrefs(profileId: string) { const shelves = await prisma.libraryShelf.findMany({ where: { enabled: true }, orderBy: { sortOrder: "asc" }, }); const prefs = await prisma.viewerShelfPreference.findMany({ - where: { viewerUserId: viewerId }, + where: { profileId }, }); const prefMap = new Map(prefs.map((p) => [p.shelfId, p])); return shelves.map((s, idx) => { @@ -1129,16 +1210,16 @@ export class ViewerService { } async setShelfPrefs( - viewerId: string, + profileId: string, items: Array<{ shelfId: string; visible: boolean; sortOrder: number }> ) { for (const item of items) { await prisma.viewerShelfPreference.upsert({ where: { - viewerUserId_shelfId: { viewerUserId: viewerId, shelfId: item.shelfId }, + profileId_shelfId: { profileId, shelfId: item.shelfId }, }, create: { - viewerUserId: viewerId, + profileId, shelfId: item.shelfId, visible: item.visible, sortOrder: item.sortOrder, @@ -1149,10 +1230,10 @@ export class ViewerService { }, }); } - return this.getShelfPrefs(viewerId); + return this.getShelfPrefs(profileId); } - async movieDetail(movieId: string) { + async movieDetail(movieId: string, kidsMode = false) { const movie = await prisma.movie.findUnique({ where: { id: movieId }, include: { @@ -1164,6 +1245,9 @@ export class ViewerService { }, }); if (!movie) throw new AppError("NOT_FOUND", "Film niet gevonden", 404); + if (kidsMode && isKidsBlockedTitle(movie)) { + throw new AppError("FORBIDDEN", "Niet beschikbaar in kids-profiel", 403); + } const extras = await this.metadata.getStremioMetaExtras( "movie", @@ -1217,7 +1301,7 @@ export class ViewerService { }; } - async seriesDetail(seriesId: string) { + async seriesDetail(seriesId: string, kidsMode = false) { const series = await prisma.series.findUnique({ where: { id: seriesId }, include: { @@ -1239,6 +1323,9 @@ export class ViewerService { }, }); if (!series) throw new AppError("NOT_FOUND", "Serie niet gevonden", 404); + if (kidsMode && isKidsBlockedTitle(series)) { + throw new AppError("FORBIDDEN", "Niet beschikbaar in kids-profiel", 403); + } const extras = await this.metadata.getStremioMetaExtras( "series",