Rename to VMC and polish TV nav, spotlight, Netflix player.
Home billboard shows latest titles; shelves sort by newest files; Films/Series get shelf subnav with search; player overlay closer to Netflix. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e22cef06dd
commit
06e7640ab5
17 changed files with 940 additions and 340 deletions
|
|
@ -42,7 +42,7 @@ export default function LinkDevicePage() {
|
||||||
return (
|
return (
|
||||||
<main className="link-page">
|
<main className="link-page">
|
||||||
<div className="link-card">
|
<div className="link-card">
|
||||||
<p className="page-kicker">Media Cluster</p>
|
<p className="page-kicker">VMC</p>
|
||||||
<h1>Apparaat koppelen</h1>
|
<h1>Apparaat koppelen</h1>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Log in met je kijker-account en voer de code van je TV of telefoon in.
|
Log in met je kijker-account en voer de code van je TV of telefoon in.
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export default function LoginPage() {
|
||||||
<div className="login-brand">
|
<div className="login-brand">
|
||||||
<span className="mark">MC</span>
|
<span className="mark">MC</span>
|
||||||
<div>
|
<div>
|
||||||
<h1>Media Cluster</h1>
|
<h1>VMC</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="login-sub">Authenticated access // ops</p>
|
<p className="login-sub">Authenticated access // ops</p>
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,9 @@ export function Nav() {
|
||||||
<>
|
<>
|
||||||
<nav className="topnav">
|
<nav className="topnav">
|
||||||
<Link href="/dashboard" className="brand">
|
<Link href="/dashboard" className="brand">
|
||||||
<span className="brand-mark">MC</span>
|
<span className="brand-mark">VMC</span>
|
||||||
<span className="brand-text">
|
<span className="brand-text">
|
||||||
Media Cluster
|
Vonas Media Cluster
|
||||||
<small>ops console</small>
|
<small>ops console</small>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,18 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "nl.vonas.mediacluster.tv"
|
namespace = "nl.vonas.mediacluster.tv"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "nl.vonas.mediacluster.tv"
|
applicationId = "nl.vonas.mediacluster.tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 5
|
versionCode = 7
|
||||||
versionName = "0.5.0"
|
versionName = "0.7.0"
|
||||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|
@ -53,10 +56,12 @@ dependencies {
|
||||||
implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
|
implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
|
||||||
implementation("androidx.tv:tv-material:1.0.0")
|
implementation("androidx.tv:tv-material:1.0.0")
|
||||||
|
|
||||||
// Zelfde stack als Stremio-achtige clients: MediaCodec + HDMI passthrough op Shield
|
// Media3 + FFmpeg-audio (DTS/TrueHD/E-AC3 → PCM) via nextlib
|
||||||
implementation("androidx.media3:media3-exoplayer:1.5.1")
|
val media3 = "1.10.1"
|
||||||
implementation("androidx.media3:media3-ui:1.5.1")
|
implementation("androidx.media3:media3-exoplayer:$media3")
|
||||||
implementation("androidx.media3:media3-datasource-okhttp:1.5.1")
|
implementation("androidx.media3:media3-ui:$media3")
|
||||||
|
implementation("androidx.media3:media3-datasource-okhttp:$media3")
|
||||||
|
implementation("io.github.anilbeesetti:nextlib-media3ext:1.10.1-0.13.0")
|
||||||
|
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ class ApiClient(
|
||||||
parseObject(execute(req)) { o ->
|
parseObject(execute(req)) { o ->
|
||||||
HomePayload(
|
HomePayload(
|
||||||
continueWatching = o.optJSONArray("continueWatching").toItems(),
|
continueWatching = o.optJSONArray("continueWatching").toItems(),
|
||||||
|
spotlight = o.optJSONArray("spotlight").toItems(),
|
||||||
shelves = o.optJSONArray("shelves").toShelves(),
|
shelves = o.optJSONArray("shelves").toShelves(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -153,6 +154,8 @@ class ApiClient(
|
||||||
ShelfPref(
|
ShelfPref(
|
||||||
shelfId = s.getString("shelfId"),
|
shelfId = s.getString("shelfId"),
|
||||||
name = s.getString("name"),
|
name = s.getString("name"),
|
||||||
|
slug = s.optString("slug"),
|
||||||
|
kind = s.optString("kind"),
|
||||||
visible = s.optBoolean("visible", true),
|
visible = s.optBoolean("visible", true),
|
||||||
sortOrder = s.optInt("sortOrder", i),
|
sortOrder = s.optInt("sortOrder", i),
|
||||||
)
|
)
|
||||||
|
|
@ -248,6 +251,7 @@ data class BrowsePayload(
|
||||||
|
|
||||||
data class HomePayload(
|
data class HomePayload(
|
||||||
val continueWatching: List<MediaItem>,
|
val continueWatching: List<MediaItem>,
|
||||||
|
val spotlight: List<MediaItem> = emptyList(),
|
||||||
val shelves: List<ShelfRow>,
|
val shelves: List<ShelfRow>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -271,6 +275,7 @@ data class MediaItem(
|
||||||
val positionMs: Long = 0,
|
val positionMs: Long = 0,
|
||||||
val durationMs: Long = 0,
|
val durationMs: Long = 0,
|
||||||
val progressId: String? = null,
|
val progressId: String? = null,
|
||||||
|
val badge: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PlayResult(
|
data class PlayResult(
|
||||||
|
|
@ -282,6 +287,8 @@ data class PlayResult(
|
||||||
data class ShelfPref(
|
data class ShelfPref(
|
||||||
val shelfId: String,
|
val shelfId: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val slug: String = "",
|
||||||
|
val kind: String = "",
|
||||||
val visible: Boolean,
|
val visible: Boolean,
|
||||||
val sortOrder: Int,
|
val sortOrder: Int,
|
||||||
)
|
)
|
||||||
|
|
@ -304,6 +311,7 @@ private fun JSONArray?.toItems(): List<MediaItem> {
|
||||||
positionMs = o.optLong("positionMs"),
|
positionMs = o.optLong("positionMs"),
|
||||||
durationMs = o.optLong("durationMs"),
|
durationMs = o.optLong("durationMs"),
|
||||||
progressId = o.optString("progressId").ifBlank { null },
|
progressId = o.optString("progressId").ifBlank { null },
|
||||||
|
badge = o.optString("badge").ifBlank { null },
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,97 @@
|
||||||
package nl.vonas.mediacluster.tv.playback
|
package nl.vonas.mediacluster.tv.playback
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
import androidx.media3.common.TrackSelectionOverride
|
import androidx.media3.common.TrackSelectionOverride
|
||||||
import androidx.media3.common.Tracks
|
import androidx.media3.common.Tracks
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.Renderer
|
||||||
|
import androidx.media3.exoplayer.audio.AudioRendererEventListener
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink
|
||||||
|
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
|
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||||
|
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hardware-video + FFmpeg-audio (prefer).
|
||||||
|
* DTS/TrueHD/E-AC3 falen vaak stil bij HDMI-passthrough naar een TV zonder decoder;
|
||||||
|
* FFmpeg decodeert naar PCM zodat geluid altijd via de TV/speakers werkt.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
private class McRenderersFactory(context: Context) : NextRenderersFactory(context) {
|
||||||
|
init {
|
||||||
|
setEnableDecoderFallback(true)
|
||||||
|
setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun buildAudioRenderers(
|
||||||
|
context: Context,
|
||||||
|
extensionRendererMode: Int,
|
||||||
|
mediaCodecSelector: MediaCodecSelector,
|
||||||
|
enableDecoderFallback: Boolean,
|
||||||
|
audioSink: AudioSink,
|
||||||
|
eventHandler: Handler,
|
||||||
|
eventListener: AudioRendererEventListener,
|
||||||
|
out: ArrayList<Renderer>,
|
||||||
|
) {
|
||||||
|
super.buildAudioRenderers(
|
||||||
|
context,
|
||||||
|
EXTENSION_RENDERER_MODE_PREFER,
|
||||||
|
mediaCodecSelector,
|
||||||
|
enableDecoderFallback,
|
||||||
|
audioSink,
|
||||||
|
eventHandler,
|
||||||
|
eventListener,
|
||||||
|
out,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun buildVideoRenderers(
|
||||||
|
context: Context,
|
||||||
|
extensionRendererMode: Int,
|
||||||
|
mediaCodecSelector: MediaCodecSelector,
|
||||||
|
enableDecoderFallback: Boolean,
|
||||||
|
eventHandler: Handler,
|
||||||
|
eventListener: VideoRendererEventListener,
|
||||||
|
allowedVideoJoiningTimeMs: Long,
|
||||||
|
out: ArrayList<Renderer>,
|
||||||
|
) {
|
||||||
|
// Shield HEVC/AV1: altijd MediaCodec, nooit software-video
|
||||||
|
super.buildVideoRenderers(
|
||||||
|
context,
|
||||||
|
EXTENSION_RENDERER_MODE_OFF,
|
||||||
|
mediaCodecSelector,
|
||||||
|
enableDecoderFallback,
|
||||||
|
eventHandler,
|
||||||
|
eventListener,
|
||||||
|
allowedVideoJoiningTimeMs,
|
||||||
|
out,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
fun buildTvPlayer(context: Context): ExoPlayer {
|
fun buildTvPlayer(context: Context): ExoPlayer {
|
||||||
val trackSelector = DefaultTrackSelector(context).apply {
|
val trackSelector = DefaultTrackSelector(context).apply {
|
||||||
parameters = buildUponParameters()
|
parameters = buildUponParameters()
|
||||||
// Laat DTS/TrueHD door (Shield HDMI passthrough) — niet filteren als "unsupported"
|
|
||||||
.setExceedRendererCapabilitiesIfNecessary(true)
|
.setExceedRendererCapabilitiesIfNecessary(true)
|
||||||
.setAllowAudioMixedMimeTypeAdaptiveness(true)
|
.setAllowAudioMixedMimeTypeAdaptiveness(true)
|
||||||
|
.setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
|
||||||
.setPreferredAudioLanguage("nl")
|
.setPreferredAudioLanguage("nl")
|
||||||
|
.setPreferredTextLanguage("nl")
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
val renderersFactory = DefaultRenderersFactory(context.applicationContext)
|
val renderersFactory = McRenderersFactory(context.applicationContext)
|
||||||
.setEnableDecoderFallback(true)
|
|
||||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF)
|
|
||||||
|
|
||||||
val audioAttributes = AudioAttributes.Builder()
|
val audioAttributes = AudioAttributes.Builder()
|
||||||
.setUsage(C.USAGE_MEDIA)
|
.setUsage(C.USAGE_MEDIA)
|
||||||
|
|
@ -65,6 +134,21 @@ fun ExoPlayer.listTracks(type: Int): List<PlayerTrack> {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kies een bruikbaar audiospoor als er nog niets speelt (commentary overslaan). */
|
||||||
|
fun ExoPlayer.ensureAudioSelected() {
|
||||||
|
if (currentTracks.hasAudioSelected()) {
|
||||||
|
volume = 1f
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val tracks = listTracks(C.TRACK_TYPE_AUDIO)
|
||||||
|
if (tracks.isEmpty()) return
|
||||||
|
val preferred = tracks.firstOrNull { t ->
|
||||||
|
val l = t.label.lowercase()
|
||||||
|
!l.contains("comment") && !l.contains("description") && !l.contains("ad ")
|
||||||
|
} ?: tracks.first()
|
||||||
|
selectTrack(preferred)
|
||||||
|
}
|
||||||
|
|
||||||
fun ExoPlayer.selectTrack(track: PlayerTrack) {
|
fun ExoPlayer.selectTrack(track: PlayerTrack) {
|
||||||
val group = currentTracks.groups.getOrNull(track.groupIndex) ?: return
|
val group = currentTracks.groups.getOrNull(track.groupIndex) ?: return
|
||||||
trackSelectionParameters = trackSelectionParameters
|
trackSelectionParameters = trackSelectionParameters
|
||||||
|
|
@ -89,6 +173,7 @@ fun ExoPlayer.disableTrackType(type: Int) {
|
||||||
private fun shortMime(mime: String): String = when {
|
private fun shortMime(mime: String): String = when {
|
||||||
MimeTypes.AUDIO_E_AC3 == mime || mime.contains("eac3") || mime.contains("ec-3") -> "E-AC3"
|
MimeTypes.AUDIO_E_AC3 == mime || mime.contains("eac3") || mime.contains("ec-3") -> "E-AC3"
|
||||||
MimeTypes.AUDIO_AC3 == mime || mime.contains("ac3") -> "AC3"
|
MimeTypes.AUDIO_AC3 == mime || mime.contains("ac3") -> "AC3"
|
||||||
|
MimeTypes.AUDIO_DTS_HD == mime || mime.contains("dts.hd") || mime.contains("dts-hd") -> "DTS-HD"
|
||||||
MimeTypes.AUDIO_DTS == mime || mime.contains("dts") -> "DTS"
|
MimeTypes.AUDIO_DTS == mime || mime.contains("dts") -> "DTS"
|
||||||
mime.contains("truehd") || mime.contains("mlp") -> "TrueHD"
|
mime.contains("truehd") || mime.contains("mlp") -> "TrueHD"
|
||||||
MimeTypes.AUDIO_AAC == mime || mime.contains("mp4a") -> "AAC"
|
MimeTypes.AUDIO_AAC == mime || mime.contains("mp4a") -> "AAC"
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -61,6 +62,7 @@ import nl.vonas.mediacluster.tv.data.ShelfPref
|
||||||
import nl.vonas.mediacluster.tv.ui.components.MainTab
|
import nl.vonas.mediacluster.tv.ui.components.MainTab
|
||||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||||
import nl.vonas.mediacluster.tv.ui.components.PosterCard
|
import nl.vonas.mediacluster.tv.ui.components.PosterCard
|
||||||
|
import nl.vonas.mediacluster.tv.ui.components.SubNavItem
|
||||||
import nl.vonas.mediacluster.tv.ui.components.TopNavBar
|
import nl.vonas.mediacluster.tv.ui.components.TopNavBar
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
|
|
||||||
|
|
@ -191,8 +193,47 @@ private fun MainHub(
|
||||||
var tab by remember { mutableStateOf(MainTab.Home) }
|
var tab by remember { mutableStateOf(MainTab.Home) }
|
||||||
var navFocused by remember { mutableStateOf(false) }
|
var navFocused by remember { mutableStateOf(false) }
|
||||||
val navFocus = remember { FocusRequester() }
|
val navFocus = remember { FocusRequester() }
|
||||||
|
var shelfPrefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
|
||||||
|
var movieCatalog by remember { mutableStateOf("movies") }
|
||||||
|
var seriesCatalog by remember { mutableStateOf("series") }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
runCatching { shelfPrefs = api.getShelfPrefs().sortedBy { it.sortOrder } }
|
||||||
|
}
|
||||||
|
|
||||||
|
val movieSubs = remember(shelfPrefs) {
|
||||||
|
buildList {
|
||||||
|
add(SubNavItem("movies", "Alles"))
|
||||||
|
add(SubNavItem("recent-movies", "Recent"))
|
||||||
|
add(SubNavItem("4k-movies", "4K"))
|
||||||
|
shelfPrefs.filter { it.kind.equals("MOVIE", true) && it.visible }.forEach { p ->
|
||||||
|
add(SubNavItem(p.slug.ifBlank { p.shelfId }, p.name))
|
||||||
|
}
|
||||||
|
add(SubNavItem("__search__", "Zoeken"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val seriesSubs = remember(shelfPrefs) {
|
||||||
|
buildList {
|
||||||
|
add(SubNavItem("series", "Alles"))
|
||||||
|
add(SubNavItem("recent-series", "Recent"))
|
||||||
|
shelfPrefs.filter { it.kind.equals("SERIES", true) && it.visible }.forEach { p ->
|
||||||
|
add(SubNavItem(p.slug.ifBlank { p.shelfId }, p.name))
|
||||||
|
}
|
||||||
|
add(SubNavItem("__search__", "Zoeken"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val subItems = when (tab) {
|
||||||
|
MainTab.Movies -> movieSubs
|
||||||
|
MainTab.Series -> seriesSubs
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
val selectedSubId = when (tab) {
|
||||||
|
MainTab.Movies -> movieCatalog
|
||||||
|
MainTab.Series -> seriesCatalog
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
// Netflix-achtig: Back brengt je naar de top-nav
|
|
||||||
BackHandler(enabled = !navFocused && tab != MainTab.Home) {
|
BackHandler(enabled = !navFocused && tab != MainTab.Home) {
|
||||||
tab = MainTab.Home
|
tab = MainTab.Home
|
||||||
navFocused = true
|
navFocused = true
|
||||||
|
|
@ -216,6 +257,25 @@ private fun MainHub(
|
||||||
onSelect = {
|
onSelect = {
|
||||||
tab = it
|
tab = it
|
||||||
navFocused = true
|
navFocused = true
|
||||||
|
when (it) {
|
||||||
|
MainTab.Movies -> if (movieCatalog == "__search__") movieCatalog = "movies"
|
||||||
|
MainTab.Series -> if (seriesCatalog == "__search__") seriesCatalog = "series"
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subItems = subItems,
|
||||||
|
selectedSubId = selectedSubId,
|
||||||
|
onSelectSub = { item ->
|
||||||
|
if (item.id == "__search__") {
|
||||||
|
tab = MainTab.Search
|
||||||
|
navFocused = true
|
||||||
|
} else {
|
||||||
|
when (tab) {
|
||||||
|
MainTab.Movies -> movieCatalog = item.id
|
||||||
|
MainTab.Series -> seriesCatalog = item.id
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -229,15 +289,15 @@ private fun MainHub(
|
||||||
)
|
)
|
||||||
MainTab.Movies -> BrowseScreen(
|
MainTab.Movies -> BrowseScreen(
|
||||||
api = api,
|
api = api,
|
||||||
catalogId = "movies",
|
catalogId = movieCatalog,
|
||||||
titleHint = "Films",
|
titleHint = movieSubs.firstOrNull { it.id == movieCatalog }?.label ?: "Films",
|
||||||
onOpenMovie = onOpenMovie,
|
onOpenMovie = onOpenMovie,
|
||||||
onOpenSeries = onOpenSeries,
|
onOpenSeries = onOpenSeries,
|
||||||
)
|
)
|
||||||
MainTab.Series -> BrowseScreen(
|
MainTab.Series -> BrowseScreen(
|
||||||
api = api,
|
api = api,
|
||||||
catalogId = "series",
|
catalogId = seriesCatalog,
|
||||||
titleHint = "Series",
|
titleHint = seriesSubs.firstOrNull { it.id == seriesCatalog }?.label ?: "Series",
|
||||||
onOpenMovie = onOpenMovie,
|
onOpenMovie = onOpenMovie,
|
||||||
onOpenSeries = onOpenSeries,
|
onOpenSeries = onOpenSeries,
|
||||||
)
|
)
|
||||||
|
|
@ -260,9 +320,11 @@ private fun MainHub(
|
||||||
private fun Splash() {
|
private fun Splash() {
|
||||||
Box(Modifier.fillMaxSize().background(McColors.Bg), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize().background(McColors.Bg), contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp)
|
Text("VMC", color = McColors.Accent, fontSize = 42.sp, fontWeight = FontWeight.Black, letterSpacing = 2.sp)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text("Laden…", color = McColors.TextDim, fontSize = 16.sp)
|
Text("Vonas Media Cluster", color = McColors.TextDim, fontSize = 14.sp, letterSpacing = 1.sp)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text("Laden…", color = McColors.TextDim, fontSize = 15.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -326,7 +388,7 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit
|
||||||
.background(McColors.BgElevated.copy(alpha = 0.92f))
|
.background(McColors.BgElevated.copy(alpha = 0.92f))
|
||||||
.padding(40.dp),
|
.padding(40.dp),
|
||||||
) {
|
) {
|
||||||
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
|
Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp)
|
||||||
Text("Koppel dit apparaat", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
Text("Koppel dit apparaat", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
||||||
Text("Open de link op je telefoon, log in en voer de code in.", color = McColors.TextDim, fontSize = 15.sp)
|
Text("Open de link op je telefoon, log in en voer de code in.", color = McColors.TextDim, fontSize = 15.sp)
|
||||||
Text(code ?: "····-····", color = McColors.Accent, fontSize = 52.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
|
Text(code ?: "····-····", color = McColors.Accent, fontSize = 52.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
|
||||||
|
|
@ -357,35 +419,34 @@ private fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val hero = home?.continueWatching?.firstOrNull()
|
val spotlight = home?.spotlight.orEmpty().ifEmpty {
|
||||||
?: home?.shelves?.firstOrNull()?.items?.firstOrNull()
|
home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 48.dp),
|
contentPadding = PaddingValues(bottom = 56.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(26.dp),
|
verticalArrangement = Arrangement.spacedBy(28.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
HeroHeader(
|
SpotlightBillboard(
|
||||||
hero = hero,
|
items = spotlight,
|
||||||
onPlayHero = {
|
onPlay = { item ->
|
||||||
val h = hero ?: return@HeroHeader
|
|
||||||
when {
|
when {
|
||||||
!h.mediaFileId.isNullOrBlank() -> onPlay(
|
!item.mediaFileId.isNullOrBlank() -> onPlay(
|
||||||
h.mediaFileId!!,
|
item.mediaFileId!!,
|
||||||
h.id.takeIf { h.type == "movie" },
|
item.id.takeIf { item.type == "movie" },
|
||||||
h.id.takeIf { h.type == "episode" },
|
item.id.takeIf { item.type == "episode" },
|
||||||
null,
|
null,
|
||||||
if (h.type == "episode") "EPISODE" else "MOVIE",
|
if (item.type == "episode") "EPISODE" else "MOVIE",
|
||||||
h.positionMs,
|
item.positionMs,
|
||||||
)
|
)
|
||||||
h.type == "movie" -> onOpenMovie(h.id)
|
item.type == "movie" -> onOpenMovie(item.id)
|
||||||
else -> onOpenSeries(h.id)
|
else -> onOpenSeries(item.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOpenHero = {
|
onOpen = { item ->
|
||||||
val h = hero ?: return@HeroHeader
|
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
|
||||||
if (h.type == "movie") onOpenMovie(h.id) else onOpenSeries(h.id)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -435,48 +496,134 @@ private fun HomeScreen(
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun HeroHeader(
|
private fun SpotlightBillboard(
|
||||||
hero: MediaItem?,
|
items: List<MediaItem>,
|
||||||
onPlayHero: () -> Unit,
|
onPlay: (MediaItem) -> Unit,
|
||||||
onOpenHero: () -> Unit,
|
onOpen: (MediaItem) -> Unit,
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxWidth().height(420.dp)) {
|
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.fillMaxWidth().height(480.dp)) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = hero?.backdropUrl ?: hero?.posterUrl,
|
model = current.backdropUrl ?: current.posterUrl,
|
||||||
contentDescription = null,
|
contentDescription = current.title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
Box(Modifier.fillMaxSize().background(McColors.HeroScrim))
|
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.horizontalGradient(listOf(McColors.Bg, Color.Transparent, Color.Transparent)),
|
Brush.verticalGradient(
|
||||||
|
listOf(Color(0x33000000), Color(0x66000000), Color(0xF00B0B0B)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
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(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth(0.58f)
|
.fillMaxWidth(0.55f)
|
||||||
.padding(start = 40.dp, end = 24.dp, bottom = 36.dp),
|
.padding(start = 48.dp, end = 24.dp, bottom = 36.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 13.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp)
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
hero?.title ?: "Welkom terug",
|
"VMC",
|
||||||
|
color = McColors.Brand,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Black,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
)
|
||||||
|
if (!current.badge.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
current.badge!!.uppercase(),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 42.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(McColors.Brand)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
current.title,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 46.sp,
|
||||||
|
fontWeight = FontWeight.Black,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
lineHeight = 46.sp,
|
lineHeight = 50.sp,
|
||||||
)
|
)
|
||||||
if (!hero?.subtitle.isNullOrBlank()) {
|
val meta = listOfNotNull(
|
||||||
Text(hero!!.subtitle!!, color = McColors.TextDim, fontSize = 16.sp)
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
if (hero != null) {
|
if (current.id.isNotBlank()) {
|
||||||
McButton(if (hero.positionMs > 30_000) "Verder kijken" else "Afspelen", primary = true, onClick = onPlayHero)
|
McButton("▶ Afspelen", primary = true, onClick = { onPlay(current) })
|
||||||
McButton("Details", onClick = onOpenHero)
|
McButton("Meer info", onClick = { onOpen(current) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -491,26 +638,25 @@ private fun ShelfBlock(
|
||||||
onSeeAll: (() -> Unit)?,
|
onSeeAll: (() -> Unit)?,
|
||||||
onClick: (MediaItem) -> Unit,
|
onClick: (MediaItem) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(start = 40.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.padding(start = 48.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(end = 40.dp),
|
Modifier.fillMaxWidth().padding(end = 48.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
title.uppercase(),
|
title,
|
||||||
color = Color(0xFFE8EEF8),
|
color = Color(0xFFE5E5E5),
|
||||||
fontSize = 17.sp,
|
fontSize = 20.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
letterSpacing = 1.1.sp,
|
|
||||||
)
|
)
|
||||||
if (onSeeAll != null) {
|
if (onSeeAll != null) {
|
||||||
McButton("Alles bekijken") { onSeeAll() }
|
McButton("Alles") { onSeeAll() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LazyRow(
|
LazyRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
contentPadding = PaddingValues(end = 40.dp),
|
contentPadding = PaddingValues(end = 48.dp),
|
||||||
) {
|
) {
|
||||||
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
||||||
PosterCard(item, onClick = { onClick(item) })
|
PosterCard(item, onClick = { onClick(item) })
|
||||||
|
|
@ -698,7 +844,7 @@ private fun SettingsScreen(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Text("Instellingen", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
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)
|
Text("Planken op home: aan/uit en volgorde.", color = McColors.TextDim, fontSize = 15.sp)
|
||||||
if (error != null) Text(error!!, color = McColors.Danger)
|
if (error != null) Text(error!!, color = McColors.Danger)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ package nl.vonas.mediacluster.tv.ui
|
||||||
import android.view.ViewGroup.LayoutParams
|
import android.view.ViewGroup.LayoutParams
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -14,9 +17,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
|
@ -49,7 +55,9 @@ import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
@ -57,6 +65,7 @@ import nl.vonas.mediacluster.tv.data.ApiClient
|
||||||
import nl.vonas.mediacluster.tv.playback.PlayerTrack
|
import nl.vonas.mediacluster.tv.playback.PlayerTrack
|
||||||
import nl.vonas.mediacluster.tv.playback.buildTvPlayer
|
import nl.vonas.mediacluster.tv.playback.buildTvPlayer
|
||||||
import nl.vonas.mediacluster.tv.playback.disableTrackType
|
import nl.vonas.mediacluster.tv.playback.disableTrackType
|
||||||
|
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
|
||||||
import nl.vonas.mediacluster.tv.playback.listTracks
|
import nl.vonas.mediacluster.tv.playback.listTracks
|
||||||
import nl.vonas.mediacluster.tv.playback.selectTrack
|
import nl.vonas.mediacluster.tv.playback.selectTrack
|
||||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||||
|
|
@ -77,6 +86,7 @@ fun PlayerScreen(
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val player = remember { buildTvPlayer(context) }
|
val player = remember { buildTvPlayer(context) }
|
||||||
val controlsFocus = remember { FocusRequester() }
|
val controlsFocus = remember { FocusRequester() }
|
||||||
|
val catcherFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
|
@ -88,13 +98,24 @@ fun PlayerScreen(
|
||||||
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
|
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
|
||||||
var menu by remember { mutableStateOf<String?>(null) } // audio | text
|
var menu by remember { mutableStateOf<String?>(null) } // audio | text
|
||||||
|
|
||||||
|
fun showControls() {
|
||||||
|
controls = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideControls() {
|
||||||
|
if (menu == null) controls = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePlay() {
|
||||||
|
if (player.isPlaying) player.pause() else player.play()
|
||||||
|
showControls()
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler {
|
BackHandler {
|
||||||
when {
|
when {
|
||||||
menu != null -> menu = null
|
menu != null -> menu = null
|
||||||
controls -> onBack()
|
controls -> onBack()
|
||||||
else -> {
|
else -> showControls()
|
||||||
controls = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,6 +139,7 @@ fun PlayerScreen(
|
||||||
loading = state == Player.STATE_BUFFERING || state == Player.STATE_IDLE
|
loading = state == Player.STATE_BUFFERING || state == Player.STATE_IDLE
|
||||||
if (state == Player.STATE_READY) {
|
if (state == Player.STATE_READY) {
|
||||||
player.volume = 1f
|
player.volume = 1f
|
||||||
|
player.ensureAudioSelected()
|
||||||
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
|
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
|
||||||
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
|
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
|
||||||
}
|
}
|
||||||
|
|
@ -130,6 +152,7 @@ fun PlayerScreen(
|
||||||
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
|
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
|
||||||
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
|
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO)
|
||||||
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
|
textTracks = player.listTracks(C.TRACK_TYPE_TEXT)
|
||||||
|
player.ensureAudioSelected()
|
||||||
player.volume = 1f
|
player.volume = 1f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,22 +209,21 @@ fun PlayerScreen(
|
||||||
|
|
||||||
LaunchedEffect(controls, menu, playing) {
|
LaunchedEffect(controls, menu, playing) {
|
||||||
if (controls && menu == null && playing) {
|
if (controls && menu == null && playing) {
|
||||||
delay(5_000)
|
delay(4_500)
|
||||||
controls = false
|
hideControls()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(controls) {
|
LaunchedEffect(controls, menu) {
|
||||||
|
delay(40)
|
||||||
|
if (menu != null) return@LaunchedEffect
|
||||||
if (controls) {
|
if (controls) {
|
||||||
delay(50)
|
|
||||||
runCatching { controlsFocus.requestFocus() }
|
runCatching { controlsFocus.requestFocus() }
|
||||||
|
} else {
|
||||||
|
runCatching { catcherFocus.requestFocus() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showControls() {
|
|
||||||
controls = true
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -210,12 +232,22 @@ fun PlayerScreen(
|
||||||
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
when (ev.key) {
|
when (ev.key) {
|
||||||
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
|
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
|
||||||
|
if (menu != null) return@onPreviewKeyEvent false
|
||||||
|
if (!controls) {
|
||||||
|
// Netflix: OK toont bediening én pauzeert/hervat
|
||||||
|
togglePlay()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Key.DirectionDown -> {
|
||||||
if (menu != null) return@onPreviewKeyEvent false
|
if (menu != null) return@onPreviewKeyEvent false
|
||||||
if (!controls) {
|
if (!controls) {
|
||||||
showControls()
|
showControls()
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
false // laat knoppen de OK afhandelen
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Key.DirectionLeft -> {
|
Key.DirectionLeft -> {
|
||||||
|
|
@ -230,9 +262,10 @@ fun PlayerScreen(
|
||||||
showControls()
|
showControls()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Key.DirectionUp, Key.DirectionDown, Key.Menu -> {
|
Key.DirectionUp, Key.Menu -> {
|
||||||
|
if (menu != null) return@onPreviewKeyEvent false
|
||||||
showControls()
|
showControls()
|
||||||
false
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
@ -243,18 +276,29 @@ fun PlayerScreen(
|
||||||
PlayerView(ctx).apply {
|
PlayerView(ctx).apply {
|
||||||
layoutParams = FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
layoutParams = FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||||
this.player = player
|
this.player = player
|
||||||
useController = false // eigen Compose-controls (D-pad focus)
|
useController = false
|
||||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING)
|
setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING)
|
||||||
keepScreenOn = true
|
keepScreenOn = true
|
||||||
isFocusable = false
|
isFocusable = false
|
||||||
isFocusableInTouchMode = false
|
isFocusableInTouchMode = false
|
||||||
|
descendantFocusability = FrameLayout.FOCUS_BLOCK_DESCENDANTS
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { it.player = player },
|
update = { it.player = player },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Houd altijd focus wanneer controls weg zijn, anders bereiken D-pad/OK Compose niet
|
||||||
|
if (!controls && menu == null && error == null) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusRequester(catcherFocus)
|
||||||
|
.focusable(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (loading && error == null) {
|
if (loading && error == null) {
|
||||||
Text("Laden…", color = Color(0xFFB0B8C8), fontSize = 18.sp, modifier = Modifier.align(Alignment.Center))
|
Text("Laden…", color = Color(0xFFB0B8C8), fontSize = 18.sp, modifier = Modifier.align(Alignment.Center))
|
||||||
}
|
}
|
||||||
|
|
@ -269,66 +313,110 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (controls && error == null) {
|
AnimatedVisibility(
|
||||||
|
visible = controls && error == null,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
// Top / bottom scrim zoals Netflix
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
listOf(Color(0x99000000), Color.Transparent, Color(0xF2000000)),
|
listOf(
|
||||||
|
Color(0xCC000000),
|
||||||
|
Color.Transparent,
|
||||||
|
Color.Transparent,
|
||||||
|
Color(0xF2000000),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.focusable(),
|
|
||||||
|
// Terug linksboven
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.padding(28.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
NetflixTextButton("← Terug", onClick = onBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grote play/pause in het midden als gepauzeerd
|
||||||
|
if (!playing) {
|
||||||
|
Surface(
|
||||||
|
onClick = { togglePlay() },
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = Color(0x55FFFFFF),
|
||||||
|
focusedContainerColor = Color.White,
|
||||||
|
contentColor = Color.White,
|
||||||
|
focusedContentColor = Color.Black,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(88.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text("▶", color = Color.Unspecified, fontSize = 36.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 36.dp, vertical = 28.dp),
|
.padding(horizontal = 48.dp, vertical = 28.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(5.dp)
|
.height(3.dp)
|
||||||
.clip(RoundedCornerShape(3.dp))
|
.clip(RoundedCornerShape(2.dp))
|
||||||
.background(Color(0x44FFFFFF)),
|
.background(Color(0x44FFFFFF)),
|
||||||
) {
|
) {
|
||||||
val pct = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
|
val pct = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth(pct)
|
.fillMaxWidth(pct)
|
||||||
.height(5.dp)
|
.height(3.dp)
|
||||||
.background(McColors.Accent),
|
.background(McColors.Brand),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
"${formatTime(positionMs)} / ${formatTime(durationMs)}",
|
|
||||||
color = Color(0xFFC5CDDB),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
)
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.focusRequester(controlsFocus),
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
McButton("Terug", onClick = onBack)
|
Text(formatTime(positionMs), color = Color(0xFFB3B3B3), fontSize = 13.sp)
|
||||||
McButton(if (playing) "Pauze" else "Play", primary = true) {
|
Text(formatTime(durationMs), color = Color(0xFFB3B3B3), fontSize = 13.sp)
|
||||||
if (player.isPlaying) player.pause() else player.play()
|
|
||||||
showControls()
|
|
||||||
}
|
}
|
||||||
McButton("-10s") {
|
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(controlsFocus),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
NetflixIconButton(if (playing) "❚❚" else "▶", primary = true) { togglePlay() }
|
||||||
|
NetflixIconButton("↺ 10") {
|
||||||
player.seekBack()
|
player.seekBack()
|
||||||
showControls()
|
showControls()
|
||||||
}
|
}
|
||||||
McButton("+30s") {
|
NetflixIconButton("10 ↻") {
|
||||||
player.seekForward()
|
player.seekForward()
|
||||||
showControls()
|
showControls()
|
||||||
}
|
}
|
||||||
McButton("Audio (${audioTracks.size})") {
|
Spacer(Modifier.weight(1f))
|
||||||
|
NetflixTextButton("Audio") {
|
||||||
menu = "audio"
|
menu = "audio"
|
||||||
showControls()
|
showControls()
|
||||||
}
|
}
|
||||||
McButton("Ondertitel") {
|
NetflixTextButton("Ondertiteling") {
|
||||||
menu = "text"
|
menu = "text"
|
||||||
showControls()
|
showControls()
|
||||||
}
|
}
|
||||||
|
|
@ -343,50 +431,134 @@ fun PlayerScreen(
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xDD05070D)),
|
.background(Color(0x99000000)),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.CenterEnd,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.width(520.dp)
|
.fillMaxHeight()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.width(400.dp)
|
||||||
.background(Color(0xFF121A2A))
|
.background(Color(0xF20A0A0A))
|
||||||
.padding(22.dp),
|
.padding(horizontal = 28.dp, vertical = 32.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (isAudio) "Audiospoor kiezen" else "Ondertitel kiezen",
|
if (isAudio) "Audio" else "Ondertiteling",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 22.sp,
|
fontSize = 22.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
if (!isAudio) {
|
if (!isAudio) {
|
||||||
McButton("Ondertitel uit") {
|
NetflixTrackRow("Uit", selected = false) {
|
||||||
player.disableTrackType(C.TRACK_TYPE_TEXT)
|
player.disableTrackType(C.TRACK_TYPE_TEXT)
|
||||||
menu = null
|
menu = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (tracks.isEmpty()) {
|
if (tracks.isEmpty()) {
|
||||||
Text("Geen sporen gevonden", color = Color(0xFF9AA6BD))
|
Text("Geen sporen", color = Color(0xFF8A8A8A), fontSize = 14.sp)
|
||||||
}
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.height(300.dp),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
items(tracks, key = { "${it.groupIndex}-${it.trackIndex}" }) { track ->
|
items(tracks, key = { "${it.groupIndex}-${it.trackIndex}" }) { track ->
|
||||||
McButton(track.label) {
|
NetflixTrackRow(track.label, selected = false) {
|
||||||
player.selectTrack(track)
|
player.selectTrack(track)
|
||||||
menu = null
|
menu = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
McButton("Sluiten") { menu = null }
|
Spacer(Modifier.height(8.dp))
|
||||||
|
NetflixTextButton("Sluiten") { menu = null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun NetflixIconButton(
|
||||||
|
label: String,
|
||||||
|
primary: Boolean = false,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.15f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = if (primary) Color.White else Color(0x22FFFFFF),
|
||||||
|
focusedContainerColor = Color.White,
|
||||||
|
contentColor = if (primary) Color.Black else Color.White,
|
||||||
|
focusedContentColor = Color.Black,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(52.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(label, color = Color.Unspecified, fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun NetflixTextButton(label: String, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color(0x33FFFFFF),
|
||||||
|
contentColor = Color(0xFFE5E5E5),
|
||||||
|
focusedContentColor = Color.White,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
|
color = Color.Unspecified,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun NetflixTrackRow(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = if (selected) Color(0x22FFFFFF) else Color.Transparent,
|
||||||
|
focusedContainerColor = Color(0x33FFFFFF),
|
||||||
|
contentColor = Color.White,
|
||||||
|
focusedContentColor = Color.White,
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (selected) "✓ " else " ",
|
||||||
|
color = McColors.Brand,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
)
|
||||||
|
Text(label, color = Color.Unspecified, fontSize = 15.sp, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun formatTime(ms: Long): String {
|
private fun formatTime(ms: Long): String {
|
||||||
if (ms <= 0) return "0:00"
|
if (ms <= 0) return "0:00"
|
||||||
val total = ms / 1000
|
val total = ms / 1000
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ package nl.vonas.mediacluster.tv.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
|
@ -14,7 +12,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -22,6 +19,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.Border
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Glow
|
import androidx.tv.material3.Glow
|
||||||
|
|
@ -39,28 +37,29 @@ fun McButton(
|
||||||
primary: Boolean = false,
|
primary: Boolean = false,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val bg = if (primary) McColors.Accent else Color(0xFF1A2336)
|
val bg = if (primary) Color.White else Color(0x33FFFFFF)
|
||||||
val bgFocus = if (primary) Color(0xFF8BB8FF) else Color(0xFF2A3A58)
|
val bgFocus = if (primary) Color.White else Color.White
|
||||||
val fg = if (primary) Color(0xFF061018) else Color(0xFFF5F7FB)
|
val fg = if (primary) Color.Black else Color.White
|
||||||
|
val fgFocus = Color.Black
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
||||||
glow = ClickableSurfaceDefaults.glow(
|
glow = ClickableSurfaceDefaults.glow(
|
||||||
focusedGlow = Glow(elevationColor = McColors.Accent.copy(alpha = 0.7f), elevation = 10.dp),
|
focusedGlow = Glow(elevationColor = Color.White.copy(alpha = 0.25f), elevation = 6.dp),
|
||||||
),
|
),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = bg,
|
containerColor = bg,
|
||||||
focusedContainerColor = bgFocus,
|
focusedContainerColor = bgFocus,
|
||||||
contentColor = fg,
|
contentColor = fg,
|
||||||
focusedContentColor = fg,
|
focusedContentColor = fgFocus,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label.ifBlank { "—" },
|
text = label.ifBlank { "—" },
|
||||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 11.dp),
|
modifier = Modifier.padding(horizontal = 22.dp, vertical = 12.dp),
|
||||||
color = fg,
|
color = Color.Unspecified,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
@ -75,8 +74,8 @@ fun PosterCard(
|
||||||
item: MediaItem,
|
item: MediaItem,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
width: Dp = 152.dp,
|
width: Dp = 140.dp,
|
||||||
height: Dp = 228.dp,
|
height: Dp = 210.dp,
|
||||||
) {
|
) {
|
||||||
val progress = if (item.durationMs > 0) {
|
val progress = if (item.durationMs > 0) {
|
||||||
(item.positionMs.toFloat() / item.durationMs.toFloat()).coerceIn(0f, 1f)
|
(item.positionMs.toFloat() / item.durationMs.toFloat()).coerceIn(0f, 1f)
|
||||||
|
|
@ -87,23 +86,26 @@ fun PosterCard(
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.width(width),
|
modifier = modifier.width(width),
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.12f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.14f),
|
||||||
glow = ClickableSurfaceDefaults.glow(
|
border = ClickableSurfaceDefaults.border(
|
||||||
focusedGlow = Glow(elevationColor = McColors.Accent, elevation = 16.dp),
|
focusedBorder = Border(
|
||||||
|
border = androidx.compose.foundation.BorderStroke(3.dp, Color.White),
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
glow = ClickableSurfaceDefaults.glow(focusedGlow = Glow(elevationColor = Color.Transparent, elevation = 0.dp)),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = Color(0xFF101827),
|
containerColor = Color(0xFF1A1A1A),
|
||||||
focusedContainerColor = Color(0xFF182338),
|
focusedContainerColor = Color(0xFF1A1A1A),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Column {
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(height)
|
.height(height)
|
||||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(Color(0xFF0B1220)),
|
.background(Color(0xFF1A1A1A)),
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = item.posterUrl ?: item.backdropUrl,
|
model = item.posterUrl ?: item.backdropUrl,
|
||||||
|
|
@ -111,17 +113,6 @@ fun PosterCard(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(56.dp)
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
listOf(Color.Transparent, Color(0xCC000000)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (progress > 0.02f) {
|
if (progress > 0.02f) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -139,35 +130,5 @@ fun PosterCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
item.title,
|
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
|
||||||
color = Color(0xFFF5F7FB),
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
lineHeight = 16.sp,
|
|
||||||
)
|
|
||||||
if (!item.subtitle.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
item.subtitle,
|
|
||||||
modifier = Modifier.padding(horizontal = 10.dp).padding(bottom = 10.dp),
|
|
||||||
color = Color(0xFF9AA6BD),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
} else if (item.year != null) {
|
|
||||||
Text(
|
|
||||||
item.year.toString(),
|
|
||||||
modifier = Modifier.padding(horizontal = 10.dp).padding(bottom = 10.dp),
|
|
||||||
color = Color(0xFF9AA6BD),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,32 @@
|
||||||
package nl.vonas.mediacluster.tv.ui.components
|
package nl.vonas.mediacluster.tv.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||||
|
|
||||||
|
|
@ -23,33 +38,105 @@ enum class MainTab(val label: String) {
|
||||||
Settings("Instellingen"),
|
Settings("Instellingen"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class SubNavItem(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TopNavBar(
|
fun TopNavBar(
|
||||||
selected: MainTab,
|
selected: MainTab,
|
||||||
onSelect: (MainTab) -> Unit,
|
onSelect: (MainTab) -> Unit,
|
||||||
|
subItems: List<SubNavItem> = emptyList(),
|
||||||
|
selectedSubId: String? = null,
|
||||||
|
onSelectSub: (SubNavItem) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
Column(modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 28.dp, vertical = 14.dp),
|
.padding(horizontal = 40.dp, vertical = 14.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"MC",
|
"VMC",
|
||||||
color = McColors.Accent,
|
color = McColors.Brand,
|
||||||
fontSize = 22.sp,
|
fontSize = 28.sp,
|
||||||
fontWeight = FontWeight.Black,
|
fontWeight = FontWeight.Black,
|
||||||
modifier = Modifier.padding(end = 12.dp),
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
)
|
)
|
||||||
MainTab.entries.forEach { tab ->
|
MainTab.entries.forEach { tab ->
|
||||||
McButton(
|
val active = selected == tab
|
||||||
label = tab.label,
|
val color by animateColorAsState(
|
||||||
primary = selected == tab,
|
if (active) Color.White else Color(0xFF808080),
|
||||||
onClick = { onSelect(tab) },
|
label = "tabColor",
|
||||||
)
|
)
|
||||||
|
Surface(
|
||||||
|
onClick = { onSelect(tab) },
|
||||||
|
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),
|
||||||
|
contentColor = color,
|
||||||
|
focusedContentColor = Color.White,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
tab.label,
|
||||||
|
color = Color.Unspecified,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = if (active) FontWeight.Bold else FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(if (active) 28.dp else 0.dp)
|
||||||
|
.height(3.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(if (active) McColors.Brand else Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subItems.isNotEmpty()) {
|
||||||
|
LazyRow(
|
||||||
|
contentPadding = PaddingValues(horizontal = 40.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||||
|
) {
|
||||||
|
items(subItems, key = { it.id }) { item ->
|
||||||
|
val active = item.id == selectedSubId
|
||||||
|
Surface(
|
||||||
|
onClick = { onSelectSub(item) },
|
||||||
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(20.dp)),
|
||||||
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
||||||
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
|
containerColor = if (active) Color.White else Color(0x22FFFFFF),
|
||||||
|
focusedContainerColor = Color.White,
|
||||||
|
contentColor = if (active) Color.Black else Color(0xFFE0E0E0),
|
||||||
|
focusedContentColor = Color.Black,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
item.label,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
color = Color.Unspecified,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = if (active) FontWeight.Bold else FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
package nl.vonas.mediacluster.tv.ui.theme
|
package nl.vonas.mediacluster.tv.ui.theme
|
||||||
|
|
||||||
object McColors {
|
object McColors {
|
||||||
val Bg = androidx.compose.ui.graphics.Color(0xFF0B0F16)
|
// Netflix-achtige basis: bijna zwart, weinig blauw “dashboard”
|
||||||
val BgElevated = androidx.compose.ui.graphics.Color(0xFF121826)
|
val Bg = androidx.compose.ui.graphics.Color(0xFF0B0B0B)
|
||||||
val Surface = androidx.compose.ui.graphics.Color(0xFF1A2336)
|
val BgElevated = androidx.compose.ui.graphics.Color(0xFF141414)
|
||||||
val SurfaceFocus = androidx.compose.ui.graphics.Color(0xFF2A3A58)
|
val Surface = androidx.compose.ui.graphics.Color(0xFF1F1F1F)
|
||||||
val Accent = androidx.compose.ui.graphics.Color(0xFF5B9DFF)
|
val SurfaceFocus = androidx.compose.ui.graphics.Color(0xFFFFFFFF)
|
||||||
val AccentSoft = androidx.compose.ui.graphics.Color(0x335B9DFF)
|
val Accent = androidx.compose.ui.graphics.Color(0xFFE50914)
|
||||||
val Text = androidx.compose.ui.graphics.Color(0xFFF5F7FB)
|
val AccentSoft = androidx.compose.ui.graphics.Color(0x33E50914)
|
||||||
val TextDim = androidx.compose.ui.graphics.Color(0xFF9AA6BD)
|
val Brand = androidx.compose.ui.graphics.Color(0xFFE50914)
|
||||||
val Danger = androidx.compose.ui.graphics.Color(0xFFFF7B8A)
|
val Text = androidx.compose.ui.graphics.Color(0xFFF5F5F5)
|
||||||
val Success = androidx.compose.ui.graphics.Color(0xFF7DFFB3)
|
val TextDim = androidx.compose.ui.graphics.Color(0xFFB3B3B3)
|
||||||
|
val Danger = androidx.compose.ui.graphics.Color(0xFFFF6B6B)
|
||||||
|
val Success = androidx.compose.ui.graphics.Color(0xFF46D369)
|
||||||
|
|
||||||
val HeroScrim = androidx.compose.ui.graphics.Brush.verticalGradient(
|
val HeroScrim = androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
androidx.compose.ui.graphics.Color.Transparent,
|
androidx.compose.ui.graphics.Color.Transparent,
|
||||||
androidx.compose.ui.graphics.Color(0x990B0F16),
|
androidx.compose.ui.graphics.Color(0x660B0B0B),
|
||||||
androidx.compose.ui.graphics.Color(0xFF0B0F16),
|
androidx.compose.ui.graphics.Color(0xEE0B0B0B),
|
||||||
|
androidx.compose.ui.graphics.Color(0xFF0B0B0B),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Media Cluster</string>
|
<string name="app_name">VMC</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.13.2" apply false
|
id("com.android.application") version "8.13.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.10" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
# Lichter voor ~16GB RAM + Android Studio open
|
# Lichter voor ~16GB RAM + Android Studio open
|
||||||
org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx1024m -XX:MaxMetaspaceSize=384m -Dfile.encoding=UTF-8
|
||||||
org.gradle.parallel=false
|
org.gradle.parallel=false
|
||||||
org.gradle.workers.max=2
|
org.gradle.workers.max=1
|
||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
kotlin.daemon.jvmargs=-Xmx1024m
|
kotlin.daemon.jvmargs=-Xmx768m
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,5 @@ dependencyResolutionManagement {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rootProject.name = "MediaClusterTv"
|
rootProject.name = "VMC"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ async function buildManifest(tokenId: string | null, config: Config) {
|
||||||
return {
|
return {
|
||||||
id: "com.mediacluster.library",
|
id: "com.mediacluster.library",
|
||||||
version: `1.7.${shelfPatch}`,
|
version: `1.7.${shelfPatch}`,
|
||||||
name: "Media Cluster",
|
name: "VMC",
|
||||||
description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG",
|
description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG",
|
||||||
catalogs: [...BASE_CATALOGS, ...shelfCatalogs, ...iptv.catalogs],
|
catalogs: [...BASE_CATALOGS, ...shelfCatalogs, ...iptv.catalogs],
|
||||||
resources: [
|
resources: [
|
||||||
|
|
|
||||||
|
|
@ -289,7 +289,8 @@ export class ViewerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async home(viewerId: string) {
|
async home(viewerId: string) {
|
||||||
const [shelves, prefs, continueItems, recentMovies, recentSeries, fourK] = await Promise.all([
|
const [shelves, prefs, continueItems, recentMovies, recentSeries, fourK, spotlight] =
|
||||||
|
await Promise.all([
|
||||||
prisma.libraryShelf.findMany({
|
prisma.libraryShelf.findMany({
|
||||||
where: { enabled: true },
|
where: { enabled: true },
|
||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
|
|
@ -299,6 +300,7 @@ export class ViewerService {
|
||||||
this.recentMovies(48),
|
this.recentMovies(48),
|
||||||
this.recentSeries(36),
|
this.recentSeries(36),
|
||||||
this.fourKMovies(36),
|
this.fourKMovies(36),
|
||||||
|
this.spotlight(12),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
|
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
|
||||||
|
|
@ -368,51 +370,154 @@ export class ViewerService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
continueWatching: continueItems,
|
continueWatching: continueItems,
|
||||||
|
spotlight,
|
||||||
shelves: rows,
|
shelves: rows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async recentMovies(take: number) {
|
/** Nieuwste titels (op mediabestand-datum) voor de home billboard. */
|
||||||
const movies = await prisma.movie.findMany({
|
async spotlight(take: number) {
|
||||||
where: { mediaFiles: { some: { available: true } } },
|
const [movieFiles, episodeFiles] = await Promise.all([
|
||||||
|
prisma.mediaFile.findMany({
|
||||||
|
where: { available: true, movieId: { not: null } },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take,
|
take: take * 3,
|
||||||
|
include: { movie: true },
|
||||||
|
}),
|
||||||
|
prisma.mediaFile.findMany({
|
||||||
|
where: { available: true, episodeId: { not: null } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: take * 4,
|
||||||
include: {
|
include: {
|
||||||
mediaFiles: {
|
episode: { include: { series: true } },
|
||||||
where: { available: true },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: 1,
|
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
});
|
]);
|
||||||
return movies.map((m) => ({
|
|
||||||
type: "movie" as const,
|
type Spot = {
|
||||||
|
type: "movie" | "series";
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
year: number | null;
|
||||||
|
posterUrl: string | null;
|
||||||
|
backdropUrl: string | null;
|
||||||
|
mediaFileId: string | null;
|
||||||
|
addedAt: number;
|
||||||
|
badge: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const spots: Spot[] = [];
|
||||||
|
const seenMovies = new Set<string>();
|
||||||
|
for (const f of movieFiles) {
|
||||||
|
const m = f.movie;
|
||||||
|
if (!m || !f.movieId || seenMovies.has(f.movieId)) continue;
|
||||||
|
seenMovies.add(f.movieId);
|
||||||
|
spots.push({
|
||||||
|
type: "movie",
|
||||||
id: m.id,
|
id: m.id,
|
||||||
title: m.title,
|
title: m.title,
|
||||||
year: m.year,
|
year: m.year,
|
||||||
posterUrl: m.posterUrl,
|
posterUrl: m.posterUrl,
|
||||||
backdropUrl: m.backdropUrl,
|
backdropUrl: m.backdropUrl,
|
||||||
mediaFileId: m.mediaFiles[0]?.id ?? null,
|
mediaFileId: f.id,
|
||||||
}));
|
addedAt: f.createdAt.getTime(),
|
||||||
|
badge: "Nieuw",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async recentSeries(take: number) {
|
const seenSeries = new Set<string>();
|
||||||
const series = await prisma.series.findMany({
|
for (const f of episodeFiles) {
|
||||||
where: {
|
const s = f.episode?.series;
|
||||||
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
const seriesId = f.episode?.seriesId;
|
||||||
},
|
if (!s || !seriesId || seenSeries.has(seriesId)) continue;
|
||||||
orderBy: { createdAt: "desc" },
|
seenSeries.add(seriesId);
|
||||||
take,
|
spots.push({
|
||||||
});
|
type: "series",
|
||||||
return series.map((s) => ({
|
|
||||||
type: "series" as const,
|
|
||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
year: s.year,
|
year: s.year,
|
||||||
posterUrl: s.posterUrl,
|
posterUrl: s.posterUrl,
|
||||||
backdropUrl: s.backdropUrl,
|
backdropUrl: s.backdropUrl,
|
||||||
mediaFileId: null as string | null,
|
mediaFileId: null,
|
||||||
}));
|
addedAt: f.createdAt.getTime(),
|
||||||
|
badge: "Nieuwe aflevering",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return spots
|
||||||
|
.sort((a, b) => b.addedAt - a.addedAt)
|
||||||
|
.slice(0, take)
|
||||||
|
.map(({ addedAt: _a, ...rest }) => rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
async recentMovies(take: number) {
|
||||||
|
const files = await prisma.mediaFile.findMany({
|
||||||
|
where: { available: true, movieId: { not: null } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: Math.max(take * 3, take),
|
||||||
|
include: { movie: true },
|
||||||
|
});
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: Array<{
|
||||||
|
type: "movie";
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
year: number | null;
|
||||||
|
posterUrl: string | null;
|
||||||
|
backdropUrl: string | null;
|
||||||
|
mediaFileId: string | null;
|
||||||
|
}> = [];
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.movieId || !f.movie || seen.has(f.movieId)) continue;
|
||||||
|
seen.add(f.movieId);
|
||||||
|
out.push({
|
||||||
|
type: "movie",
|
||||||
|
id: f.movie.id,
|
||||||
|
title: f.movie.title,
|
||||||
|
year: f.movie.year,
|
||||||
|
posterUrl: f.movie.posterUrl,
|
||||||
|
backdropUrl: f.movie.backdropUrl,
|
||||||
|
mediaFileId: f.id,
|
||||||
|
});
|
||||||
|
if (out.length >= take) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async recentSeries(take: number) {
|
||||||
|
const files = await prisma.mediaFile.findMany({
|
||||||
|
where: { available: true, episodeId: { not: null } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: Math.max(take * 5, take),
|
||||||
|
include: { episode: { include: { series: true } } },
|
||||||
|
});
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: Array<{
|
||||||
|
type: "series";
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
year: number | null;
|
||||||
|
posterUrl: string | null;
|
||||||
|
backdropUrl: string | null;
|
||||||
|
mediaFileId: string | null;
|
||||||
|
}> = [];
|
||||||
|
for (const f of files) {
|
||||||
|
const series = f.episode?.series;
|
||||||
|
const seriesId = f.episode?.seriesId;
|
||||||
|
if (!series || !seriesId || seen.has(seriesId)) continue;
|
||||||
|
seen.add(seriesId);
|
||||||
|
out.push({
|
||||||
|
type: "series",
|
||||||
|
id: series.id,
|
||||||
|
title: series.title,
|
||||||
|
year: series.year,
|
||||||
|
posterUrl: series.posterUrl,
|
||||||
|
backdropUrl: series.backdropUrl,
|
||||||
|
mediaFileId: null,
|
||||||
|
});
|
||||||
|
if (out.length >= take) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fourKMovies(take: number) {
|
async fourKMovies(take: number) {
|
||||||
|
|
@ -458,52 +563,80 @@ export class ViewerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
|
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
|
||||||
|
// Sorteer op laatst toegevoegd mediabestand (niet op metadata-aanmaakdatum)
|
||||||
if (kind === "MOVIE") {
|
if (kind === "MOVIE") {
|
||||||
const movies = await prisma.movie.findMany({
|
const files = await prisma.mediaFile.findMany({
|
||||||
where: {
|
where: { shelfId, available: true, movieId: { not: null } },
|
||||||
mediaFiles: { some: { shelfId, available: true } },
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take,
|
take: Math.max(take * 4, take),
|
||||||
include: {
|
include: { movie: true },
|
||||||
mediaFiles: {
|
|
||||||
where: { shelfId, available: true },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return movies.map((m) => ({
|
const seen = new Set<string>();
|
||||||
type: "movie" as const,
|
const out: Array<{
|
||||||
id: m.id,
|
type: "movie";
|
||||||
title: m.title,
|
id: string;
|
||||||
year: m.year,
|
title: string;
|
||||||
posterUrl: m.posterUrl,
|
year: number | null;
|
||||||
backdropUrl: m.backdropUrl,
|
posterUrl: string | null;
|
||||||
mediaFileId: m.mediaFiles[0]?.id ?? null,
|
backdropUrl: string | null;
|
||||||
}));
|
mediaFileId: string | null;
|
||||||
|
}> = [];
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.movieId || !f.movie || seen.has(f.movieId)) continue;
|
||||||
|
seen.add(f.movieId);
|
||||||
|
out.push({
|
||||||
|
type: "movie",
|
||||||
|
id: f.movie.id,
|
||||||
|
title: f.movie.title,
|
||||||
|
year: f.movie.year,
|
||||||
|
posterUrl: f.movie.posterUrl,
|
||||||
|
backdropUrl: f.movie.backdropUrl,
|
||||||
|
mediaFileId: f.id,
|
||||||
|
});
|
||||||
|
if (out.length >= take) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const series = await prisma.series.findMany({
|
const files = await prisma.mediaFile.findMany({
|
||||||
where: {
|
where: {
|
||||||
episodes: {
|
shelfId,
|
||||||
some: {
|
available: true,
|
||||||
mediaFiles: { some: { shelfId, available: true } },
|
episodeId: { not: null },
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take,
|
take: Math.max(take * 6, take),
|
||||||
|
include: {
|
||||||
|
episode: { include: { series: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return series.map((s) => ({
|
const seen = new Set<string>();
|
||||||
type: "series" as const,
|
const out: Array<{
|
||||||
id: s.id,
|
type: "series";
|
||||||
title: s.title,
|
id: string;
|
||||||
year: s.year,
|
title: string;
|
||||||
posterUrl: s.posterUrl,
|
year: number | null;
|
||||||
backdropUrl: s.backdropUrl,
|
posterUrl: string | null;
|
||||||
mediaFileId: null as string | null,
|
backdropUrl: string | null;
|
||||||
}));
|
mediaFileId: string | null;
|
||||||
|
}> = [];
|
||||||
|
for (const f of files) {
|
||||||
|
const series = f.episode?.series;
|
||||||
|
const seriesId = f.episode?.seriesId;
|
||||||
|
if (!series || !seriesId || seen.has(seriesId)) continue;
|
||||||
|
seen.add(seriesId);
|
||||||
|
out.push({
|
||||||
|
type: "series",
|
||||||
|
id: series.id,
|
||||||
|
title: series.title,
|
||||||
|
year: series.year,
|
||||||
|
posterUrl: series.posterUrl,
|
||||||
|
backdropUrl: series.backdropUrl,
|
||||||
|
mediaFileId: null,
|
||||||
|
});
|
||||||
|
if (out.length >= take) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async continueWatching(viewerId: string) {
|
async continueWatching(viewerId: string) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue