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"
|
applicationId = "nl.vonas.mediacluster.tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 2
|
||||||
versionName = "0.1.0"
|
versionName = "0.2.0"
|
||||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<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
|
<uses-feature
|
||||||
android:name="android.software.leanback"
|
android:name="android.software.leanback"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package nl.vonas.mediacluster.tv
|
package nl.vonas.mediacluster.tv
|
||||||
|
|
||||||
|
import android.media.AudioManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
|
@ -10,6 +11,7 @@ import nl.vonas.mediacluster.tv.ui.theme.MediaClusterTheme
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
volumeControlStream = AudioManager.STREAM_MUSIC
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
MediaClusterTheme {
|
MediaClusterTheme {
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,26 @@ class ApiClient(
|
||||||
Unit
|
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 {
|
private fun authed(url: String): Request.Builder {
|
||||||
val b = Request.Builder().url(url)
|
val b = Request.Builder().url(url)
|
||||||
val t = token
|
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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.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.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
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
|
||||||
|
|
@ -27,40 +28,36 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.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.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
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.Switch
|
||||||
|
import androidx.tv.material3.Text
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
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.HomePayload
|
||||||
import nl.vonas.mediacluster.tv.data.MediaItem
|
import nl.vonas.mediacluster.tv.data.MediaItem
|
||||||
import nl.vonas.mediacluster.tv.data.PollResult
|
import nl.vonas.mediacluster.tv.data.PollResult
|
||||||
import nl.vonas.mediacluster.tv.data.SessionStore
|
import nl.vonas.mediacluster.tv.data.SessionStore
|
||||||
import nl.vonas.mediacluster.tv.data.ShelfPref
|
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
|
@Composable
|
||||||
fun McNav() {
|
fun McNav() {
|
||||||
|
|
@ -68,6 +65,7 @@ fun McNav() {
|
||||||
val store = remember { SessionStore(context) }
|
val store = remember { SessionStore(context) }
|
||||||
val api = remember { ApiClient(baseUrl = "") }
|
val api = remember { ApiClient(baseUrl = "") }
|
||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
var ready by remember { mutableStateOf(false) }
|
var ready by remember { mutableStateOf(false) }
|
||||||
var authed by remember { mutableStateOf(false) }
|
var authed by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
|
@ -80,31 +78,23 @@ fun McNav() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
Box(Modifier.fillMaxSize().background(Color(0xFF04060A)), contentAlignment = Alignment.Center) {
|
Splash()
|
||||||
Text("Media Cluster", color = Color.White, fontSize = 28.sp)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
NavHost(navController = nav, startDestination = if (authed) "home" else "link") {
|
NavHost(navController = nav, startDestination = if (authed) "home" else "link") {
|
||||||
composable("link") {
|
composable("link") {
|
||||||
LinkScreen(
|
LinkScreen(api, store) {
|
||||||
api = api,
|
authed = true
|
||||||
store = store,
|
nav.navigate("home") { popUpTo("link") { inclusive = true } }
|
||||||
onLinked = {
|
}
|
||||||
authed = true
|
|
||||||
nav.navigate("home") {
|
|
||||||
popUpTo("link") { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("home") {
|
composable("home") {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
api = api,
|
api = api,
|
||||||
onPlayMovie = { id -> nav.navigate("movie/$id") },
|
onOpenMovie = { nav.navigate("movie/$it") },
|
||||||
onPlaySeries = { id -> nav.navigate("series/$id") },
|
onOpenSeries = { nav.navigate("series/$it") },
|
||||||
onPlayDirect = { mediaFileId, movieId, episodeId, seriesId, type, startMs ->
|
onPlay = { mediaFileId, movieId, episodeId, seriesId, type, startMs, _title ->
|
||||||
val m = movieId?.ifBlank { null } ?: "-"
|
val m = movieId?.ifBlank { null } ?: "-"
|
||||||
val e = episodeId?.ifBlank { null } ?: "-"
|
val e = episodeId?.ifBlank { null } ?: "-"
|
||||||
val s = seriesId?.ifBlank { null } ?: "-"
|
val s = seriesId?.ifBlank { null } ?: "-"
|
||||||
|
|
@ -112,41 +102,35 @@ fun McNav() {
|
||||||
},
|
},
|
||||||
onSettings = { nav.navigate("settings") },
|
onSettings = { nav.navigate("settings") },
|
||||||
onLogout = {
|
onLogout = {
|
||||||
kotlinx.coroutines.MainScope().launch {
|
scope.launch {
|
||||||
store.setToken(null)
|
store.setToken(null)
|
||||||
api.configure(store.apiBase.first(), null)
|
api.configure(store.apiBase.first(), null)
|
||||||
authed = false
|
authed = false
|
||||||
nav.navigate("link") {
|
nav.navigate("link") { popUpTo(0) { inclusive = true } }
|
||||||
popUpTo(0) { inclusive = true }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
||||||
"movie/{id}",
|
|
||||||
arguments = listOf(navArgument("id") { type = NavType.StringType }),
|
|
||||||
) { entry ->
|
|
||||||
val id = entry.arguments?.getString("id") ?: return@composable
|
val id = entry.arguments?.getString("id") ?: return@composable
|
||||||
DetailScreen(
|
MovieDetailScreen(
|
||||||
load = { api.movie(id) },
|
api = api,
|
||||||
onPlay = { mediaFileId ->
|
movieId = id,
|
||||||
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/0")
|
|
||||||
},
|
|
||||||
onBack = { nav.popBackStack() },
|
onBack = { nav.popBackStack() },
|
||||||
|
onPlay = { mediaFileId, title, startMs ->
|
||||||
|
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs")
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
||||||
"series/{id}",
|
|
||||||
arguments = listOf(navArgument("id") { type = NavType.StringType }),
|
|
||||||
) { entry ->
|
|
||||||
val id = entry.arguments?.getString("id") ?: return@composable
|
val id = entry.arguments?.getString("id") ?: return@composable
|
||||||
SeriesScreen(
|
SeriesDetailScreen(
|
||||||
load = { api.series(id) },
|
api = api,
|
||||||
onPlay = { mediaFileId, episodeId ->
|
seriesId = id,
|
||||||
|
onBack = { nav.popBackStack() },
|
||||||
|
onPlay = { mediaFileId, episodeId, _title ->
|
||||||
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0")
|
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0")
|
||||||
},
|
},
|
||||||
onBack = { nav.popBackStack() },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
|
|
@ -173,45 +157,51 @@ fun McNav() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("settings") {
|
composable("settings") {
|
||||||
SettingsScreen(
|
SettingsScreen(api = api, onBack = { nav.popBackStack() })
|
||||||
api = api,
|
|
||||||
store = store,
|
|
||||||
onBack = { nav.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun LinkScreen(
|
private fun Splash() {
|
||||||
api: ApiClient,
|
Box(
|
||||||
store: SessionStore,
|
Modifier.fillMaxSize().background(McColors.Bg),
|
||||||
onLinked: () -> Unit,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
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 code by remember { mutableStateOf<String?>(null) }
|
||||||
var linkUrl by remember { mutableStateOf("") }
|
var linkUrl by remember { mutableStateOf("") }
|
||||||
|
var status by remember { mutableStateOf("Verbinden…") }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var status by remember { mutableStateOf("Code ophalen…") }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val base = store.apiBase.first()
|
val base = store.apiBase.first()
|
||||||
api.configure(base, null)
|
api.configure(base, null)
|
||||||
try {
|
try {
|
||||||
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
|
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" }
|
||||||
val created = api.createDeviceCode(deviceName.ifBlank { "Android TV" })
|
val created = api.createDeviceCode(deviceName)
|
||||||
code = created.code
|
code = created.code
|
||||||
linkUrl = created.linkUrl
|
linkUrl = created.linkUrl
|
||||||
status = "Wachten op koppeling…"
|
status = "Wachten tot je de code bevestigt…"
|
||||||
while (true) {
|
while (true) {
|
||||||
delay((created.pollIntervalSeconds.coerceAtLeast(2)) * 1000L)
|
delay(created.pollIntervalSeconds.coerceAtLeast(2) * 1000L)
|
||||||
when (val poll = api.pollDeviceCode(created.code)) {
|
when (val poll = api.pollDeviceCode(created.code)) {
|
||||||
is PollResult.Linked -> {
|
is PollResult.Linked -> {
|
||||||
store.setToken(poll.refreshToken)
|
store.setToken(poll.refreshToken)
|
||||||
api.configure(base, poll.refreshToken)
|
api.configure(base, poll.refreshToken)
|
||||||
status = "Welkom ${poll.viewerName}"
|
status = "Welkom, ${poll.viewerName}"
|
||||||
delay(600)
|
delay(700)
|
||||||
onLinked()
|
onLinked()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -227,29 +217,50 @@ private fun LinkScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = e.message ?: "Kon niet verbinden met master"
|
error = e.message ?: "Kon master niet bereiken"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xFF04060A))
|
.background(
|
||||||
.padding(48.dp),
|
Brush.radialGradient(
|
||||||
|
colors = listOf(Color(0xFF12203A), McColors.Bg),
|
||||||
|
radius = 900f,
|
||||||
|
),
|
||||||
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(
|
||||||
Text("Media Cluster", fontSize = 40.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
Text("Koppel dit apparaat", fontSize = 22.sp, color = Color(0xFFB8C0D0))
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
Spacer(Modifier.height(12.dp))
|
modifier = Modifier
|
||||||
Text(code ?: "····-····", fontSize = 56.sp, fontWeight = FontWeight.Bold, color = Color(0xFF6EA8FF))
|
.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()) {
|
if (linkUrl.isNotBlank()) {
|
||||||
Text("Ga naar $linkUrl", fontSize = 18.sp, color = Color.White)
|
Text(linkUrl, color = McColors.Text, fontSize = 18.sp, fontWeight = FontWeight.Medium)
|
||||||
}
|
|
||||||
Text(status, fontSize = 16.sp, color = Color(0xFF9AA3B5))
|
|
||||||
if (error != null) {
|
|
||||||
Text(error!!, color = Color(0xFFFF8B8B), fontSize = 16.sp)
|
|
||||||
}
|
}
|
||||||
|
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
|
@Composable
|
||||||
private fun HomeScreen(
|
private fun HomeScreen(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
onPlayMovie: (String) -> Unit,
|
onOpenMovie: (String) -> Unit,
|
||||||
onPlaySeries: (String) -> Unit,
|
onOpenSeries: (String) -> Unit,
|
||||||
onPlayDirect: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
|
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long, title: String) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
) {
|
) {
|
||||||
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 loading by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
suspend fun reload() {
|
||||||
|
loading = true
|
||||||
try {
|
try {
|
||||||
home = api.home()
|
home = api.home()
|
||||||
|
error = null
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = e.message
|
error = e.message
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { reload() }
|
||||||
|
|
||||||
|
val hero = home?.continueWatching?.firstOrNull()
|
||||||
|
?: home?.shelves?.firstOrNull()?.items?.firstOrNull()
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
Modifier
|
Modifier.fillMaxSize().background(McColors.Bg),
|
||||||
.fillMaxSize()
|
contentPadding = PaddingValues(bottom = 48.dp),
|
||||||
.background(Color(0xFF04060A)),
|
|
||||||
contentPadding = PaddingValues(32.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(28.dp),
|
verticalArrangement = Arrangement.spacedBy(28.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Row(
|
HeroHeader(
|
||||||
Modifier.fillMaxWidth(),
|
hero = hero,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
onPlayHero = {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
val h = hero ?: return@HeroHeader
|
||||||
) {
|
when {
|
||||||
Text("Media Cluster", fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
!h.mediaFileId.isNullOrBlank() -> onPlay(
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
h.mediaFileId!!,
|
||||||
TvButton("Planken", onSettings)
|
h.id.takeIf { h.type == "movie" },
|
||||||
TvButton("Uitloggen", onLogout)
|
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) {
|
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()
|
val continueItems = home?.continueWatching.orEmpty()
|
||||||
if (continueItems.isNotEmpty()) {
|
if (continueItems.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
ShelfSection("Verder kijken", continueItems) { item ->
|
ShelfBlock("Verder kijken", continueItems) { item ->
|
||||||
val fileId = item.mediaFileId
|
when {
|
||||||
if (!fileId.isNullOrBlank()) {
|
!item.mediaFileId.isNullOrBlank() -> onPlay(
|
||||||
onPlayDirect(
|
item.mediaFileId!!,
|
||||||
fileId,
|
|
||||||
item.id.takeIf { item.type == "movie" },
|
item.id.takeIf { item.type == "movie" },
|
||||||
item.id.takeIf { item.type == "episode" },
|
item.id.takeIf { item.type == "episode" },
|
||||||
null,
|
null,
|
||||||
if (item.type == "episode") "EPISODE" else "MOVIE",
|
if (item.type == "episode") "EPISODE" else "MOVIE",
|
||||||
item.positionMs,
|
item.positionMs,
|
||||||
|
item.title,
|
||||||
)
|
)
|
||||||
} else if (item.type == "series" || item.type == "episode") {
|
item.type == "movie" -> onOpenMovie(item.id)
|
||||||
// fall through
|
else -> onOpenSeries(item.id)
|
||||||
} else {
|
|
||||||
onPlayMovie(item.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(home?.shelves.orEmpty()) { shelf ->
|
items(home?.shelves.orEmpty()) { shelf ->
|
||||||
ShelfSection(shelf.title, shelf.items) { item ->
|
ShelfBlock(shelf.title, shelf.items) { item ->
|
||||||
when (item.type) {
|
if (item.type == "movie") onOpenMovie(item.id) else onOpenSeries(item.id)
|
||||||
"movie" -> onPlayMovie(item.id)
|
|
||||||
else -> onPlaySeries(item.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -333,11 +368,76 @@ private fun HomeScreen(
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ShelfSection(title: String, items: List<MediaItem>, onClick: (MediaItem) -> Unit) {
|
private fun HeroHeader(
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
hero: MediaItem?,
|
||||||
Text(title, fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
onPlayHero: () -> Unit,
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
onOpenHero: () -> Unit,
|
||||||
items(items, key = { "${it.type}:${it.id}" }) { item ->
|
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) })
|
PosterCard(item, onClick = { onClick(item) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -346,66 +446,27 @@ private fun ShelfSection(title: String, items: List<MediaItem>, onClick: (MediaI
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun PosterCard(item: MediaItem, onClick: () -> Unit) {
|
private fun MovieDetailScreen(
|
||||||
Surface(
|
api: ApiClient,
|
||||||
onClick = onClick,
|
movieId: String,
|
||||||
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,
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onPlay: (mediaFileId: String, title: String, startMs: Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
var title by remember { mutableStateOf("…") }
|
var title by remember { mutableStateOf("…") }
|
||||||
var overview by remember { mutableStateOf("") }
|
var overview by remember { mutableStateOf("") }
|
||||||
var backdrop by remember { mutableStateOf<String?>(null) }
|
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 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()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(movieId) {
|
||||||
try {
|
try {
|
||||||
val o = load()
|
val o = api.movie(movieId)
|
||||||
title = o.optString("title")
|
title = o.optString("title")
|
||||||
overview = o.optString("overview")
|
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 arr = o.optJSONArray("mediaFiles")
|
||||||
val list = mutableListOf<Pair<String, String>>()
|
val list = mutableListOf<Pair<String, String>>()
|
||||||
if (arr != null) {
|
if (arr != null) {
|
||||||
|
|
@ -414,9 +475,9 @@ private fun DetailScreen(
|
||||||
val label = listOfNotNull(
|
val label = listOfNotNull(
|
||||||
f.optString("resolution").ifBlank { null },
|
f.optString("resolution").ifBlank { null },
|
||||||
f.optString("hdrType").ifBlank { null },
|
f.optString("hdrType").ifBlank { null },
|
||||||
f.optString("releaseName").ifBlank { null },
|
f.optString("videoCodec").ifBlank { null },
|
||||||
).joinToString(" · ")
|
).joinToString(" · ").ifBlank { "Afspelen" }
|
||||||
list += f.getString("id") to label.ifBlank { "Afspelen" }
|
list += f.getString("id") to label
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
files = list
|
files = list
|
||||||
|
|
@ -425,24 +486,41 @@ private fun DetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize().background(Color(0xFF04060A))) {
|
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||||
if (backdrop != null) {
|
AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||||
AsyncImage(
|
Box(Modifier.fillMaxSize().background(Brush.horizontalGradient(listOf(McColors.Bg, McColors.Bg.copy(alpha = 0.85f), Color.Transparent))))
|
||||||
model = backdrop,
|
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, McColors.Bg))))
|
||||||
contentDescription = null,
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
Modifier
|
||||||
contentScale = ContentScale.Crop,
|
.fillMaxHeight()
|
||||||
alpha = 0.35f,
|
.fillMaxWidth(0.55f)
|
||||||
)
|
.padding(40.dp),
|
||||||
}
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
Column(Modifier.padding(40.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
) {
|
||||||
TvButton("Terug", onBack)
|
McButton("Terug", onBack)
|
||||||
Text(title, fontSize = 36.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(overview, color = Color(0xFFD0D6E2), fontSize = 16.sp, maxLines = 6)
|
Text(title, color = McColors.Text, fontSize = 36.sp, fontWeight = FontWeight.Bold, maxLines = 3)
|
||||||
if (error != null) Text(error!!, color = Color(0xFFFF8B8B))
|
if (year != null) Text(year.toString(), color = McColors.TextDim, fontSize = 16.sp)
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Text(overview, color = McColors.TextDim, fontSize = 15.sp, maxLines = 8, overflow = TextOverflow.Ellipsis, lineHeight = 22.sp)
|
||||||
items(files) { (id, label) ->
|
if (error != null) Text(error!!, color = McColors.Danger)
|
||||||
TvButton(label.take(48), onClick = { onPlay(id) })
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
files.firstOrNull()?.let { (id, _) ->
|
||||||
|
McButton("Afspelen", { onPlay(id, title, 0) }, primary = true)
|
||||||
|
}
|
||||||
|
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) ->
|
||||||
|
McButton(label.take(40)) { onPlay(id, title, 0) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -451,21 +529,26 @@ private fun DetailScreen(
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SeriesScreen(
|
private fun SeriesDetailScreen(
|
||||||
load: suspend () -> JSONObject,
|
api: ApiClient,
|
||||||
onPlay: (mediaFileId: String, episodeId: String) -> Unit,
|
seriesId: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onPlay: (mediaFileId: String, episodeId: String, title: String) -> Unit,
|
||||||
) {
|
) {
|
||||||
var title by remember { mutableStateOf("…") }
|
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) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(seriesId) {
|
||||||
try {
|
try {
|
||||||
val o = load()
|
val o = api.series(seriesId)
|
||||||
title = o.optString("title")
|
title = o.optString("title")
|
||||||
|
overview = o.optString("overview")
|
||||||
|
backdrop = o.optString("backdropUrl").ifBlank { o.optString("posterUrl").ifBlank { null } }
|
||||||
val seasons = o.optJSONArray("seasons")
|
val seasons = o.optJSONArray("seasons")
|
||||||
val list = mutableListOf<Triple<String, String, String?>>()
|
val list = mutableListOf<Triple<String, String, String>>()
|
||||||
if (seasons != null) {
|
if (seasons != null) {
|
||||||
for (si in 0 until seasons.length()) {
|
for (si in 0 until seasons.length()) {
|
||||||
val season = seasons.getJSONObject(si)
|
val season = seasons.getJSONObject(si)
|
||||||
|
|
@ -473,8 +556,9 @@ private fun SeriesScreen(
|
||||||
for (ei in 0 until eps.length()) {
|
for (ei in 0 until eps.length()) {
|
||||||
val e = eps.getJSONObject(ei)
|
val e = eps.getJSONObject(ei)
|
||||||
if (!e.optBoolean("available", false)) continue
|
if (!e.optBoolean("available", false)) continue
|
||||||
val label = "S${e.optInt("seasonNumber")}E${e.optInt("episodeNumber")} · ${e.optString("title")}"
|
val fileId = e.optString("mediaFileId").ifBlank { null } ?: continue
|
||||||
list += Triple(e.getString("id"), label, e.optString("mediaFileId").ifBlank { null })
|
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(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
|
||||||
Modifier.fillMaxSize().background(Color(0xFF04060A)).padding(32.dp),
|
AsyncImage(model = backdrop, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, alpha = 0.35f)
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
Box(Modifier.fillMaxSize().background(McColors.HeroScrim))
|
||||||
) {
|
LazyColumn(
|
||||||
item {
|
contentPadding = PaddingValues(40.dp),
|
||||||
TvButton("Terug", onBack)
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
Spacer(Modifier.height(8.dp))
|
) {
|
||||||
Text(title, fontSize = 32.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
item {
|
||||||
if (error != null) Text(error!!, color = Color(0xFFFF8B8B))
|
McButton("Terug", onBack)
|
||||||
}
|
Spacer(Modifier.height(12.dp))
|
||||||
items(episodes) { (id, label, fileId) ->
|
Text(title, color = McColors.Text, fontSize = 34.sp, fontWeight = FontWeight.Bold)
|
||||||
if (fileId != null) {
|
Spacer(Modifier.height(6.dp))
|
||||||
TvButton(label, onClick = { onPlay(fileId, id) })
|
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) ->
|
||||||
}
|
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)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsScreen(
|
private fun SettingsScreen(api: ApiClient, onBack: () -> Unit) {
|
||||||
api: ApiClient,
|
|
||||||
store: SessionStore,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
|
var prefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
@ -620,26 +619,30 @@ private fun SettingsScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
Modifier.fillMaxSize().background(Color(0xFF04060A)).padding(32.dp),
|
Modifier.fillMaxSize().background(McColors.Bg).padding(40.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
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))
|
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 ->
|
itemsIndexed(prefs) { index, p ->
|
||||||
val p = prefs[index]
|
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(McColors.BgElevated)
|
||||||
|
.padding(16.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
TvButton("Omhoog") {
|
McButton("↑") {
|
||||||
if (index > 0) {
|
if (index > 0) {
|
||||||
val next = prefs.toMutableList()
|
val next = prefs.toMutableList()
|
||||||
val tmp = next[index - 1]
|
val tmp = next[index - 1]
|
||||||
|
|
@ -648,7 +651,7 @@ private fun SettingsScreen(
|
||||||
persist(next)
|
persist(next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TvButton("Omlaag") {
|
McButton("↓") {
|
||||||
if (index < prefs.lastIndex) {
|
if (index < prefs.lastIndex) {
|
||||||
val next = prefs.toMutableList()
|
val next = prefs.toMutableList()
|
||||||
val tmp = next[index + 1]
|
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
|
package nl.vonas.mediacluster.tv.ui.theme
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.darkColorScheme
|
import androidx.tv.material3.darkColorScheme
|
||||||
|
|
||||||
private val Scheme = darkColorScheme(
|
private val Scheme = darkColorScheme(
|
||||||
primary = Color(0xFF6EA8FF),
|
primary = McColors.Accent,
|
||||||
onPrimary = Color(0xFF001B3D),
|
onPrimary = McColors.Bg,
|
||||||
surface = Color(0xFF0A0E16),
|
surface = McColors.BgElevated,
|
||||||
onSurface = Color(0xFFF2F5FA),
|
onSurface = McColors.Text,
|
||||||
border = Color(0x33FFFFFF),
|
border = McColors.AccentSoft,
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaClusterTheme(content: @Composable () -> Unit) {
|
fun MediaClusterTheme(content: @Composable () -> Unit) {
|
||||||
MaterialTheme(
|
MaterialTheme(colorScheme = Scheme, content = content)
|
||||||
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