Polish Android TV UI and fix player audio/controls.
This commit is contained in:
parent
0b107515e4
commit
921fe57bd8
12 changed files with 810 additions and 346 deletions
|
|
@ -12,8 +12,8 @@ android {
|
|||
applicationId = "nl.vonas.mediacluster.tv"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionCode = 2
|
||||
versionName = "0.2.0"
|
||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package nl.vonas.mediacluster.tv
|
||||
|
||||
import android.media.AudioManager
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
|
|
@ -10,6 +11,7 @@ import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme
|
|||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
volumeControlStream = AudioManager.STREAM_MUSIC
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MediaClusterTheme {
|
||||
|
|
|
|||
|
|
@ -157,6 +157,26 @@ class ApiClient(
|
|||
Unit
|
||||
}
|
||||
|
||||
suspend fun markWatched(movieId: String? = null, episodeId: String? = null) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val o = JSONObject()
|
||||
if (movieId != null) o.put("movieId", movieId)
|
||||
if (episodeId != null) o.put("episodeId", episodeId)
|
||||
val req = authed("$baseUrl/api/v1/client/progress/mark-watched")
|
||||
.post(o.toString().toRequestBody(jsonMedia))
|
||||
.build()
|
||||
execute(req)
|
||||
Unit
|
||||
}
|
||||
|
||||
suspend fun dismissContinue(progressId: String) = withContext(Dispatchers.IO) {
|
||||
val req = authed("$baseUrl/api/v1/client/progress/$progressId/dismiss")
|
||||
.post("{}".toRequestBody(jsonMedia))
|
||||
.build()
|
||||
execute(req)
|
||||
Unit
|
||||
}
|
||||
|
||||
private fun authed(url: String): Request.Builder {
|
||||
val b = Request.Builder().url(url)
|
||||
val t = token
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
package nl.vonas.mediacluster.tv.playback
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
|
||||
/** ExoPlayer tuned for Shield / Android TV direct play + audible output. */
|
||||
fun buildTvPlayer(context: Context): ExoPlayer {
|
||||
val trackSelector = DefaultTrackSelector(context).apply {
|
||||
parameters = buildUponParameters()
|
||||
.setPreferredAudioMimeTypes(
|
||||
MimeTypes.AUDIO_E_AC3,
|
||||
MimeTypes.AUDIO_AC3,
|
||||
MimeTypes.AUDIO_AAC,
|
||||
MimeTypes.AUDIO_MPEG,
|
||||
MimeTypes.AUDIO_OPUS,
|
||||
)
|
||||
.setAllowAudioMixedMimeTypeAdaptiveness(true)
|
||||
.setExceedRendererCapabilitiesIfNecessary(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
val renderersFactory = DefaultRenderersFactory(context)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
|
||||
return ExoPlayer.Builder(context, renderersFactory)
|
||||
.setTrackSelector(trackSelector)
|
||||
.setAudioAttributes(audioAttributes, /* handleAudioFocus = */ true)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
.setSeekBackIncrementMs(10_000)
|
||||
.setSeekForwardIncrementMs(30_000)
|
||||
.build()
|
||||
.also { player ->
|
||||
player.volume = 1f
|
||||
player.playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Pick the most playable audio track (avoid silent DTS/TrueHD when alternatives exist). */
|
||||
fun ExoPlayer.selectBestAudioTrack() {
|
||||
val groups = currentTracks.groups
|
||||
var bestGroup: androidx.media3.common.Tracks.Group? = null
|
||||
var bestTrackIndex = -1
|
||||
var bestScore = -1
|
||||
for (gi in 0 until groups.size) {
|
||||
val group = groups[gi]
|
||||
if (group.type != C.TRACK_TYPE_AUDIO) continue
|
||||
for (ti in 0 until group.length) {
|
||||
if (!group.isTrackSupported(ti)) continue
|
||||
val format = group.getTrackFormat(ti)
|
||||
val mime = (format.sampleMimeType ?: "").lowercase()
|
||||
val score = when {
|
||||
"eac3" in mime || "ec-3" in mime -> 90
|
||||
"ac3" in mime || "ac-3" in mime -> 80
|
||||
"mp4a" in mime || "aac" in mime -> 70
|
||||
"mpeg" in mime -> 60
|
||||
"opus" in mime -> 50
|
||||
"dts" in mime || "truehd" in mime || "mlp" in mime -> 15
|
||||
else -> 35
|
||||
} + format.channelCount.coerceIn(0, 8)
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestGroup = group
|
||||
bestTrackIndex = ti
|
||||
}
|
||||
}
|
||||
}
|
||||
val group = bestGroup ?: return
|
||||
if (bestTrackIndex < 0) return
|
||||
trackSelectionParameters = trackSelectionParameters
|
||||
.buildUpon()
|
||||
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
|
||||
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, listOf(bestTrackIndex)))
|
||||
.build()
|
||||
volume = 1f
|
||||
}
|
||||
|
|
@ -8,18 +8,19 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -27,40 +28,36 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.MediaItem as ExoMediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.Switch
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.data.ApiException
|
||||
import nl.vonas.mediacluster.tv.data.HomePayload
|
||||
import nl.vonas.mediacluster.tv.data.MediaItem
|
||||
import nl.vonas.mediacluster.tv.data.PollResult
|
||||
import nl.vonas.mediacluster.tv.data.SessionStore
|
||||
import nl.vonas.mediacluster.tv.data.ShelfPref
|
||||
import org.json.JSONObject
|
||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||
import nl.vonas.mediacluster.tv.ui.components.PosterCard
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
|
||||
@Composable
|
||||
fun McNav() {
|
||||
|
|
@ -68,6 +65,7 @@ fun McNav() {
|
|||
val store = remember { SessionStore(context) }
|
||||
val api = remember { ApiClient(baseUrl = "") }
|
||||
val nav = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
var ready by remember { mutableStateOf(false) }
|
||||
var authed by remember { mutableStateOf(false) }
|
||||
|
||||
|
|
@ -80,31 +78,23 @@ fun McNav() {
|
|||
}
|
||||
|
||||
if (!ready) {
|
||||
Box(Modifier.fillMaxSize().background(Color(0xFF04060A)), contentAlignment = Alignment.Center) {
|
||||
Text("Media Cluster", color = Color.White, fontSize = 28.sp)
|
||||
}
|
||||
Splash()
|
||||
return
|
||||
}
|
||||
|
||||
NavHost(navController = nav, startDestination = if (authed) "home" else "link") {
|
||||
composable("link") {
|
||||
LinkScreen(
|
||||
api = api,
|
||||
store = store,
|
||||
onLinked = {
|
||||
LinkScreen(api, store) {
|
||||
authed = true
|
||||
nav.navigate("home") {
|
||||
popUpTo("link") { inclusive = true }
|
||||
nav.navigate("home") { popUpTo("link") { inclusive = true } }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable("home") {
|
||||
HomeScreen(
|
||||
api = api,
|
||||
onPlayMovie = { id -> nav.navigate("movie/$id") },
|
||||
onPlaySeries = { id -> nav.navigate("series/$id") },
|
||||
onPlayDirect = { mediaFileId, movieId, episodeId, seriesId, type, startMs ->
|
||||
onOpenMovie = { nav.navigate("movie/$it") },
|
||||
onOpenSeries = { nav.navigate("series/$it") },
|
||||
onPlay = { mediaFileId, movieId, episodeId, seriesId, type, startMs, _title ->
|
||||
val m = movieId?.ifBlank { null } ?: "-"
|
||||
val e = episodeId?.ifBlank { null } ?: "-"
|
||||
val s = seriesId?.ifBlank { null } ?: "-"
|
||||
|
|
@ -112,41 +102,35 @@ fun McNav() {
|
|||
},
|
||||
onSettings = { nav.navigate("settings") },
|
||||
onLogout = {
|
||||
kotlinx.coroutines.MainScope().launch {
|
||||
scope.launch {
|
||||
store.setToken(null)
|
||||
api.configure(store.apiBase.first(), null)
|
||||
authed = false
|
||||
nav.navigate("link") {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
nav.navigate("link") { popUpTo(0) { inclusive = true } }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"movie/{id}",
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
||||
val id = entry.arguments?.getString("id") ?: return@composable
|
||||
DetailScreen(
|
||||
load = { api.movie(id) },
|
||||
onPlay = { mediaFileId ->
|
||||
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/0")
|
||||
},
|
||||
MovieDetailScreen(
|
||||
api = api,
|
||||
movieId = id,
|
||||
onBack = { nav.popBackStack() },
|
||||
onPlay = { mediaFileId, title, startMs ->
|
||||
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs")
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"series/{id}",
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
||||
val id = entry.arguments?.getString("id") ?: return@composable
|
||||
SeriesScreen(
|
||||
load = { api.series(id) },
|
||||
onPlay = { mediaFileId, episodeId ->
|
||||
SeriesDetailScreen(
|
||||
api = api,
|
||||
seriesId = id,
|
||||
onBack = { nav.popBackStack() },
|
||||
onPlay = { mediaFileId, episodeId, _title ->
|
||||
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0")
|
||||
},
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
|
|
@ -173,45 +157,51 @@ fun McNav() {
|
|||
)
|
||||
}
|
||||
composable("settings") {
|
||||
SettingsScreen(
|
||||
api = api,
|
||||
store = store,
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
SettingsScreen(api = api, onBack = { nav.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LinkScreen(
|
||||
api: ApiClient,
|
||||
store: SessionStore,
|
||||
onLinked: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
private fun Splash() {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(McColors.Bg),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Laden…", color = McColors.TextDim, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit) {
|
||||
var code by remember { mutableStateOf<String?>(null) }
|
||||
var linkUrl by remember { mutableStateOf("") }
|
||||
var status by remember { mutableStateOf("Verbinden…") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var status by remember { mutableStateOf("Code ophalen…") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val base = store.apiBase.first()
|
||||
api.configure(base, null)
|
||||
try {
|
||||
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
|
||||
val created = api.createDeviceCode(deviceName.ifBlank { "Android TV" })
|
||||
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" }
|
||||
val created = api.createDeviceCode(deviceName)
|
||||
code = created.code
|
||||
linkUrl = created.linkUrl
|
||||
status = "Wachten op koppeling…"
|
||||
status = "Wachten tot je de code bevestigt…"
|
||||
while (true) {
|
||||
delay((created.pollIntervalSeconds.coerceAtLeast(2)) * 1000L)
|
||||
delay(created.pollIntervalSeconds.coerceAtLeast(2) * 1000L)
|
||||
when (val poll = api.pollDeviceCode(created.code)) {
|
||||
is PollResult.Linked -> {
|
||||
store.setToken(poll.refreshToken)
|
||||
api.configure(base, poll.refreshToken)
|
||||
status = "Welkom ${poll.viewerName}"
|
||||
delay(600)
|
||||
status = "Welkom, ${poll.viewerName}"
|
||||
delay(700)
|
||||
onLinked()
|
||||
break
|
||||
}
|
||||
|
|
@ -227,29 +217,50 @@ private fun LinkScreen(
|
|||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "Kon niet verbinden met master"
|
||||
error = e.message ?: "Kon master niet bereiken"
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF04060A))
|
||||
.padding(48.dp),
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(Color(0xFF12203A), McColors.Bg),
|
||||
radius = 900f,
|
||||
),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("Media Cluster", fontSize = 40.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text("Koppel dit apparaat", fontSize = 22.sp, color = Color(0xFFB8C0D0))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(code ?: "····-····", fontSize = 56.sp, fontWeight = FontWeight.Bold, color = Color(0xFF6EA8FF))
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier
|
||||
.width(560.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(McColors.BgElevated.copy(alpha = 0.92f))
|
||||
.padding(40.dp),
|
||||
) {
|
||||
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
|
||||
Text("Koppel dit apparaat", color = McColors.Text, fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Open de link hieronder op je telefoon of computer, log in en voer de code in.",
|
||||
color = McColors.TextDim,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
code ?: "····-····",
|
||||
color = McColors.Accent,
|
||||
fontSize = 52.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 3.sp,
|
||||
)
|
||||
if (linkUrl.isNotBlank()) {
|
||||
Text("Ga naar $linkUrl", fontSize = 18.sp, color = Color.White)
|
||||
}
|
||||
Text(status, fontSize = 16.sp, color = Color(0xFF9AA3B5))
|
||||
if (error != null) {
|
||||
Text(error!!, color = Color(0xFFFF8B8B), fontSize = 16.sp)
|
||||
Text(linkUrl, color = McColors.Text, fontSize = 18.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
Text(status, color = McColors.TextDim, fontSize = 14.sp)
|
||||
if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -258,74 +269,98 @@ private fun LinkScreen(
|
|||
@Composable
|
||||
private fun HomeScreen(
|
||||
api: ApiClient,
|
||||
onPlayMovie: (String) -> Unit,
|
||||
onPlaySeries: (String) -> Unit,
|
||||
onPlayDirect: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
|
||||
onOpenMovie: (String) -> Unit,
|
||||
onOpenSeries: (String) -> Unit,
|
||||
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long, title: String) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
) {
|
||||
var home by remember { mutableStateOf<HomePayload?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
suspend fun reload() {
|
||||
loading = true
|
||||
try {
|
||||
home = api.home()
|
||||
error = null
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
val hero = home?.continueWatching?.firstOrNull()
|
||||
?: home?.shelves?.firstOrNull()?.items?.firstOrNull()
|
||||
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF04060A)),
|
||||
contentPadding = PaddingValues(32.dp),
|
||||
Modifier.fillMaxSize().background(McColors.Bg),
|
||||
contentPadding = PaddingValues(bottom = 48.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(28.dp),
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Media Cluster", fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
TvButton("Planken", onSettings)
|
||||
TvButton("Uitloggen", onLogout)
|
||||
}
|
||||
HeroHeader(
|
||||
hero = hero,
|
||||
onPlayHero = {
|
||||
val h = hero ?: return@HeroHeader
|
||||
when {
|
||||
!h.mediaFileId.isNullOrBlank() -> onPlay(
|
||||
h.mediaFileId!!,
|
||||
h.id.takeIf { h.type == "movie" },
|
||||
h.id.takeIf { h.type == "episode" },
|
||||
null,
|
||||
if (h.type == "episode") "EPISODE" else "MOVIE",
|
||||
h.positionMs,
|
||||
h.title,
|
||||
)
|
||||
h.type == "movie" -> onOpenMovie(h.id)
|
||||
else -> onOpenSeries(h.id)
|
||||
}
|
||||
},
|
||||
onOpenHero = {
|
||||
val h = hero ?: return@HeroHeader
|
||||
if (h.type == "movie") onOpenMovie(h.id) else onOpenSeries(h.id)
|
||||
},
|
||||
onSettings = onSettings,
|
||||
onLogout = onLogout,
|
||||
)
|
||||
}
|
||||
if (error != null) {
|
||||
item { Text(error!!, color = Color(0xFFFF8B8B)) }
|
||||
item {
|
||||
Text(error!!, color = McColors.Danger, modifier = Modifier.padding(horizontal = 40.dp))
|
||||
}
|
||||
}
|
||||
if (loading && home == null) {
|
||||
item {
|
||||
Text("Bibliotheek laden…", color = McColors.TextDim, modifier = Modifier.padding(40.dp))
|
||||
}
|
||||
}
|
||||
val continueItems = home?.continueWatching.orEmpty()
|
||||
if (continueItems.isNotEmpty()) {
|
||||
item {
|
||||
ShelfSection("Verder kijken", continueItems) { item ->
|
||||
val fileId = item.mediaFileId
|
||||
if (!fileId.isNullOrBlank()) {
|
||||
onPlayDirect(
|
||||
fileId,
|
||||
ShelfBlock("Verder kijken", continueItems) { item ->
|
||||
when {
|
||||
!item.mediaFileId.isNullOrBlank() -> onPlay(
|
||||
item.mediaFileId!!,
|
||||
item.id.takeIf { item.type == "movie" },
|
||||
item.id.takeIf { item.type == "episode" },
|
||||
null,
|
||||
if (item.type == "episode") "EPISODE" else "MOVIE",
|
||||
item.positionMs,
|
||||
item.title,
|
||||
)
|
||||
} else if (item.type == "series" || item.type == "episode") {
|
||||
// fall through
|
||||
} else {
|
||||
onPlayMovie(item.id)
|
||||
item.type == "movie" -> onOpenMovie(item.id)
|
||||
else -> onOpenSeries(item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items(home?.shelves.orEmpty()) { shelf ->
|
||||
ShelfSection(shelf.title, shelf.items) { item ->
|
||||
when (item.type) {
|
||||
"movie" -> onPlayMovie(item.id)
|
||||
else -> onPlaySeries(item.id)
|
||||
}
|
||||
ShelfBlock(shelf.title, shelf.items) { item ->
|
||||
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -333,11 +368,76 @@ private fun HomeScreen(
|
|||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ShelfSection(title: String, items: List<MediaItem>, onClick: (MediaItem) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(title, fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
items(items, key = { "${it.type}:${it.id}" }) { item ->
|
||||
private fun HeroHeader(
|
||||
hero: MediaItem?,
|
||||
onPlayHero: () -> Unit,
|
||||
onOpenHero: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(360.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = hero?.backdropUrl ?: hero?.posterUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(McColors.HeroScrim))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(McColors.Bg, Color.Transparent, Color.Transparent),
|
||||
),
|
||||
),
|
||||
)
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth(0.55f)
|
||||
.padding(start = 40.dp, end = 24.dp, bottom = 36.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("MEDIA CLUSTER", color = McColors.Accent, fontSize = 13.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp)
|
||||
Text(
|
||||
hero?.title ?: "Welkom terug",
|
||||
color = McColors.Text,
|
||||
fontSize = 40.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
lineHeight = 44.sp,
|
||||
)
|
||||
if (!hero?.subtitle.isNullOrBlank()) {
|
||||
Text(hero!!.subtitle!!, color = McColors.TextDim, fontSize = 16.sp)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (hero != null) {
|
||||
McButton(if ((hero.positionMs) > 30_000) "Verder kijken" else "Afspelen", onPlayHero, primary = true)
|
||||
McButton("Details", onOpenHero)
|
||||
}
|
||||
McButton("Planken", onSettings)
|
||||
McButton("Uitloggen", onLogout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ShelfBlock(title: String, items: List<MediaItem>, onClick: (MediaItem) -> Unit) {
|
||||
Column(Modifier.padding(start = 40.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text(title, color = McColors.Text, fontSize = 22.sp, fontWeight = FontWeight.SemiBold)
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
contentPadding = PaddingValues(end = 40.dp),
|
||||
) {
|
||||
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
|
||||
PosterCard(item, onClick = { onClick(item) })
|
||||
}
|
||||
}
|
||||
|
|
@ -346,66 +446,27 @@ private fun ShelfSection(title: String, items: List<MediaItem>, onClick: (MediaI
|
|||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PosterCard(item: MediaItem, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.width(140.dp),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF121826),
|
||||
focusedContainerColor = Color(0xFF1A2438),
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
AsyncImage(
|
||||
model = item.posterUrl,
|
||||
contentDescription = item.title,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.background(Color(0xFF1A2233)),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Text(
|
||||
item.title,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 2,
|
||||
)
|
||||
if (!item.subtitle.isNullOrBlank()) {
|
||||
Text(
|
||||
item.subtitle,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp),
|
||||
color = Color(0xFF9AA3B5),
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DetailScreen(
|
||||
load: suspend () -> JSONObject,
|
||||
onPlay: (String) -> Unit,
|
||||
private fun MovieDetailScreen(
|
||||
api: ApiClient,
|
||||
movieId: String,
|
||||
onBack: () -> Unit,
|
||||
onPlay: (mediaFileId: String, title: String, startMs: Long) -> Unit,
|
||||
) {
|
||||
var title by remember { mutableStateOf("…") }
|
||||
var overview by remember { mutableStateOf("") }
|
||||
var backdrop by remember { mutableStateOf<String?>(null) }
|
||||
var year by remember { mutableStateOf<Int?>(null) }
|
||||
var files by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(movieId) {
|
||||
try {
|
||||
val o = load()
|
||||
val o = api.movie(movieId)
|
||||
title = o.optString("title")
|
||||
overview = o.optString("overview")
|
||||
backdrop = o.optString("backdropUrl").ifBlank { null }
|
||||
backdrop = o.optString("backdropUrl").ifBlank { o.optString("posterUrl").ifBlank { null } }
|
||||
year = o.optInt("year").takeIf { o.has("year") && !o.isNull("year") }
|
||||
val arr = o.optJSONArray("mediaFiles")
|
||||
val list = mutableListOf<Pair<String, String>>()
|
||||
if (arr != null) {
|
||||
|
|
@ -414,9 +475,9 @@ private fun DetailScreen(
|
|||
val label = listOfNotNull(
|
||||
f.optString("resolution").ifBlank { null },
|
||||
f.optString("hdrType").ifBlank { null },
|
||||
f.optString("releaseName").ifBlank { null },
|
||||
).joinToString(" · ")
|
||||
list += f.getString("id") to label.ifBlank { "Afspelen" }
|
||||
f.optString("videoCodec").ifBlank { null },
|
||||
).joinToString(" · ").ifBlank { "Afspelen" }
|
||||
list += f.getString("id") to label
|
||||
}
|
||||
}
|
||||
files = list
|
||||
|
|
@ -425,24 +486,41 @@ private fun DetailScreen(
|
|||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().background(Color(0xFF04060A))) {
|
||||
if (backdrop != null) {
|
||||
AsyncImage(
|
||||
model = backdrop,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
alpha = 0.35f,
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||
AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
Box(Modifier.fillMaxSize().background(Brush.horizontalGradient(listOf(McColors.Bg, McColors.Bg.copy(alpha = 0.85f), Color.Transparent))))
|
||||
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, McColors.Bg))))
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(0.55f)
|
||||
.padding(40.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
McButton("Terug", onBack)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(title, color = McColors.Text, fontSize = 36.sp, fontWeight = FontWeight.Bold, maxLines = 3)
|
||||
if (year != null) Text(year.toString(), color = McColors.TextDim, fontSize = 16.sp)
|
||||
Text(overview, color = McColors.TextDim, fontSize = 15.sp, maxLines = 8, overflow = TextOverflow.Ellipsis, lineHeight = 22.sp)
|
||||
if (error != null) Text(error!!, color = McColors.Danger)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
files.firstOrNull()?.let { (id, _) ->
|
||||
McButton("Afspelen", { onPlay(id, title, 0) }, primary = true)
|
||||
}
|
||||
Column(Modifier.padding(40.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
TvButton("Terug", onBack)
|
||||
Text(title, fontSize = 36.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(overview, color = Color(0xFFD0D6E2), fontSize = 16.sp, maxLines = 6)
|
||||
if (error != null) Text(error!!, color = Color(0xFFFF8B8B))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
McButton("Als gekeken") {
|
||||
scope.launch {
|
||||
runCatching { api.markWatched(movieId = movieId) }
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (files.size > 1) {
|
||||
Text("Versies", color = McColors.Text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(files) { (id, label) ->
|
||||
TvButton(label.take(48), onClick = { onPlay(id) })
|
||||
McButton(label.take(40)) { onPlay(id, title, 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -451,21 +529,26 @@ private fun DetailScreen(
|
|||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SeriesScreen(
|
||||
load: suspend () -> JSONObject,
|
||||
onPlay: (mediaFileId: String, episodeId: String) -> Unit,
|
||||
private fun SeriesDetailScreen(
|
||||
api: ApiClient,
|
||||
seriesId: String,
|
||||
onBack: () -> Unit,
|
||||
onPlay: (mediaFileId: String, episodeId: String, title: String) -> Unit,
|
||||
) {
|
||||
var title by remember { mutableStateOf("…") }
|
||||
var episodes by remember { mutableStateOf<List<Triple<String, String, String?>>>(emptyList()) }
|
||||
var overview by remember { mutableStateOf("") }
|
||||
var backdrop by remember { mutableStateOf<String?>(null) }
|
||||
var episodes by remember { mutableStateOf<List<Triple<String, String, String>>>(emptyList()) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(seriesId) {
|
||||
try {
|
||||
val o = load()
|
||||
val o = api.series(seriesId)
|
||||
title = o.optString("title")
|
||||
overview = o.optString("overview")
|
||||
backdrop = o.optString("backdropUrl").ifBlank { o.optString("posterUrl").ifBlank { null } }
|
||||
val seasons = o.optJSONArray("seasons")
|
||||
val list = mutableListOf<Triple<String, String, String?>>()
|
||||
val list = mutableListOf<Triple<String, String, String>>()
|
||||
if (seasons != null) {
|
||||
for (si in 0 until seasons.length()) {
|
||||
val season = seasons.getJSONObject(si)
|
||||
|
|
@ -473,8 +556,9 @@ private fun SeriesScreen(
|
|||
for (ei in 0 until eps.length()) {
|
||||
val e = eps.getJSONObject(ei)
|
||||
if (!e.optBoolean("available", false)) continue
|
||||
val label = "S${e.optInt("seasonNumber")}E${e.optInt("episodeNumber")} · ${e.optString("title")}"
|
||||
list += Triple(e.getString("id"), label, e.optString("mediaFileId").ifBlank { null })
|
||||
val fileId = e.optString("mediaFileId").ifBlank { null } ?: continue
|
||||
val label = "S${e.optInt("seasonNumber")}E${e.optInt("episodeNumber")} ${e.optString("title")}"
|
||||
list += Triple(e.getString("id"), label, fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -484,118 +568,33 @@ private fun SeriesScreen(
|
|||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||
AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, alpha = 0.35f)
|
||||
Box(Modifier.fillMaxSize().background(McColors.HeroScrim))
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().background(Color(0xFF04060A)).padding(32.dp),
|
||||
contentPadding = PaddingValues(40.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
item {
|
||||
TvButton("Terug", onBack)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(title, fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
if (error != null) Text(error!!, color = Color(0xFFFF8B8B))
|
||||
McButton("Terug", onBack)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(title, color = McColors.Text, fontSize = 34.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(overview, color = McColors.TextDim, fontSize = 15.sp, maxLines = 5, overflow = TextOverflow.Ellipsis)
|
||||
if (error != null) Text(error!!, color = McColors.Danger)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Afleveringen", color = McColors.Text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
items(episodes) { (id, label, fileId) ->
|
||||
if (fileId != null) {
|
||||
TvButton(label, onClick = { onPlay(fileId, id) })
|
||||
McButton(label) { onPlay(fileId, id, "$title · $label") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerScreen(
|
||||
api: ApiClient,
|
||||
mediaFileId: String,
|
||||
mediaType: String,
|
||||
movieId: String?,
|
||||
episodeId: String?,
|
||||
seriesId: String?,
|
||||
startMs: Long,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val player = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(mediaFileId) {
|
||||
try {
|
||||
val play = api.play(mediaFileId)
|
||||
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
|
||||
player.prepare()
|
||||
if (startMs > 0) player.seekTo(startMs)
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(player) {
|
||||
while (true) {
|
||||
delay(15_000)
|
||||
if (player.playbackState == Player.STATE_READY || player.isPlaying) {
|
||||
try {
|
||||
api.postProgress(
|
||||
mediaType = mediaType,
|
||||
movieId = movieId,
|
||||
episodeId = episodeId,
|
||||
seriesId = seriesId,
|
||||
positionMs = player.currentPosition,
|
||||
durationMs = player.duration.coerceAtLeast(0),
|
||||
)
|
||||
} catch (_: ApiException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidx.compose.runtime.DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
try {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
api.postProgress(
|
||||
mediaType = mediaType,
|
||||
movieId = movieId,
|
||||
episodeId = episodeId,
|
||||
seriesId = seriesId,
|
||||
positionMs = player.currentPosition,
|
||||
durationMs = player.duration.coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().background(Color.Black)) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
this.player = player
|
||||
useController = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
if (error != null) {
|
||||
Text(error!!, color = Color(0xFFFF8B8B), modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
Box(Modifier.padding(24.dp)) {
|
||||
TvButton("Terug", onBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SettingsScreen(
|
||||
api: ApiClient,
|
||||
store: SessionStore,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
private fun SettingsScreen(api: ApiClient, onBack: () -> Unit) {
|
||||
var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
|
@ -620,26 +619,30 @@ private fun SettingsScreen(
|
|||
}
|
||||
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().background(Color(0xFF04060A)).padding(32.dp),
|
||||
Modifier.fillMaxSize().background(McColors.Bg).padding(40.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
TvButton("Terug", onBack)
|
||||
McButton("Terug", onBack)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Planken", color = McColors.Text, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
||||
Text("Kies wat je op home ziet en in welke volgorde.", color = McColors.TextDim, fontSize = 15.sp)
|
||||
if (error != null) Text(error!!, color = McColors.Danger)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Planken", fontSize = 28.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text("Zet planken aan/uit en verander de volgorde.", color = Color(0xFF9AA3B5))
|
||||
if (error != null) Text(error!!, color = Color(0xFFFF8B8B))
|
||||
}
|
||||
items(prefs.size) { index ->
|
||||
val p = prefs[index]
|
||||
itemsIndexed(prefs) { index, p ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(McColors.BgElevated)
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(p.name, color = Color.White, fontSize = 18.sp, modifier = Modifier.weight(1f))
|
||||
Text(p.name, color = McColors.Text, fontSize = 18.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TvButton("Omhoog") {
|
||||
McButton("↑") {
|
||||
if (index > 0) {
|
||||
val next = prefs.toMutableList()
|
||||
val tmp = next[index - 1]
|
||||
|
|
@ -648,7 +651,7 @@ private fun SettingsScreen(
|
|||
persist(next)
|
||||
}
|
||||
}
|
||||
TvButton("Omlaag") {
|
||||
McButton("↓") {
|
||||
if (index < prefs.lastIndex) {
|
||||
val next = prefs.toMutableList()
|
||||
val tmp = next[index + 1]
|
||||
|
|
@ -670,23 +673,3 @@ private fun SettingsScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TvButton(label: String, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = Color(0xFF1A2438),
|
||||
focusedContainerColor = Color(0xFF2A3A5A),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
package nl.vonas.mediacluster.tv.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.MediaItem as ExoMediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nl.vonas.mediacluster.tv.R
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.playback.buildTvPlayer
|
||||
import nl.vonas.mediacluster.tv.playback.selectBestAudioTrack
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
api: ApiClient,
|
||||
mediaFileId: String,
|
||||
mediaType: String,
|
||||
movieId: String?,
|
||||
episodeId: String?,
|
||||
seriesId: String?,
|
||||
startMs: Long,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var buffering by remember { mutableStateOf(true) }
|
||||
|
||||
val player = remember {
|
||||
buildTvPlayer(context)
|
||||
}
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
LaunchedEffect(mediaFileId) {
|
||||
try {
|
||||
val play = api.play(mediaFileId)
|
||||
player.setMediaItem(ExoMediaItem.fromUri(play.streamUrl))
|
||||
player.prepare()
|
||||
if (startMs > 5_000) player.seekTo(startMs)
|
||||
player.volume = 1f
|
||||
player.play()
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "Afspelen mislukt"
|
||||
buffering = false
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(player) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
buffering = playbackState == Player.STATE_BUFFERING
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
player.selectBestAudioTrack()
|
||||
player.volume = 1f
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(e: PlaybackException) {
|
||||
error = e.message ?: "Spelerfout"
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
|
||||
player.selectBestAudioTrack()
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
onDispose {
|
||||
try {
|
||||
runBlocking {
|
||||
api.postProgress(
|
||||
mediaType = mediaType,
|
||||
movieId = movieId,
|
||||
episodeId = episodeId,
|
||||
seriesId = seriesId,
|
||||
positionMs = player.currentPosition.coerceAtLeast(0),
|
||||
durationMs = player.duration.coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
player.removeListener(listener)
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(player) {
|
||||
while (true) {
|
||||
delay(12_000)
|
||||
if (player.isPlaying || player.playbackState == Player.STATE_READY) {
|
||||
try {
|
||||
api.postProgress(
|
||||
mediaType = mediaType,
|
||||
movieId = movieId,
|
||||
episodeId = episodeId,
|
||||
seriesId = seriesId,
|
||||
positionMs = player.currentPosition.coerceAtLeast(0),
|
||||
durationMs = player.duration.coerceAtLeast(0),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().background(Color.Black)) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
val root = LayoutInflater.from(ctx).inflate(R.layout.mc_player_view, null, false) as PlayerView
|
||||
root.layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
root.player = player
|
||||
root.controllerShowTimeoutMs = 4_500
|
||||
root.setShowNextButton(false)
|
||||
root.setShowPreviousButton(false)
|
||||
root.setShowFastForwardButton(true)
|
||||
root.setShowRewindButton(true)
|
||||
root.setShowSubtitleButton(true)
|
||||
root.useController = true
|
||||
root.setControllerHideOnTouch(true)
|
||||
// Request focus so remote OK toggles controls
|
||||
root.requestFocus()
|
||||
root
|
||||
},
|
||||
update = { view ->
|
||||
if (view.player !== player) view.player = player
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
if (buffering && error == null) {
|
||||
Text(
|
||||
"Laden…",
|
||||
color = McColors.TextDim,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
if (error != null) {
|
||||
Text(
|
||||
error!!,
|
||||
color = McColors.Danger,
|
||||
fontSize = 18.sp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
package nl.vonas.mediacluster.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Glow
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import nl.vonas.mediacluster.tv.data.MediaItem
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun McButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
primary: Boolean = false,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
|
||||
glow = ClickableSurfaceDefaults.glow(
|
||||
focusedGlow = Glow(elevationColor = McColors.Accent, elevation = 8.dp),
|
||||
),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (primary) McColors.Accent else McColors.Surface,
|
||||
focusedContainerColor = if (primary) Color(0xFF7EB0FF) else McColors.SurfaceFocus,
|
||||
contentColor = if (primary) Color(0xFF041022) else McColors.Text,
|
||||
focusedContentColor = if (primary) Color(0xFF041022) else McColors.Text,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun PosterCard(
|
||||
item: MediaItem,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
width: Dp = 148.dp,
|
||||
height: Dp = 222.dp,
|
||||
) {
|
||||
val progress = if (item.durationMs > 0) {
|
||||
(item.positionMs.toFloat() / item.durationMs.toFloat()).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.width(width),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f),
|
||||
glow = ClickableSurfaceDefaults.glow(
|
||||
focusedGlow = Glow(elevationColor = McColors.Accent, elevation = 12.dp),
|
||||
),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = McColors.Surface,
|
||||
focusedContainerColor = McColors.SurfaceFocus,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.clip(RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp))
|
||||
.background(McColors.BgElevated),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = item.posterUrl ?: item.backdropUrl,
|
||||
contentDescription = item.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
if (progress > 0.02f) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.background(Color(0x55FFFFFF)),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(progress)
|
||||
.height(4.dp)
|
||||
.background(McColors.Accent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
item.title,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
color = McColors.Text,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
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 = McColors.TextDim,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package nl.vonas.mediacluster.tv.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
object McColors {
|
||||
val Bg = Color(0xFF05070D)
|
||||
val BgElevated = Color(0xFF0E1420)
|
||||
val Surface = Color(0xFF151C2B)
|
||||
val SurfaceFocus = Color(0xFF1E2A42)
|
||||
val Accent = Color(0xFF5B9DFF)
|
||||
val AccentSoft = Color(0x335B9DFF)
|
||||
val Text = Color(0xFFF4F7FC)
|
||||
val TextDim = Color(0xFF9AA6BD)
|
||||
val Danger = Color(0xFFFF7B8A)
|
||||
val Success = Color(0xFF7DFFB3)
|
||||
|
||||
val HeroScrim = Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color(0xCC05070D), Color(0xFF05070D)),
|
||||
)
|
||||
val CardScrim = Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color(0xE605070D)),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,24 +1,20 @@
|
|||
package nl.vonas.mediacluster.tv.ui.theme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.darkColorScheme
|
||||
|
||||
private val Scheme = darkColorScheme(
|
||||
primary = Color(0xFF6EA8FF),
|
||||
onPrimary = Color(0xFF001B3D),
|
||||
surface = Color(0xFF0A0E16),
|
||||
onSurface = Color(0xFFF2F5FA),
|
||||
border = Color(0x33FFFFFF),
|
||||
primary = McColors.Accent,
|
||||
onPrimary = McColors.Bg,
|
||||
surface = McColors.BgElevated,
|
||||
onSurface = McColors.Text,
|
||||
border = McColors.AccentSoft,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MediaClusterTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = Scheme,
|
||||
content = content,
|
||||
)
|
||||
MaterialTheme(colorScheme = Scheme, content = content)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="90"
|
||||
android:endColor="#00000000"
|
||||
android:startColor="#CC000000" />
|
||||
</shape>
|
||||
16
apps/android-tv/app/src/main/res/layout/mc_player_view.xml
Normal file
16
apps/android-tv/app/src/main/res/layout/mc_player_view.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.media3.ui.PlayerView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/player_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000"
|
||||
android:keepScreenOn="true"
|
||||
app:auto_show="true"
|
||||
app:hide_on_touch="true"
|
||||
app:resize_mode="fit"
|
||||
app:show_buffering="when_playing"
|
||||
app:show_subtitle_button="true"
|
||||
app:show_timeout="4500"
|
||||
app:surface_type="surface_view"
|
||||
app:use_controller="true" />
|
||||
Loading…
Reference in a new issue