Tighten TV back navigation, continue-watching actions, and detail focus.
Long-press continue items to mark watched or hide, scroll-back before exit, confirm quit on home, and focus Play by default. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7e624dd452
commit
04d6a38234
7 changed files with 371 additions and 158 deletions
|
|
@ -12,8 +12,8 @@ android {
|
||||||
applicationId = "nl.vonas.mediacluster.tv"
|
applicationId = "nl.vonas.mediacluster.tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 12
|
versionCode = 13
|
||||||
versionName = "0.7.5"
|
versionName = "0.7.6"
|
||||||
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")
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package nl.vonas.mediacluster.tv.ui
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.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.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
|
@ -47,6 +46,7 @@ fun BrowseScreen(
|
||||||
catalogId: String,
|
catalogId: String,
|
||||||
titleHint: String? = null,
|
titleHint: String? = null,
|
||||||
onBack: (() -> Unit)? = null,
|
onBack: (() -> Unit)? = null,
|
||||||
|
onAtTopBack: (() -> Unit)? = null,
|
||||||
onOpenMovie: (String) -> Unit,
|
onOpenMovie: (String) -> Unit,
|
||||||
onOpenSeries: (String) -> Unit,
|
onOpenSeries: (String) -> Unit,
|
||||||
onPlayContinue: ((
|
onPlayContinue: ((
|
||||||
|
|
@ -109,8 +109,19 @@ fun BrowseScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onBack != null) {
|
val handleBack = onBack != null || onAtTopBack != null
|
||||||
BackHandler(onBack = onBack)
|
if (handleBack) {
|
||||||
|
BackHandler {
|
||||||
|
val scrolled = gridState.firstVisibleItemIndex > 0 ||
|
||||||
|
gridState.firstVisibleItemScrollOffset > 0
|
||||||
|
if (scrolled) {
|
||||||
|
scope.launch { gridState.animateScrollToItem(0) }
|
||||||
|
} else if (onBack != null) {
|
||||||
|
onBack()
|
||||||
|
} else {
|
||||||
|
onAtTopBack?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package nl.vonas.mediacluster.tv.ui
|
package nl.vonas.mediacluster.tv.ui
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
@ -19,6 +20,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -202,7 +204,11 @@ 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) }
|
||||||
|
var exitConfirm by remember { mutableStateOf(false) }
|
||||||
val navFocus = remember { FocusRequester() }
|
val navFocus = remember { FocusRequester() }
|
||||||
|
val homeListState = rememberLazyListState()
|
||||||
|
val hubScope = rememberCoroutineScope()
|
||||||
|
val activity = LocalContext.current as? Activity
|
||||||
var shelfPrefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
|
var shelfPrefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
|
||||||
var movieCatalog by remember { mutableStateOf("movies") }
|
var movieCatalog by remember { mutableStateOf("movies") }
|
||||||
var seriesCatalog by remember { mutableStateOf("series") }
|
var seriesCatalog by remember { mutableStateOf("series") }
|
||||||
|
|
@ -252,14 +258,41 @@ private fun MainHub(
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(enabled = !navFocused && tab != MainTab.Home) {
|
fun focusTopNav() {
|
||||||
tab = MainTab.Home
|
|
||||||
navFocused = true
|
|
||||||
}
|
|
||||||
BackHandler(enabled = !navFocused && tab == MainTab.Home) {
|
|
||||||
navFocused = true
|
navFocused = true
|
||||||
|
exitConfirm = false
|
||||||
|
hubScope.launch {
|
||||||
|
delay(40)
|
||||||
runCatching { navFocus.requestFocus() }
|
runCatching { navFocus.requestFocus() }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit-dialog: terug annuleert
|
||||||
|
BackHandler(enabled = exitConfirm) {
|
||||||
|
exitConfirm = false
|
||||||
|
}
|
||||||
|
// Home + top-nav: vraag om afsluiten
|
||||||
|
BackHandler(enabled = tab == MainTab.Home && navFocused && !exitConfirm) {
|
||||||
|
exitConfirm = true
|
||||||
|
}
|
||||||
|
// Home content: eerst scroll omhoog, anders top-nav
|
||||||
|
BackHandler(enabled = tab == MainTab.Home && !navFocused && !exitConfirm) {
|
||||||
|
if (homeListState.firstVisibleItemIndex > 0 || homeListState.firstVisibleItemScrollOffset > 0) {
|
||||||
|
hubScope.launch { homeListState.animateScrollToItem(0) }
|
||||||
|
} else {
|
||||||
|
focusTopNav()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Films/Series/Zoeken: BrowseScreen handelt scroll→top af; hier alleen top-nav → Home
|
||||||
|
BackHandler(enabled = navFocused && tab != MainTab.Home && !exitConfirm) {
|
||||||
|
tab = MainTab.Home
|
||||||
|
focusTopNav()
|
||||||
|
}
|
||||||
|
// Settings (geen eigen scroll-back): terug naar home
|
||||||
|
BackHandler(enabled = tab == MainTab.Settings && !navFocused && !exitConfirm) {
|
||||||
|
tab = MainTab.Home
|
||||||
|
focusTopNav()
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(navFocused) {
|
LaunchedEffect(navFocused) {
|
||||||
if (navFocused) {
|
if (navFocused) {
|
||||||
|
|
@ -268,13 +301,15 @@ private fun MainHub(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().background(McColors.Bg)) {
|
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
Box(Modifier.focusRequester(navFocus)) {
|
Box(Modifier.focusRequester(navFocus)) {
|
||||||
TopNavBar(
|
TopNavBar(
|
||||||
selected = tab,
|
selected = tab,
|
||||||
onSelect = {
|
onSelect = {
|
||||||
tab = it
|
tab = it
|
||||||
navFocused = true
|
exitConfirm = false
|
||||||
|
focusTopNav()
|
||||||
when (it) {
|
when (it) {
|
||||||
MainTab.Movies -> {
|
MainTab.Movies -> {
|
||||||
if (movieSubId == "__search__") {
|
if (movieSubId == "__search__") {
|
||||||
|
|
@ -296,7 +331,7 @@ private fun MainHub(
|
||||||
onSelectSub = { item ->
|
onSelectSub = { item ->
|
||||||
if (item.id == "__search__") {
|
if (item.id == "__search__") {
|
||||||
tab = MainTab.Search
|
tab = MainTab.Search
|
||||||
navFocused = true
|
focusTopNav()
|
||||||
} else {
|
} else {
|
||||||
when (tab) {
|
when (tab) {
|
||||||
MainTab.Movies -> {
|
MainTab.Movies -> {
|
||||||
|
|
@ -316,6 +351,8 @@ private fun MainHub(
|
||||||
when (tab) {
|
when (tab) {
|
||||||
MainTab.Home -> HomeScreen(
|
MainTab.Home -> HomeScreen(
|
||||||
api = api,
|
api = api,
|
||||||
|
listState = homeListState,
|
||||||
|
onContentFocused = { navFocused = false },
|
||||||
onOpenMovie = onOpenMovie,
|
onOpenMovie = onOpenMovie,
|
||||||
onOpenSeries = onOpenSeries,
|
onOpenSeries = onOpenSeries,
|
||||||
onSeeAll = onBrowse,
|
onSeeAll = onBrowse,
|
||||||
|
|
@ -325,6 +362,7 @@ private fun MainHub(
|
||||||
api = api,
|
api = api,
|
||||||
catalogId = movieCatalog,
|
catalogId = movieCatalog,
|
||||||
titleHint = movieSubs.firstOrNull { it.id == movieSubId }?.label ?: "Films",
|
titleHint = movieSubs.firstOrNull { it.id == movieSubId }?.label ?: "Films",
|
||||||
|
onAtTopBack = { focusTopNav() },
|
||||||
onOpenMovie = onOpenMovie,
|
onOpenMovie = onOpenMovie,
|
||||||
onOpenSeries = onOpenSeries,
|
onOpenSeries = onOpenSeries,
|
||||||
)
|
)
|
||||||
|
|
@ -332,6 +370,7 @@ private fun MainHub(
|
||||||
api = api,
|
api = api,
|
||||||
catalogId = seriesCatalog,
|
catalogId = seriesCatalog,
|
||||||
titleHint = seriesSubs.firstOrNull { it.id == seriesSubId }?.label ?: "Series",
|
titleHint = seriesSubs.firstOrNull { it.id == seriesSubId }?.label ?: "Series",
|
||||||
|
onAtTopBack = { focusTopNav() },
|
||||||
onOpenMovie = onOpenMovie,
|
onOpenMovie = onOpenMovie,
|
||||||
onOpenSeries = onOpenSeries,
|
onOpenSeries = onOpenSeries,
|
||||||
)
|
)
|
||||||
|
|
@ -339,14 +378,49 @@ private fun MainHub(
|
||||||
api = api,
|
api = api,
|
||||||
onOpenMovie = onOpenMovie,
|
onOpenMovie = onOpenMovie,
|
||||||
onOpenSeries = onOpenSeries,
|
onOpenSeries = onOpenSeries,
|
||||||
|
onAtTopBack = { focusTopNav() },
|
||||||
)
|
)
|
||||||
MainTab.Settings -> SettingsScreen(
|
MainTab.Settings -> SettingsScreen(
|
||||||
api = api,
|
api = api,
|
||||||
onBack = { tab = MainTab.Home },
|
onBack = {
|
||||||
|
tab = MainTab.Home
|
||||||
|
focusTopNav()
|
||||||
|
},
|
||||||
onLogout = onLogout,
|
onLogout = onLogout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exitConfirm) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0xCC000000)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.width(420.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color(0xFF1A1A1A))
|
||||||
|
.padding(28.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text("VMC afsluiten?", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
"Weet je zeker dat je de app wilt sluiten?",
|
||||||
|
color = McColors.TextDim,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
McButton("Annuleren") { exitConfirm = false }
|
||||||
|
McButton("Afsluiten", primary = true) { activity?.finish() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
|
@ -437,6 +511,8 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit
|
||||||
@Composable
|
@Composable
|
||||||
private fun HomeScreen(
|
private fun HomeScreen(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
|
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||||
|
onContentFocused: () -> Unit,
|
||||||
onOpenMovie: (String) -> Unit,
|
onOpenMovie: (String) -> Unit,
|
||||||
onOpenSeries: (String) -> Unit,
|
onOpenSeries: (String) -> Unit,
|
||||||
onSeeAll: (catalogId: String, title: String) -> Unit,
|
onSeeAll: (catalogId: String, title: String) -> Unit,
|
||||||
|
|
@ -444,21 +520,37 @@ private fun HomeScreen(
|
||||||
) {
|
) {
|
||||||
var home by remember { mutableStateOf<HomePayload?>(null) }
|
var home by remember { mutableStateOf<HomePayload?>(null) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val menuFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
|
suspend fun reloadHome() {
|
||||||
|
home = api.home()
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
try {
|
try {
|
||||||
home = api.home()
|
reloadHome()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = e.message
|
error = e.message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(continueMenu) {
|
||||||
|
if (continueMenu != null) {
|
||||||
|
delay(40)
|
||||||
|
runCatching { menuFocus.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val spotlight = home?.spotlight.orEmpty().ifEmpty {
|
val spotlight = home?.spotlight.orEmpty().ifEmpty {
|
||||||
home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty()
|
home?.shelves?.flatMap { it.items }?.distinctBy { "${it.type}:${it.id}" }?.take(10).orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
Modifier.fillMaxSize(),
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 56.dp),
|
contentPadding = PaddingValues(bottom = 56.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(28.dp),
|
verticalArrangement = Arrangement.spacedBy(28.dp),
|
||||||
) {
|
) {
|
||||||
|
|
@ -466,6 +558,7 @@ private fun HomeScreen(
|
||||||
SpotlightBillboard(
|
SpotlightBillboard(
|
||||||
items = spotlight,
|
items = spotlight,
|
||||||
onPlay = { item ->
|
onPlay = { item ->
|
||||||
|
onContentFocused()
|
||||||
when {
|
when {
|
||||||
!item.mediaFileId.isNullOrBlank() -> onPlay(
|
!item.mediaFileId.isNullOrBlank() -> onPlay(
|
||||||
item.mediaFileId!!,
|
item.mediaFileId!!,
|
||||||
|
|
@ -480,6 +573,7 @@ private fun HomeScreen(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOpen = { item ->
|
onOpen = { item ->
|
||||||
|
onContentFocused()
|
||||||
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
|
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -500,6 +594,7 @@ private fun HomeScreen(
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = { item ->
|
onClick = { item ->
|
||||||
|
onContentFocused()
|
||||||
when {
|
when {
|
||||||
!item.mediaFileId.isNullOrBlank() -> onPlay(
|
!item.mediaFileId.isNullOrBlank() -> onPlay(
|
||||||
item.mediaFileId!!,
|
item.mediaFileId!!,
|
||||||
|
|
@ -514,6 +609,7 @@ private fun HomeScreen(
|
||||||
else -> onOpenSeries(item.id)
|
else -> onOpenSeries(item.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onLongClick = { item -> continueMenu = item },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -527,11 +623,69 @@ private fun HomeScreen(
|
||||||
items = shelf.items,
|
items = shelf.items,
|
||||||
onSeeAll = { onSeeAll(catalogId, shelf.title) },
|
onSeeAll = { onSeeAll(catalogId, shelf.title) },
|
||||||
onClick = { item ->
|
onClick = { item ->
|
||||||
|
onContentFocused()
|
||||||
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
|
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val menuItem = continueMenu
|
||||||
|
if (menuItem != null) {
|
||||||
|
BackHandler { continueMenu = null }
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0xCC000000)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.width(440.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color(0xFF1A1A1A))
|
||||||
|
.padding(28.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(menuItem.title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 2)
|
||||||
|
if (!menuItem.subtitle.isNullOrBlank()) {
|
||||||
|
Text(menuItem.subtitle!!, color = McColors.TextDim, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
McButton(
|
||||||
|
"Markeren als gezien",
|
||||||
|
primary = true,
|
||||||
|
focusRequester = menuFocus,
|
||||||
|
) {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
if (menuItem.type == "movie") {
|
||||||
|
api.markWatched(movieId = menuItem.id)
|
||||||
|
} else {
|
||||||
|
api.markWatched(episodeId = menuItem.id)
|
||||||
|
}
|
||||||
|
reloadHome()
|
||||||
|
}.onFailure { error = it.message }
|
||||||
|
continueMenu = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
McButton("Verbergen") {
|
||||||
|
scope.launch {
|
||||||
|
val pid = menuItem.progressId
|
||||||
|
if (!pid.isNullOrBlank()) {
|
||||||
|
runCatching {
|
||||||
|
api.dismissContinue(pid)
|
||||||
|
reloadHome()
|
||||||
|
}.onFailure { error = it.message }
|
||||||
|
}
|
||||||
|
continueMenu = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
McButton("Annuleren") { continueMenu = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
|
|
@ -677,6 +831,7 @@ private fun ShelfBlock(
|
||||||
items: List<MediaItem>,
|
items: List<MediaItem>,
|
||||||
onSeeAll: (() -> Unit)?,
|
onSeeAll: (() -> Unit)?,
|
||||||
onClick: (MediaItem) -> Unit,
|
onClick: (MediaItem) -> Unit,
|
||||||
|
onLongClick: ((MediaItem) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(start = 48.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
Column(Modifier.padding(start = 48.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -699,7 +854,11 @@ private fun ShelfBlock(
|
||||||
contentPadding = PaddingValues(end = 48.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 = item,
|
||||||
|
onClick = { onClick(item) },
|
||||||
|
onLongClick = onLongClick?.let { handler -> { handler(item) } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -720,6 +879,7 @@ private fun MovieDetailScreen(
|
||||||
var files by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
|
var files by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val playFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(movieId) {
|
LaunchedEffect(movieId) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -751,6 +911,13 @@ private fun MovieDetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(files) {
|
||||||
|
if (files.isNotEmpty()) {
|
||||||
|
delay(80)
|
||||||
|
runCatching { playFocus.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||||
AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||||
|
|
@ -767,7 +934,7 @@ private fun MovieDetailScreen(
|
||||||
if (error != null) Text(error!!, color = McColors.Danger)
|
if (error != null) Text(error!!, color = McColors.Danger)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
files.firstOrNull()?.let { (id, _) ->
|
files.firstOrNull()?.let { (id, _) ->
|
||||||
McButton("Afspelen", primary = true) { onPlay(id, title, 0) }
|
McButton("Afspelen", primary = true, focusRequester = playFocus) { onPlay(id, title, 0) }
|
||||||
}
|
}
|
||||||
McButton("Als gekeken") {
|
McButton("Als gekeken") {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
@ -801,6 +968,7 @@ private fun SeriesDetailScreen(
|
||||||
var backdrop by remember { mutableStateOf<String?>(null) }
|
var backdrop by remember { mutableStateOf<String?>(null) }
|
||||||
var episodes by remember { mutableStateOf<List<Triple<String, String, String>>>(emptyList()) }
|
var episodes by remember { mutableStateOf<List<Triple<String, String, String>>>(emptyList()) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
val firstEpFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(seriesId) {
|
LaunchedEffect(seriesId) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -829,6 +997,13 @@ private fun SeriesDetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(episodes) {
|
||||||
|
if (episodes.isNotEmpty()) {
|
||||||
|
delay(80)
|
||||||
|
runCatching { firstEpFocus.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||||
AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, alpha = 0.35f)
|
AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, alpha = 0.35f)
|
||||||
|
|
@ -843,8 +1018,12 @@ private fun SeriesDetailScreen(
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Text("Afleveringen", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
Text("Afleveringen", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
items(episodes) { (id, label, fileId) ->
|
itemsIndexed(episodes) { index, (id, label, fileId) ->
|
||||||
McButton(label) { onPlay(fileId, id, "$title · $label") }
|
McButton(
|
||||||
|
label,
|
||||||
|
primary = index == 0,
|
||||||
|
focusRequester = if (index == 0) firstEpFocus else null,
|
||||||
|
) { onPlay(fileId, id, "$title · $label") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -194,8 +194,11 @@ fun PlayerScreen(
|
||||||
when {
|
when {
|
||||||
menu == "os" -> menu = "text"
|
menu == "os" -> menu = "text"
|
||||||
menu != null -> menu = null
|
menu != null -> menu = null
|
||||||
controls -> onBack()
|
controls -> {
|
||||||
else -> showControls()
|
menu = null
|
||||||
|
controls = false
|
||||||
|
}
|
||||||
|
else -> onBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package nl.vonas.mediacluster.tv.ui
|
package nl.vonas.mediacluster.tv.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -13,8 +14,8 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -42,6 +43,7 @@ fun SearchScreen(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
onOpenMovie: (String) -> Unit,
|
onOpenMovie: (String) -> Unit,
|
||||||
onOpenSeries: (String) -> Unit,
|
onOpenSeries: (String) -> Unit,
|
||||||
|
onAtTopBack: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
var results by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
|
var results by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
|
||||||
|
|
@ -49,6 +51,19 @@ fun SearchScreen(
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var searchJob by remember { mutableStateOf<Job?>(null) }
|
var searchJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
val gridState = rememberLazyGridState()
|
||||||
|
|
||||||
|
if (onAtTopBack != null) {
|
||||||
|
BackHandler {
|
||||||
|
val scrolled = gridState.firstVisibleItemIndex > 0 ||
|
||||||
|
gridState.firstVisibleItemScrollOffset > 0
|
||||||
|
if (scrolled) {
|
||||||
|
scope.launch { gridState.animateScrollToItem(0) }
|
||||||
|
} else {
|
||||||
|
onAtTopBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun scheduleSearch(next: String) {
|
fun scheduleSearch(next: String) {
|
||||||
query = next
|
query = next
|
||||||
|
|
@ -127,6 +142,7 @@ fun SearchScreen(
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 140.dp),
|
columns = GridCells.Adaptive(minSize = 140.dp),
|
||||||
|
state = gridState,
|
||||||
contentPadding = PaddingValues(bottom = 40.dp),
|
contentPadding = PaddingValues(bottom = 40.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ 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.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
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
|
||||||
|
|
@ -35,6 +37,7 @@ fun McButton(
|
||||||
label: String,
|
label: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
primary: Boolean = false,
|
primary: Boolean = false,
|
||||||
|
focusRequester: FocusRequester? = null,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val bg = if (primary) Color.White else Color(0x33FFFFFF)
|
val bg = if (primary) Color.White else Color(0x33FFFFFF)
|
||||||
|
|
@ -43,7 +46,9 @@ fun McButton(
|
||||||
val fgFocus = Color.Black
|
val fgFocus = Color.Black
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier,
|
modifier = modifier.then(
|
||||||
|
if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
|
||||||
|
),
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
||||||
glow = ClickableSurfaceDefaults.glow(
|
glow = ClickableSurfaceDefaults.glow(
|
||||||
|
|
@ -76,6 +81,7 @@ fun PosterCard(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
width: Dp = 140.dp,
|
width: Dp = 140.dp,
|
||||||
height: Dp = 210.dp,
|
height: Dp = 210.dp,
|
||||||
|
onLongClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
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)
|
||||||
|
|
@ -85,6 +91,7 @@ fun PosterCard(
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
modifier = modifier.width(width),
|
modifier = modifier.width(width),
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.14f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.14f),
|
||||||
|
|
@ -117,18 +124,11 @@ fun PosterCard(
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth()
|
|
||||||
.height(3.dp)
|
|
||||||
.background(Color(0x55FFFFFF)),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth(progress)
|
.fillMaxWidth(progress)
|
||||||
.height(3.dp)
|
.height(4.dp)
|
||||||
.background(McColors.Accent),
|
.background(McColors.Brand),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -757,6 +757,8 @@ export class ViewerService {
|
||||||
? { hiddenFromContinue: body.hiddenFromContinue }
|
? { hiddenFromContinue: body.hiddenFromContinue }
|
||||||
: autoComplete
|
: autoComplete
|
||||||
? { hiddenFromContinue: true }
|
? { hiddenFromContinue: true }
|
||||||
|
: positionMs > 30_000
|
||||||
|
? { hiddenFromContinue: false }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -791,6 +793,8 @@ export class ViewerService {
|
||||||
? { hiddenFromContinue: body.hiddenFromContinue }
|
? { hiddenFromContinue: body.hiddenFromContinue }
|
||||||
: autoComplete
|
: autoComplete
|
||||||
? { hiddenFromContinue: true }
|
? { hiddenFromContinue: true }
|
||||||
|
: positionMs > 30_000
|
||||||
|
? { hiddenFromContinue: false }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue