Add watchlist, next-episode playback, and fix TV shelf reorder.
Probe-driven continue-watching advances to the next episode; Android TV gets next controls, red focus rings, and a Watchlist tab between Home and Films.
This commit is contained in:
parent
6de2c767bf
commit
07901c1d0c
11 changed files with 414 additions and 69 deletions
|
|
@ -41,6 +41,7 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
|
|||
|
||||
enum class MainTab(val label: String) {
|
||||
Home("Home"),
|
||||
Watchlist("Watchlist"),
|
||||
Movies("Films"),
|
||||
Series("Series"),
|
||||
Tv("TV"),
|
||||
|
|
@ -56,6 +57,7 @@ data class SubNavItem(
|
|||
|
||||
private fun MainTab.icon(): ImageVector = when (this) {
|
||||
MainTab.Home -> Icons.Filled.Home
|
||||
MainTab.Watchlist -> Icons.Filled.Star
|
||||
MainTab.Movies -> Icons.Filled.PlayArrow
|
||||
MainTab.Series -> Icons.Filled.Menu
|
||||
MainTab.Tv -> Icons.Filled.Star
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ android {
|
|||
applicationId = "nl.vonas.mediacluster.tv"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 86
|
||||
versionName = "0.14.23"
|
||||
versionCode = 87
|
||||
versionName = "0.14.24"
|
||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
|
|
|
|||
|
|
@ -151,6 +151,59 @@ class ApiClient(
|
|||
JSONObject(execute(req))
|
||||
}
|
||||
|
||||
suspend fun nextEpisode(episodeId: String): NextEpisode? = withContext(Dispatchers.IO) {
|
||||
val req = authed("$baseUrl/api/v1/client/episodes/$episodeId/next").get().build()
|
||||
parseObject(execute(req)) { o ->
|
||||
val n = o.optJSONObject("next") ?: return@parseObject null
|
||||
val mediaFileId = n.optString("mediaFileId").ifBlank { null } ?: return@parseObject null
|
||||
NextEpisode(
|
||||
id = n.getString("id"),
|
||||
seriesId = n.getString("seriesId"),
|
||||
seasonNumber = n.optInt("seasonNumber"),
|
||||
episodeNumber = n.optInt("episodeNumber"),
|
||||
title = n.optString("title").ifBlank { null },
|
||||
seriesTitle = n.optString("seriesTitle").ifBlank { null },
|
||||
subtitle = n.optString("subtitle").ifBlank { null },
|
||||
mediaFileId = mediaFileId,
|
||||
posterUrl = n.optString("posterUrl").ifBlank { null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listFavorites(): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||
val req = authed("$baseUrl/api/v1/client/favorites").get().build()
|
||||
parseObject(execute(req)) { o ->
|
||||
val arr = o.optJSONArray("items") ?: return@parseObject emptyList()
|
||||
buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
val it = arr.optJSONObject(i) ?: continue
|
||||
add(
|
||||
MediaItem(
|
||||
type = it.optString("type").ifBlank { "movie" },
|
||||
id = it.getString("id"),
|
||||
title = it.optString("title"),
|
||||
year = it.optInt("year").takeIf { y -> y > 0 },
|
||||
posterUrl = it.optString("posterUrl").ifBlank { null },
|
||||
backdropUrl = it.optString("backdropUrl").ifBlank { null },
|
||||
logoUrl = it.optString("logoUrl").ifBlank { null },
|
||||
mediaFileId = it.optString("mediaFileId").ifBlank { null },
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun toggleFavorite(type: String, id: String): Boolean = withContext(Dispatchers.IO) {
|
||||
val body = JSONObject()
|
||||
.put("type", type)
|
||||
.put("id", id)
|
||||
.toString()
|
||||
.toRequestBody(jsonMedia)
|
||||
val req = authed("$baseUrl/api/v1/client/favorites/toggle").post(body).build()
|
||||
parseObject(execute(req)) { it.optBoolean("favorited") }
|
||||
}
|
||||
|
||||
suspend fun play(mediaFileId: String): PlayResult = withContext(Dispatchers.IO) {
|
||||
val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia)
|
||||
val req = authed("$baseUrl/api/v1/client/play").post(body).build()
|
||||
|
|
@ -602,6 +655,18 @@ data class MediaItem(
|
|||
val badge: String? = null,
|
||||
)
|
||||
|
||||
data class NextEpisode(
|
||||
val id: String,
|
||||
val seriesId: String,
|
||||
val seasonNumber: Int,
|
||||
val episodeNumber: Int,
|
||||
val title: String?,
|
||||
val seriesTitle: String?,
|
||||
val subtitle: String?,
|
||||
val mediaFileId: String,
|
||||
val posterUrl: String?,
|
||||
)
|
||||
|
||||
data class PlayResult(
|
||||
val streamUrl: String,
|
||||
val sessionId: String,
|
||||
|
|
|
|||
|
|
@ -309,6 +309,7 @@ fun MovieDetailScreen(
|
|||
var versions by remember { mutableStateOf<List<MediaVersion>>(emptyList()) }
|
||||
var selectedVersionId by remember { mutableStateOf<String?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var onWatchlist by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val playFocus = remember { FocusRequester() }
|
||||
val scroll = rememberScrollState()
|
||||
|
|
@ -327,6 +328,9 @@ fun MovieDetailScreen(
|
|||
genres = o.optJSONArray("genres").stringList()
|
||||
cast = o.optJSONArray("cast").stringList()
|
||||
director = o.optJSONArray("director").stringList()
|
||||
onWatchlist = runCatching {
|
||||
api.listFavorites().any { it.id == movieId && it.type.equals("movie", true) }
|
||||
}.getOrDefault(false)
|
||||
trailerKey = o.optCleanString("trailerYoutubeKey")
|
||||
val arr = o.optJSONArray("mediaFiles")
|
||||
val list = mutableListOf<MediaVersion>()
|
||||
|
|
@ -397,6 +401,11 @@ fun MovieDetailScreen(
|
|||
openYoutubeTrailer(context, trailerKey!!)
|
||||
}
|
||||
}
|
||||
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
|
||||
scope.launch {
|
||||
runCatching { onWatchlist = api.toggleFavorite("movie", movieId) }
|
||||
}
|
||||
}
|
||||
DetailActionButton("Markeer als gezien") {
|
||||
scope.launch {
|
||||
runCatching { api.markWatched(movieId = movieId) }
|
||||
|
|
@ -531,6 +540,8 @@ fun SeriesDetailScreen(
|
|||
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) }
|
||||
var selectedSeason by remember { mutableIntStateOf(0) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var onWatchlist by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val firstEpFocus = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(seriesId) {
|
||||
|
|
@ -544,6 +555,9 @@ fun SeriesDetailScreen(
|
|||
rating = o.optCleanString("rating")
|
||||
genres = o.optJSONArray("genres").stringList()
|
||||
trailerKey = o.optCleanString("trailerYoutubeKey")
|
||||
onWatchlist = runCatching {
|
||||
api.listFavorites().any { it.id == seriesId && it.type.equals("series", true) }
|
||||
}.getOrDefault(false)
|
||||
val arr = o.optJSONArray("seasons")
|
||||
val list = mutableListOf<JSONObject>()
|
||||
if (arr != null) {
|
||||
|
|
@ -627,6 +641,11 @@ fun SeriesDetailScreen(
|
|||
if (!trailerKey.isNullOrBlank()) {
|
||||
DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) }
|
||||
}
|
||||
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
|
||||
scope.launch {
|
||||
runCatching { onWatchlist = api.toggleFavorite("series", seriesId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (seasons.size > 1) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
|
|
|||
|
|
@ -368,6 +368,13 @@ fun McNav() {
|
|||
seriesId = dash(entry.arguments?.getString("seriesId")),
|
||||
startMs = entry.arguments?.getLong("startMs") ?: 0L,
|
||||
onBack = { nav.popBackStack() },
|
||||
onPlayNext = { nextMediaFileId, nextEpisodeId, nextSeriesId ->
|
||||
nav.navigate("player/$nextMediaFileId/EPISODE/-/$nextEpisodeId/$nextSeriesId/0") {
|
||||
popUpTo("player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}") {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
|
|
@ -655,6 +662,12 @@ private fun MainHub(
|
|||
onSeeAll = onBrowse,
|
||||
onPlay = onPlay,
|
||||
)
|
||||
MainTab.Watchlist -> WatchlistScreen(
|
||||
api = api,
|
||||
onAtTopBack = { focusTopNav() },
|
||||
onOpenMovie = onOpenMovie,
|
||||
onOpenSeries = onOpenSeries,
|
||||
)
|
||||
MainTab.Movies -> BrowseScreen(
|
||||
api = api,
|
||||
catalogId = movieCatalog,
|
||||
|
|
@ -1702,73 +1715,61 @@ private fun SettingsScreen(
|
|||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
itemsIndexed(prefs) { index, p ->
|
||||
Surface(
|
||||
onClick = {
|
||||
val next = prefs.toMutableList()
|
||||
next[index] = p.copy(visible = !p.visible)
|
||||
persist(next)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.015f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF1A1A22),
|
||||
focusedContainerColor = Color(0xFF262632),
|
||||
),
|
||||
itemsIndexed(prefs, key = { _, p -> p.shelfId }) { index, p ->
|
||||
// Geen clickable parent-Surface: anders vangen ↑/↓ geen OK op Android TV.
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color(0xFF1A1A22))
|
||||
.padding(horizontal = 18.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
p.name,
|
||||
color = Color.White,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
if (p.visible) "Zichtbaar op Home" else "Verborgen",
|
||||
color = if (p.visible) Color(0xFF8E8E9A) else Color(0xFF555555),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
p.name,
|
||||
color = Color.White,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
if (p.visible) "Zichtbaar op Home" else "Verborgen",
|
||||
color = if (p.visible) Color(0xFF8E8E9A) else Color(0xFF555555),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
McButton("↑") {
|
||||
if (index > 0) {
|
||||
val next = prefs.toMutableList()
|
||||
val tmp = next[index - 1]
|
||||
next[index - 1] = next[index]
|
||||
next[index] = tmp
|
||||
persist(next)
|
||||
}
|
||||
McButton("↑") {
|
||||
if (index > 0) {
|
||||
val next = prefs.toMutableList()
|
||||
val tmp = next[index - 1]
|
||||
next[index - 1] = next[index]
|
||||
next[index] = tmp
|
||||
persist(next)
|
||||
}
|
||||
McButton("↓") {
|
||||
if (index < prefs.lastIndex) {
|
||||
val next = prefs.toMutableList()
|
||||
val tmp = next[index + 1]
|
||||
next[index + 1] = next[index]
|
||||
next[index] = tmp
|
||||
persist(next)
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = p.visible,
|
||||
onCheckedChange = { checked ->
|
||||
val next = prefs.toMutableList()
|
||||
next[index] = p.copy(visible = checked)
|
||||
persist(next)
|
||||
},
|
||||
)
|
||||
}
|
||||
McButton("↓") {
|
||||
if (index < prefs.lastIndex) {
|
||||
val next = prefs.toMutableList()
|
||||
val tmp = next[index + 1]
|
||||
next[index + 1] = next[index]
|
||||
next[index] = tmp
|
||||
persist(next)
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = p.visible,
|
||||
onCheckedChange = { checked ->
|
||||
val next = prefs.toMutableList()
|
||||
next[index] = p.copy(visible = checked)
|
||||
persist(next)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1787,6 +1788,82 @@ private fun SettingsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun WatchlistScreen(
|
||||
api: ApiClient,
|
||||
onAtTopBack: () -> Unit,
|
||||
onOpenMovie: (String) -> Unit,
|
||||
onOpenSeries: (String) -> Unit,
|
||||
) {
|
||||
var items by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
loading = true
|
||||
try {
|
||||
items = api.listFavorites()
|
||||
error = null
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
}
|
||||
loading = false
|
||||
runCatching { firstFocus.requestFocus() }
|
||||
}
|
||||
|
||||
BackHandler(onBack = onAtTopBack)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF0B0B0F))
|
||||
.padding(horizontal = 40.dp, vertical = 20.dp),
|
||||
) {
|
||||
Text("Watchlist", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"Films en series die je later wilt kijken",
|
||||
color = Color(0xFF8E8E9A),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
when {
|
||||
loading -> Text("Laden…", color = Color(0xFF8E8E9A))
|
||||
error != null -> Text(error!!, color = McColors.Danger)
|
||||
items.isEmpty() -> Text(
|
||||
"Nog niets op je watchlist. Voeg titels toe vanaf de detailpagina.",
|
||||
color = Color(0xFF8E8E9A),
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
else -> {
|
||||
androidx.compose.foundation.lazy.grid.LazyVerticalGrid(
|
||||
columns = androidx.compose.foundation.lazy.grid.GridCells.Adaptive(minSize = 124.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
contentPadding = PaddingValues(bottom = 32.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(items.size, key = { items[it].id }) { idx ->
|
||||
val item = items[idx]
|
||||
PosterCard(
|
||||
item = item,
|
||||
onClick = {
|
||||
when (item.type.lowercase()) {
|
||||
"series" -> onOpenSeries(item.id)
|
||||
else -> onOpenMovie(item.id)
|
||||
}
|
||||
},
|
||||
modifier = if (idx == 0) Modifier.focusRequester(firstFocus) else Modifier,
|
||||
fillWidth = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SettingsActionRow(
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -91,11 +92,13 @@ fun PlayerScreen(
|
|||
seriesId: String?,
|
||||
startMs: Long,
|
||||
onBack: () -> Unit,
|
||||
onPlayNext: ((mediaFileId: String, episodeId: String, seriesId: String) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val player = remember { buildTvPlayer(context) }
|
||||
val controlsFocus = remember { FocusRequester() }
|
||||
val catcherFocus = remember { FocusRequester() }
|
||||
val nextOverlayFocus = remember { FocusRequester() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
|
@ -113,13 +116,23 @@ fun PlayerScreen(
|
|||
var osLoading by remember { mutableStateOf(false) }
|
||||
var osError by remember { mutableStateOf<String?>(null) }
|
||||
var osBusy by remember { mutableStateOf(false) }
|
||||
var nextEp by remember { mutableStateOf<nl.vonas.mediacluster.tv.data.NextEpisode?>(null) }
|
||||
var showNextOverlay by remember { mutableStateOf(false) }
|
||||
var nextCountdown by remember { mutableIntStateOf(10) }
|
||||
var nextOverlayConsumed by remember { mutableStateOf(false) }
|
||||
|
||||
fun showControls() {
|
||||
controls = true
|
||||
}
|
||||
|
||||
fun hideControls() {
|
||||
if (menu == null) controls = false
|
||||
if (menu == null && !showNextOverlay) controls = false
|
||||
}
|
||||
|
||||
fun goNext() {
|
||||
val n = nextEp ?: return
|
||||
showNextOverlay = false
|
||||
onPlayNext?.invoke(n.mediaFileId, n.id, n.seriesId) ?: onBack()
|
||||
}
|
||||
|
||||
fun togglePlay() {
|
||||
|
|
@ -196,6 +209,10 @@ fun PlayerScreen(
|
|||
|
||||
BackHandler {
|
||||
when {
|
||||
showNextOverlay -> {
|
||||
showNextOverlay = false
|
||||
nextOverlayConsumed = true
|
||||
}
|
||||
menu == "os" -> menu = "text"
|
||||
menu != null -> menu = null
|
||||
controls -> {
|
||||
|
|
@ -207,6 +224,13 @@ fun PlayerScreen(
|
|||
}
|
||||
|
||||
LaunchedEffect(mediaFileId) {
|
||||
nextEp = null
|
||||
showNextOverlay = false
|
||||
nextOverlayConsumed = false
|
||||
nextCountdown = 10
|
||||
if (mediaType == "EPISODE" && !episodeId.isNullOrBlank()) {
|
||||
nextEp = runCatching { api.nextEpisode(episodeId) }.getOrNull()
|
||||
}
|
||||
try {
|
||||
val play = api.play(mediaFileId)
|
||||
embeddedHints = play.embeddedTracks
|
||||
|
|
@ -242,6 +266,17 @@ fun PlayerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showNextOverlay, nextEp) {
|
||||
if (!showNextOverlay || nextEp == null) return@LaunchedEffect
|
||||
nextCountdown = 10
|
||||
runCatching { nextOverlayFocus.requestFocus() }
|
||||
while (nextCountdown > 0 && showNextOverlay) {
|
||||
kotlinx.coroutines.delay(1000)
|
||||
nextCountdown--
|
||||
}
|
||||
if (showNextOverlay && nextCountdown <= 0) goNext()
|
||||
}
|
||||
|
||||
DisposableEffect(player) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
|
|
@ -252,6 +287,12 @@ fun PlayerScreen(
|
|||
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
|
||||
textTracks = player.listTracks(C.TRACK_TYPE_TEXT, embeddedHintsState.value)
|
||||
}
|
||||
if (state == Player.STATE_ENDED && nextEp != null && !nextOverlayConsumed) {
|
||||
nextOverlayConsumed = true
|
||||
player.pause()
|
||||
showNextOverlay = true
|
||||
menu = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
|
|
@ -550,6 +591,11 @@ fun PlayerScreen(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
NetflixIconButton(if (playing) "❚❚" else "▶", primary = true) { togglePlay() }
|
||||
if (nextEp != null && onPlayNext != null) {
|
||||
NetflixIconButton("⏭") {
|
||||
goNext()
|
||||
}
|
||||
}
|
||||
NetflixIconButton("↺ 10") {
|
||||
player.seekBack()
|
||||
showControls()
|
||||
|
|
@ -572,6 +618,52 @@ fun PlayerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (showNextOverlay && nextEp != null) {
|
||||
val n = nextEp!!
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xCC000000)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.width(520.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color(0xFF14141C))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text("Volgende aflevering", color = Color(0xFF8E8E9A), fontSize = 14.sp)
|
||||
Text(
|
||||
n.seriesTitle ?: "Serie",
|
||||
color = Color.White,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
n.subtitle ?: "S${n.seasonNumber}E${n.episodeNumber}",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
Text(
|
||||
"Start over ${nextCountdown}s",
|
||||
color = McColors.Brand,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
NetflixTextButton("Speel nu") { goNext() }
|
||||
NetflixTextButton("Annuleren") {
|
||||
showNextOverlay = false
|
||||
nextOverlayConsumed = true
|
||||
player.pause()
|
||||
showControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (menu != null) {
|
||||
val isAudio = menu == "audio"
|
||||
val isOs = menu == "os"
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ fun PosterCard(
|
|||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.10f),
|
||||
border = ClickableSurfaceDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = androidx.compose.foundation.BorderStroke(3.dp, Color.White),
|
||||
border = androidx.compose.foundation.BorderStroke(3.dp, McColors.Brand),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ object HomeChrome {
|
|||
|
||||
enum class MainTab(val label: String) {
|
||||
Home("Home"),
|
||||
Watchlist("Watchlist"),
|
||||
Movies("Films"),
|
||||
Series("Series"),
|
||||
Tv("TV"),
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ struct HomeScreen: View {
|
|||
|
||||
VStack(alignment: .leading, spacing: 22) {
|
||||
if !app.favorites.mediaItems.isEmpty {
|
||||
ShelfSection(title: "Favorieten", items: app.favorites.mediaItems)
|
||||
ShelfSection(title: "Watchlist", items: app.favorites.mediaItems)
|
||||
}
|
||||
|
||||
if !home.continueWatching.isEmpty {
|
||||
|
|
|
|||
|
|
@ -535,6 +535,14 @@ export function registerViewerRoutes(
|
|||
return viewers.seriesDetail(id, auth.profile?.isKids === true);
|
||||
});
|
||||
|
||||
app.get("/api/v1/client/episodes/:id/next", async (request) => {
|
||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||
requireProfile(auth);
|
||||
const { id } = request.params as { id: string };
|
||||
const next = await viewers.findNextPlayableEpisode(id);
|
||||
return { next };
|
||||
});
|
||||
|
||||
app.post("/api/v1/client/play", async (request) => {
|
||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||
requireProfile(auth);
|
||||
|
|
|
|||
|
|
@ -1564,7 +1564,11 @@ export class ViewerService {
|
|||
profileId,
|
||||
hiddenFromContinue: false,
|
||||
completed: false,
|
||||
positionMs: { gt: 30_000 },
|
||||
OR: [
|
||||
{ positionMs: { gt: 30_000 } },
|
||||
// Up-next na afgeronde aflevering (geseed met positionMs = 0)
|
||||
{ mediaType: "EPISODE", positionMs: { lte: 30_000 } },
|
||||
],
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 24,
|
||||
|
|
@ -1618,6 +1622,7 @@ export class ViewerService {
|
|||
positionMs: p.positionMs,
|
||||
durationMs: p.durationMs,
|
||||
progressId: p.id,
|
||||
upNext: p.positionMs <= 30_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1679,7 +1684,7 @@ export class ViewerService {
|
|||
const ep = await prisma.episode.findUnique({ where: { id: body.episodeId } });
|
||||
seriesId = ep?.seriesId ?? null;
|
||||
}
|
||||
return prisma.watchProgress.upsert({
|
||||
const row = await prisma.watchProgress.upsert({
|
||||
where: {
|
||||
profileId_episodeId: { profileId, episodeId: body.episodeId },
|
||||
},
|
||||
|
|
@ -1707,6 +1712,81 @@ export class ViewerService {
|
|||
: {}),
|
||||
},
|
||||
});
|
||||
if (autoComplete) {
|
||||
await this.seedNextEpisodeContinue(profileId, body.episodeId);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Volgende speelbare aflevering (zelfde seizoen of volgend seizoen). */
|
||||
async findNextPlayableEpisode(episodeId: string) {
|
||||
const current = await prisma.episode.findUnique({ where: { id: episodeId } });
|
||||
if (!current) return null;
|
||||
const episodes = await prisma.episode.findMany({
|
||||
where: { seriesId: current.seriesId },
|
||||
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||||
include: {
|
||||
mediaFiles: {
|
||||
where: { available: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
series: { select: { id: true, title: true, posterUrl: true, backdropUrl: true } },
|
||||
},
|
||||
});
|
||||
const idx = episodes.findIndex((e) => e.id === episodeId);
|
||||
if (idx < 0) return null;
|
||||
for (let i = idx + 1; i < episodes.length; i++) {
|
||||
const ep = episodes[i];
|
||||
const file = ep.mediaFiles[0];
|
||||
if (!file) continue;
|
||||
return {
|
||||
id: ep.id,
|
||||
seriesId: ep.seriesId,
|
||||
seasonNumber: ep.seasonNumber,
|
||||
episodeNumber: ep.episodeNumber,
|
||||
title: ep.title,
|
||||
seriesTitle: ep.series.title,
|
||||
posterUrl: ep.series.posterUrl || ep.stillUrl,
|
||||
backdropUrl: ep.series.backdropUrl,
|
||||
mediaFileId: file.id,
|
||||
subtitle: `S${ep.seasonNumber}E${ep.episodeNumber}${ep.title ? ` · ${ep.title}` : ""}`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Zet volgende aflevering in Verder kijken na afronden. */
|
||||
async seedNextEpisodeContinue(profileId: string, completedEpisodeId: string) {
|
||||
const next = await this.findNextPlayableEpisode(completedEpisodeId);
|
||||
if (!next) return null;
|
||||
const existing = await prisma.watchProgress.findUnique({
|
||||
where: { profileId_episodeId: { profileId, episodeId: next.id } },
|
||||
});
|
||||
// Niet overschrijven als gebruiker al serieus bezig is met die aflevering
|
||||
if (existing && !existing.completed && existing.positionMs > 30_000) {
|
||||
return existing;
|
||||
}
|
||||
return prisma.watchProgress.upsert({
|
||||
where: { profileId_episodeId: { profileId, episodeId: next.id } },
|
||||
create: {
|
||||
profileId,
|
||||
mediaType: "EPISODE",
|
||||
episodeId: next.id,
|
||||
seriesId: next.seriesId,
|
||||
positionMs: 0,
|
||||
durationMs: 0,
|
||||
completed: false,
|
||||
hiddenFromContinue: false,
|
||||
},
|
||||
update: {
|
||||
seriesId: next.seriesId,
|
||||
completed: false,
|
||||
hiddenFromContinue: false,
|
||||
positionMs: existing && existing.positionMs > 0 && !existing.completed ? existing.positionMs : 0,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async markWatched(profileId: string, opts: { movieId?: string; episodeId?: string }) {
|
||||
|
|
@ -1721,7 +1801,7 @@ export class ViewerService {
|
|||
});
|
||||
}
|
||||
if (opts.episodeId) {
|
||||
return this.upsertProgress(profileId, {
|
||||
const row = await this.upsertProgress(profileId, {
|
||||
mediaType: "EPISODE",
|
||||
episodeId: opts.episodeId,
|
||||
positionMs: 1,
|
||||
|
|
@ -1729,6 +1809,7 @@ export class ViewerService {
|
|||
completed: true,
|
||||
hiddenFromContinue: true,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue