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) {
|
enum class MainTab(val label: String) {
|
||||||
Home("Home"),
|
Home("Home"),
|
||||||
|
Watchlist("Watchlist"),
|
||||||
Movies("Films"),
|
Movies("Films"),
|
||||||
Series("Series"),
|
Series("Series"),
|
||||||
Tv("TV"),
|
Tv("TV"),
|
||||||
|
|
@ -56,6 +57,7 @@ data class SubNavItem(
|
||||||
|
|
||||||
private fun MainTab.icon(): ImageVector = when (this) {
|
private fun MainTab.icon(): ImageVector = when (this) {
|
||||||
MainTab.Home -> Icons.Filled.Home
|
MainTab.Home -> Icons.Filled.Home
|
||||||
|
MainTab.Watchlist -> Icons.Filled.Star
|
||||||
MainTab.Movies -> Icons.Filled.PlayArrow
|
MainTab.Movies -> Icons.Filled.PlayArrow
|
||||||
MainTab.Series -> Icons.Filled.Menu
|
MainTab.Series -> Icons.Filled.Menu
|
||||||
MainTab.Tv -> Icons.Filled.Star
|
MainTab.Tv -> Icons.Filled.Star
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ android {
|
||||||
applicationId = "nl.vonas.mediacluster.tv"
|
applicationId = "nl.vonas.mediacluster.tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 86
|
versionCode = 87
|
||||||
versionName = "0.14.23"
|
versionName = "0.14.24"
|
||||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,59 @@ class ApiClient(
|
||||||
JSONObject(execute(req))
|
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) {
|
suspend fun play(mediaFileId: String): PlayResult = withContext(Dispatchers.IO) {
|
||||||
val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia)
|
val body = JSONObject().put("mediaFileId", mediaFileId).toString().toRequestBody(jsonMedia)
|
||||||
val req = authed("$baseUrl/api/v1/client/play").post(body).build()
|
val req = authed("$baseUrl/api/v1/client/play").post(body).build()
|
||||||
|
|
@ -602,6 +655,18 @@ data class MediaItem(
|
||||||
val badge: String? = null,
|
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(
|
data class PlayResult(
|
||||||
val streamUrl: String,
|
val streamUrl: String,
|
||||||
val sessionId: String,
|
val sessionId: String,
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,7 @@ fun MovieDetailScreen(
|
||||||
var versions by remember { mutableStateOf<List<MediaVersion>>(emptyList()) }
|
var versions by remember { mutableStateOf<List<MediaVersion>>(emptyList()) }
|
||||||
var selectedVersionId by remember { mutableStateOf<String?>(null) }
|
var selectedVersionId by remember { mutableStateOf<String?>(null) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var onWatchlist by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val playFocus = remember { FocusRequester() }
|
val playFocus = remember { FocusRequester() }
|
||||||
val scroll = rememberScrollState()
|
val scroll = rememberScrollState()
|
||||||
|
|
@ -327,6 +328,9 @@ fun MovieDetailScreen(
|
||||||
genres = o.optJSONArray("genres").stringList()
|
genres = o.optJSONArray("genres").stringList()
|
||||||
cast = o.optJSONArray("cast").stringList()
|
cast = o.optJSONArray("cast").stringList()
|
||||||
director = o.optJSONArray("director").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")
|
trailerKey = o.optCleanString("trailerYoutubeKey")
|
||||||
val arr = o.optJSONArray("mediaFiles")
|
val arr = o.optJSONArray("mediaFiles")
|
||||||
val list = mutableListOf<MediaVersion>()
|
val list = mutableListOf<MediaVersion>()
|
||||||
|
|
@ -397,6 +401,11 @@ fun MovieDetailScreen(
|
||||||
openYoutubeTrailer(context, trailerKey!!)
|
openYoutubeTrailer(context, trailerKey!!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
|
||||||
|
scope.launch {
|
||||||
|
runCatching { onWatchlist = api.toggleFavorite("movie", movieId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
DetailActionButton("Markeer als gezien") {
|
DetailActionButton("Markeer als gezien") {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { api.markWatched(movieId = movieId) }
|
runCatching { api.markWatched(movieId = movieId) }
|
||||||
|
|
@ -531,6 +540,8 @@ fun SeriesDetailScreen(
|
||||||
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) }
|
var seasons by remember { mutableStateOf<List<JSONObject>>(emptyList()) }
|
||||||
var selectedSeason by remember { mutableIntStateOf(0) }
|
var selectedSeason by remember { mutableIntStateOf(0) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var onWatchlist by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val firstEpFocus = remember { FocusRequester() }
|
val firstEpFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(seriesId) {
|
LaunchedEffect(seriesId) {
|
||||||
|
|
@ -544,6 +555,9 @@ fun SeriesDetailScreen(
|
||||||
rating = o.optCleanString("rating")
|
rating = o.optCleanString("rating")
|
||||||
genres = o.optJSONArray("genres").stringList()
|
genres = o.optJSONArray("genres").stringList()
|
||||||
trailerKey = o.optCleanString("trailerYoutubeKey")
|
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 arr = o.optJSONArray("seasons")
|
||||||
val list = mutableListOf<JSONObject>()
|
val list = mutableListOf<JSONObject>()
|
||||||
if (arr != null) {
|
if (arr != null) {
|
||||||
|
|
@ -627,6 +641,11 @@ fun SeriesDetailScreen(
|
||||||
if (!trailerKey.isNullOrBlank()) {
|
if (!trailerKey.isNullOrBlank()) {
|
||||||
DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) }
|
DetailActionButton("Trailer") { openYoutubeTrailer(context, trailerKey!!) }
|
||||||
}
|
}
|
||||||
|
DetailActionButton(if (onWatchlist) "✓ Watchlist" else "+ Watchlist") {
|
||||||
|
scope.launch {
|
||||||
|
runCatching { onWatchlist = api.toggleFavorite("series", seriesId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (seasons.size > 1) {
|
if (seasons.size > 1) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,13 @@ fun McNav() {
|
||||||
seriesId = dash(entry.arguments?.getString("seriesId")),
|
seriesId = dash(entry.arguments?.getString("seriesId")),
|
||||||
startMs = entry.arguments?.getLong("startMs") ?: 0L,
|
startMs = entry.arguments?.getLong("startMs") ?: 0L,
|
||||||
onBack = { nav.popBackStack() },
|
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(
|
composable(
|
||||||
|
|
@ -655,6 +662,12 @@ private fun MainHub(
|
||||||
onSeeAll = onBrowse,
|
onSeeAll = onBrowse,
|
||||||
onPlay = onPlay,
|
onPlay = onPlay,
|
||||||
)
|
)
|
||||||
|
MainTab.Watchlist -> WatchlistScreen(
|
||||||
|
api = api,
|
||||||
|
onAtTopBack = { focusTopNav() },
|
||||||
|
onOpenMovie = onOpenMovie,
|
||||||
|
onOpenSeries = onOpenSeries,
|
||||||
|
)
|
||||||
MainTab.Movies -> BrowseScreen(
|
MainTab.Movies -> BrowseScreen(
|
||||||
api = api,
|
api = api,
|
||||||
catalogId = movieCatalog,
|
catalogId = movieCatalog,
|
||||||
|
|
@ -1702,24 +1715,13 @@ private fun SettingsScreen(
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsIndexed(prefs) { index, p ->
|
itemsIndexed(prefs, key = { _, p -> p.shelfId }) { index, p ->
|
||||||
Surface(
|
// Geen clickable parent-Surface: anders vangen ↑/↓ geen OK op Android TV.
|
||||||
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),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(Color(0xFF1A1A22))
|
||||||
.padding(horizontal = 18.dp, vertical = 14.dp),
|
.padding(horizontal = 18.dp, vertical = 14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
|
@ -1771,7 +1773,6 @@ private fun SettingsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
@ -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)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsActionRow(
|
private fun SettingsActionRow(
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -91,11 +92,13 @@ fun PlayerScreen(
|
||||||
seriesId: String?,
|
seriesId: String?,
|
||||||
startMs: Long,
|
startMs: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onPlayNext: ((mediaFileId: String, episodeId: String, seriesId: String) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
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() }
|
val catcherFocus = remember { FocusRequester() }
|
||||||
|
val nextOverlayFocus = remember { FocusRequester() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
@ -113,13 +116,23 @@ fun PlayerScreen(
|
||||||
var osLoading by remember { mutableStateOf(false) }
|
var osLoading by remember { mutableStateOf(false) }
|
||||||
var osError by remember { mutableStateOf<String?>(null) }
|
var osError by remember { mutableStateOf<String?>(null) }
|
||||||
var osBusy by remember { mutableStateOf(false) }
|
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() {
|
fun showControls() {
|
||||||
controls = true
|
controls = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hideControls() {
|
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() {
|
fun togglePlay() {
|
||||||
|
|
@ -196,6 +209,10 @@ fun PlayerScreen(
|
||||||
|
|
||||||
BackHandler {
|
BackHandler {
|
||||||
when {
|
when {
|
||||||
|
showNextOverlay -> {
|
||||||
|
showNextOverlay = false
|
||||||
|
nextOverlayConsumed = true
|
||||||
|
}
|
||||||
menu == "os" -> menu = "text"
|
menu == "os" -> menu = "text"
|
||||||
menu != null -> menu = null
|
menu != null -> menu = null
|
||||||
controls -> {
|
controls -> {
|
||||||
|
|
@ -207,6 +224,13 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(mediaFileId) {
|
LaunchedEffect(mediaFileId) {
|
||||||
|
nextEp = null
|
||||||
|
showNextOverlay = false
|
||||||
|
nextOverlayConsumed = false
|
||||||
|
nextCountdown = 10
|
||||||
|
if (mediaType == "EPISODE" && !episodeId.isNullOrBlank()) {
|
||||||
|
nextEp = runCatching { api.nextEpisode(episodeId) }.getOrNull()
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
val play = api.play(mediaFileId)
|
val play = api.play(mediaFileId)
|
||||||
embeddedHints = play.embeddedTracks
|
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) {
|
DisposableEffect(player) {
|
||||||
val listener = object : Player.Listener {
|
val listener = object : Player.Listener {
|
||||||
override fun onPlaybackStateChanged(state: Int) {
|
override fun onPlaybackStateChanged(state: Int) {
|
||||||
|
|
@ -252,6 +287,12 @@ fun PlayerScreen(
|
||||||
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
|
audioTracks = player.listTracks(C.TRACK_TYPE_AUDIO, embeddedHintsState.value)
|
||||||
textTracks = player.listTracks(C.TRACK_TYPE_TEXT, 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) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
|
@ -550,6 +591,11 @@ fun PlayerScreen(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
NetflixIconButton(if (playing) "❚❚" else "▶", primary = true) { togglePlay() }
|
NetflixIconButton(if (playing) "❚❚" else "▶", primary = true) { togglePlay() }
|
||||||
|
if (nextEp != null && onPlayNext != null) {
|
||||||
|
NetflixIconButton("⏭") {
|
||||||
|
goNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
NetflixIconButton("↺ 10") {
|
NetflixIconButton("↺ 10") {
|
||||||
player.seekBack()
|
player.seekBack()
|
||||||
showControls()
|
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) {
|
if (menu != null) {
|
||||||
val isAudio = menu == "audio"
|
val isAudio = menu == "audio"
|
||||||
val isOs = menu == "os"
|
val isOs = menu == "os"
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ fun PosterCard(
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.10f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.10f),
|
||||||
border = ClickableSurfaceDefaults.border(
|
border = ClickableSurfaceDefaults.border(
|
||||||
focusedBorder = 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),
|
shape = RoundedCornerShape(4.dp),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ object HomeChrome {
|
||||||
|
|
||||||
enum class MainTab(val label: String) {
|
enum class MainTab(val label: String) {
|
||||||
Home("Home"),
|
Home("Home"),
|
||||||
|
Watchlist("Watchlist"),
|
||||||
Movies("Films"),
|
Movies("Films"),
|
||||||
Series("Series"),
|
Series("Series"),
|
||||||
Tv("TV"),
|
Tv("TV"),
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ struct HomeScreen: View {
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 22) {
|
VStack(alignment: .leading, spacing: 22) {
|
||||||
if !app.favorites.mediaItems.isEmpty {
|
if !app.favorites.mediaItems.isEmpty {
|
||||||
ShelfSection(title: "Favorieten", items: app.favorites.mediaItems)
|
ShelfSection(title: "Watchlist", items: app.favorites.mediaItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !home.continueWatching.isEmpty {
|
if !home.continueWatching.isEmpty {
|
||||||
|
|
|
||||||
|
|
@ -535,6 +535,14 @@ export function registerViewerRoutes(
|
||||||
return viewers.seriesDetail(id, auth.profile?.isKids === true);
|
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) => {
|
app.post("/api/v1/client/play", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
requireProfile(auth);
|
requireProfile(auth);
|
||||||
|
|
|
||||||
|
|
@ -1564,7 +1564,11 @@ export class ViewerService {
|
||||||
profileId,
|
profileId,
|
||||||
hiddenFromContinue: false,
|
hiddenFromContinue: false,
|
||||||
completed: 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" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 24,
|
take: 24,
|
||||||
|
|
@ -1618,6 +1622,7 @@ export class ViewerService {
|
||||||
positionMs: p.positionMs,
|
positionMs: p.positionMs,
|
||||||
durationMs: p.durationMs,
|
durationMs: p.durationMs,
|
||||||
progressId: p.id,
|
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 } });
|
const ep = await prisma.episode.findUnique({ where: { id: body.episodeId } });
|
||||||
seriesId = ep?.seriesId ?? null;
|
seriesId = ep?.seriesId ?? null;
|
||||||
}
|
}
|
||||||
return prisma.watchProgress.upsert({
|
const row = await prisma.watchProgress.upsert({
|
||||||
where: {
|
where: {
|
||||||
profileId_episodeId: { profileId, episodeId: body.episodeId },
|
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 }) {
|
async markWatched(profileId: string, opts: { movieId?: string; episodeId?: string }) {
|
||||||
|
|
@ -1721,7 +1801,7 @@ export class ViewerService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (opts.episodeId) {
|
if (opts.episodeId) {
|
||||||
return this.upsertProgress(profileId, {
|
const row = await this.upsertProgress(profileId, {
|
||||||
mediaType: "EPISODE",
|
mediaType: "EPISODE",
|
||||||
episodeId: opts.episodeId,
|
episodeId: opts.episodeId,
|
||||||
positionMs: 1,
|
positionMs: 1,
|
||||||
|
|
@ -1729,6 +1809,7 @@ export class ViewerService {
|
||||||
completed: true,
|
completed: true,
|
||||||
hiddenFromContinue: true,
|
hiddenFromContinue: true,
|
||||||
});
|
});
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400);
|
throw new AppError("INVALID_REQUEST", "movieId of episodeId verplicht", 400);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue