Add app/src/main/java/space/bloggin/whosthat
This commit is contained in:
@@ -0,0 +1,142 @@
|
|||||||
|
package space.bloggin.whosthat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.aspectRatio
|
||||||
|
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.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
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.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
|
||||||
|
val Square = RoundedCornerShape(0)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PrimaryBtn(text: String, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) {
|
||||||
|
Button(onClick = onClick, enabled = enabled, shape = Square, modifier = modifier,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Accent, contentColor = Color.Black),
|
||||||
|
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 12.dp)) {
|
||||||
|
Text(text, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SecondaryBtn(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
OutlinedButton(onClick = onClick, shape = Square, modifier = modifier,
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.dp, Line),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp)) {
|
||||||
|
Text(text, fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Chip(text: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.background(if (selected) Accent else Panel)
|
||||||
|
.border(1.dp, if (selected) Accent else Line)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 9.dp)
|
||||||
|
) {
|
||||||
|
Text(text, color = if (selected) Color.Black else Color.White, fontSize = 14.sp,
|
||||||
|
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TopBar(title: String, onBack: (() -> Unit)?, right: @Composable (() -> Unit)? = null) {
|
||||||
|
Row(Modifier.fillMaxWidth().background(Bg).padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (onBack != null) {
|
||||||
|
Box(Modifier.size(48.dp).clickable(onClick = onBack), contentAlignment = Alignment.Center) {
|
||||||
|
Text("←", color = Color.White, fontSize = 26.sp)
|
||||||
|
}
|
||||||
|
} else Spacer(Modifier.width(10.dp))
|
||||||
|
Text(title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||||
|
if (right != null) right()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Loading() {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(color = Accent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorBox(msg: String, onRetry: () -> Unit) {
|
||||||
|
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(msg, color = Color.White, fontSize = 16.sp, textAlign = TextAlign.Center)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
PrimaryBtn("Try again", onClick = onRetry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A poster/headshot with a plain placeholder when there's no image. */
|
||||||
|
@Composable
|
||||||
|
fun Pic(url: String?, label: String, modifier: Modifier) {
|
||||||
|
Box(modifier.background(Panel), contentAlignment = Alignment.Center) {
|
||||||
|
if (url == null) {
|
||||||
|
Text(label.split(" ").filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1) }.uppercase(),
|
||||||
|
color = Dim, fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
||||||
|
} else {
|
||||||
|
AsyncImage(model = url, contentDescription = label, contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CastCard(m: Tmdb.CastMember, onClick: () -> Unit) {
|
||||||
|
Column(Modifier.clickable(onClick = onClick).background(Panel)) {
|
||||||
|
Pic(Tmdb.img(m.profile, "w342"), m.name, Modifier.fillMaxWidth().aspectRatio(2f / 3f))
|
||||||
|
Column(Modifier.padding(horizontal = 8.dp, vertical = 7.dp)) {
|
||||||
|
Text(m.name, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp, maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis, lineHeight = 17.sp)
|
||||||
|
if (m.character.isNotBlank())
|
||||||
|
Text(m.character, color = Accent, fontSize = 13.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, lineHeight = 16.sp)
|
||||||
|
if (m.note.isNotBlank())
|
||||||
|
Text(m.note, color = Dim, fontSize = 12.sp, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ageOn(birthday: String?, year: String): Int? {
|
||||||
|
if (birthday == null || birthday.length < 4 || year.length < 4) return null
|
||||||
|
return year.toIntOrNull()?.minus(birthday.substring(0, 4).toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ageNow(birthday: String?, deathday: String?): Int? {
|
||||||
|
if (birthday == null || birthday.length < 10) return null
|
||||||
|
val b = java.time.LocalDate.parse(birthday.substring(0, 10))
|
||||||
|
val end = if (deathday != null && deathday.length >= 10) java.time.LocalDate.parse(deathday.substring(0, 10))
|
||||||
|
else java.time.LocalDate.now()
|
||||||
|
return java.time.Period.between(b, end).years
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package space.bloggin.whosthat
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.imePadding
|
||||||
|
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.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
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.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(store: Store, onOpen: (Tmdb.Hit) -> Unit, onAbout: () -> Unit) {
|
||||||
|
var q by rememberSaveable { mutableStateOf("") }
|
||||||
|
var results by remember { mutableStateOf<List<Tmdb.Hit>>(emptyList()) }
|
||||||
|
var error by remember { mutableStateOf("") }
|
||||||
|
var loading by remember { mutableStateOf(false) }
|
||||||
|
var recent by remember { mutableStateOf(store.recent()) }
|
||||||
|
var filter by rememberSaveable { mutableStateOf("all") }
|
||||||
|
|
||||||
|
LaunchedEffect(q) {
|
||||||
|
if (q.trim().length < 2) { results = emptyList(); error = ""; return@LaunchedEffect }
|
||||||
|
delay(350)
|
||||||
|
loading = true
|
||||||
|
try { results = Tmdb.search(q.trim()); error = "" } catch (e: Exception) { error = e.message ?: "Couldn't reach TMDB." }
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().imePadding()) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 6.dp, top = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("WHO'S ", color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Black)
|
||||||
|
Text("THAT", color = Accent, fontSize = 26.sp, fontWeight = FontWeight.Black)
|
||||||
|
Text("?", color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Black, modifier = Modifier.weight(1f))
|
||||||
|
Box(Modifier.size(48.dp).clickable(onClick = onAbout), contentAlignment = Alignment.Center) {
|
||||||
|
Text("☰", color = Color.White, fontSize = 24.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = q, onValueChange = { q = it },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
placeholder = { Text(if (filter == "person") "Type an actor's name" else "What are you watching? (or an actor's name)", color = Dim) },
|
||||||
|
singleLine = true, shape = Square,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
trailingIcon = {
|
||||||
|
if (q.isNotEmpty()) Box(Modifier.size(44.dp).clickable { q = "" }, contentAlignment = Alignment.Center) {
|
||||||
|
Text("✕", color = Dim, fontSize = 18.sp)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = Accent, unfocusedBorderColor = Line, cursorColor = Accent)
|
||||||
|
)
|
||||||
|
Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Chip("All", filter == "all") { filter = "all" }
|
||||||
|
Chip("Shows & Movies", filter == "titles") { filter = "titles" }
|
||||||
|
Chip("Actors", filter == "person") { filter = "person" }
|
||||||
|
}
|
||||||
|
val shown = when (filter) {
|
||||||
|
"person" -> results.filter { it.type == "person" }
|
||||||
|
"titles" -> results.filter { it.type != "person" }
|
||||||
|
else -> results
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
q.trim().length >= 2 -> {
|
||||||
|
if (error.isNotBlank()) Text(error, color = Color(0xFFFF6B6B), modifier = Modifier.padding(16.dp))
|
||||||
|
if (loading && results.isEmpty()) Loading()
|
||||||
|
else if (shown.isEmpty() && !loading) Text(
|
||||||
|
if (filter == "person") "No actors match that name." else "Nothing matches that.",
|
||||||
|
color = Dim, fontSize = 16.sp, modifier = Modifier.padding(16.dp))
|
||||||
|
else HitList(shown, onOpen)
|
||||||
|
}
|
||||||
|
recent.isNotEmpty() -> {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("RECENT", color = Dim, fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||||
|
Text("Clear", color = Dim, fontSize = 13.sp, modifier = Modifier.clickable { store.clearRecent(); recent = emptyList() }.padding(8.dp))
|
||||||
|
}
|
||||||
|
HitList(recent, onOpen)
|
||||||
|
}
|
||||||
|
else -> Column(Modifier.fillMaxSize().padding(28.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("Type the name of the movie or show you're watching.", color = Color.White, fontSize = 18.sp)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text("Then tap the face you're wondering about to see who it is and where else you've seen them.", color = Dim, fontSize = 16.sp)
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
Text("Know the actor but not the show? Tap Actors, type their name, and choose Newest first to see what they're in right now.", color = Dim, fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HitList(list: List<Tmdb.Hit>, onOpen: (Tmdb.Hit) -> Unit) {
|
||||||
|
LazyColumn(Modifier.fillMaxSize()) {
|
||||||
|
items(list, key = { "${it.type}:${it.id}" }) { h ->
|
||||||
|
Row(Modifier.fillMaxWidth().clickable { onOpen(h) }.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Pic(Tmdb.img(h.poster, "w154"), h.title, Modifier.width(56.dp).height(84.dp))
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(h.title + if (h.year.isNotBlank()) " (${h.year})" else "", color = Color.White,
|
||||||
|
fontSize = 17.sp, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(h.sub, color = if (h.type == "person") Accent else Dim, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).padding(horizontal = 16.dp).background(Line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AboutScreen(store: Store, familyBuild: Boolean, onBack: () -> Unit, onChangeKey: () -> Unit) {
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
TopBar("About", onBack = onBack)
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp)) {
|
||||||
|
Text("Who's That", color = Accent, fontSize = 26.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Text("Version ${BuildConfig.VERSION_NAME}" + if (familyBuild) " (family edition)" else "", color = Dim, fontSize = 14.sp)
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
Text("Search for what you're watching, pick the season and episode for TV shows, and tap any face to see who it is, how old they are, and everything else they've been in. " +
|
||||||
|
"Mark shows and movies as watched, and those will always be listed first so you can spot where you know someone from.",
|
||||||
|
color = Color.White, fontSize = 16.sp, lineHeight = 22.sp)
|
||||||
|
Spacer(Modifier.height(22.dp))
|
||||||
|
Column(Modifier.fillMaxWidth().background(Panel).border(1.dp, Line).padding(16.dp)) {
|
||||||
|
Text("Data from TMDB", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text("This product uses the TMDB API but is not endorsed or certified by TMDB.", color = Dim, fontSize = 14.sp)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
SecondaryBtn("Visit themoviedb.org") { ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.themoviedb.org"))) }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(22.dp))
|
||||||
|
Text("You have ${store.watched().size} titles marked as watched.", color = Dim, fontSize = 15.sp)
|
||||||
|
Spacer(Modifier.height(22.dp))
|
||||||
|
SecondaryBtn(if (familyBuild && store.cred.isBlank()) "Use my own TMDB key instead" else "Change my TMDB key", Modifier.fillMaxWidth(), onClick = onChangeKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package space.bloggin.whosthat
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
val Bg = Color(0xFF0E0E0E)
|
||||||
|
val Panel = Color(0xFF1B1B1B)
|
||||||
|
val Line = Color(0xFF2E2E2E)
|
||||||
|
val Accent = Color(0xFFFFD60A)
|
||||||
|
val Dim = Color(0xFF9A9A9A)
|
||||||
|
|
||||||
|
sealed class Screen {
|
||||||
|
data object Setup : Screen()
|
||||||
|
data object Home : Screen()
|
||||||
|
data object About : Screen()
|
||||||
|
data class Title(val type: String, val id: Int) : Screen()
|
||||||
|
data class PersonPage(val id: Int, val contextYear: String = "", val contextTitle: String = "") : Screen()
|
||||||
|
}
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent { App() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun App() {
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
val store = remember { Store(ctx) }
|
||||||
|
val builtIn = BuildConfig.BUILTIN_TOKEN
|
||||||
|
val startCred = store.cred.ifBlank { builtIn }
|
||||||
|
Tmdb.cred = startCred
|
||||||
|
val stack = remember { mutableStateListOf<Screen>(if (startCred.isBlank()) Screen.Setup else Screen.Home) }
|
||||||
|
|
||||||
|
fun go(s: Screen) { stack.add(s) }
|
||||||
|
fun back() { if (stack.size > 1) stack.removeAt(stack.lastIndex) }
|
||||||
|
|
||||||
|
val square = RoundedCornerShape(0)
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = darkColorScheme(
|
||||||
|
primary = Accent, onPrimary = Color.Black, background = Bg, surface = Panel,
|
||||||
|
onBackground = Color.White, onSurface = Color.White, surfaceVariant = Panel, outline = Line
|
||||||
|
),
|
||||||
|
shapes = Shapes(square, square, square, square, square)
|
||||||
|
) {
|
||||||
|
BackHandler(enabled = stack.size > 1) { back() }
|
||||||
|
Box(Modifier.fillMaxSize().background(Bg).systemBarsPadding()) {
|
||||||
|
when (val s = stack.last()) {
|
||||||
|
Screen.Setup -> SetupScreen(
|
||||||
|
canCancel = stack.size > 1,
|
||||||
|
onDone = { cred ->
|
||||||
|
store.cred = cred; Tmdb.cred = cred
|
||||||
|
stack.clear(); stack.add(Screen.Home)
|
||||||
|
},
|
||||||
|
onCancel = { back() })
|
||||||
|
Screen.Home -> HomeScreen(store, onOpen = { h ->
|
||||||
|
store.addRecent(h)
|
||||||
|
if (h.type == "person") go(Screen.PersonPage(h.id)) else go(Screen.Title(h.type, h.id))
|
||||||
|
}, onAbout = { go(Screen.About) })
|
||||||
|
Screen.About -> AboutScreen(store, builtIn.isNotBlank(), onBack = { back() }, onChangeKey = { go(Screen.Setup) })
|
||||||
|
is Screen.Title -> TitleScreen(store, s.type, s.id, onBack = { back() },
|
||||||
|
onPerson = { pid, year, title -> go(Screen.PersonPage(pid, year, title)) })
|
||||||
|
is Screen.PersonPage -> PersonScreen(store, s.id, s.contextYear, s.contextTitle, onBack = { back() },
|
||||||
|
onTitle = { type, id -> go(Screen.Title(type, id)) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package space.bloggin.whosthat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
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.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PersonScreen(store: Store, id: Int, contextYear: String, contextTitle: String, onBack: () -> Unit, onTitle: (String, Int) -> Unit) {
|
||||||
|
var person by remember { mutableStateOf<Tmdb.Person?>(null) }
|
||||||
|
var error by remember { mutableStateOf("") }
|
||||||
|
var reload by remember { mutableIntStateOf(0) }
|
||||||
|
var showBio by remember { mutableStateOf(false) }
|
||||||
|
var showAll by remember { mutableStateOf(false) }
|
||||||
|
var mode by remember { mutableStateOf(if (contextTitle.isBlank()) "new" else "known") }
|
||||||
|
|
||||||
|
LaunchedEffect(reload) {
|
||||||
|
error = ""
|
||||||
|
try { person = Tmdb.person(id) } catch (e: Exception) { error = e.message ?: "Couldn't reach TMDB." }
|
||||||
|
}
|
||||||
|
|
||||||
|
val p = person
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
TopBar(p?.name ?: "", onBack = onBack)
|
||||||
|
when {
|
||||||
|
error.isNotBlank() && p == null -> ErrorBox(error) { reload++ }
|
||||||
|
p == null -> Loading()
|
||||||
|
else -> {
|
||||||
|
val watched = store.watched()
|
||||||
|
val sorted = when (mode) {
|
||||||
|
"new" -> p.credits.sortedWith(compareByDescending<Tmdb.Credit> { it.year.ifBlank { "9999" } }.thenByDescending { it.votes })
|
||||||
|
"movie" -> p.credits.filter { it.type == "movie" }
|
||||||
|
"tv" -> p.credits.filter { it.type == "tv" }
|
||||||
|
else -> p.credits
|
||||||
|
}
|
||||||
|
val seen = if (mode == "known") sorted.filter { "${it.type}:${it.id}" in watched } else emptyList()
|
||||||
|
val rest = if (mode == "known") sorted.filterNot { "${it.type}:${it.id}" in watched } else sorted
|
||||||
|
val shown = if (showAll) rest else rest.take(30)
|
||||||
|
LazyColumn(Modifier.fillMaxSize()) {
|
||||||
|
item {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
|
Pic(Tmdb.img(p.profile, "w342"), p.name, Modifier.width(130.dp).height(195.dp))
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(p.name, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold, lineHeight = 28.sp)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
val now = ageNow(p.birthday, p.deathday)
|
||||||
|
if (now != null) {
|
||||||
|
val line = if (p.deathday != null) "Died ${p.deathday.take(4)} at $now" else "Age $now"
|
||||||
|
Text(line, color = Color.White, fontSize = 17.sp)
|
||||||
|
}
|
||||||
|
val then = ageOn(p.birthday, contextYear)
|
||||||
|
if (then != null && contextTitle.isNotBlank())
|
||||||
|
Text("About $then in $contextTitle ($contextYear)", color = Accent, fontSize = 15.sp)
|
||||||
|
if (p.place.isNotBlank()) {
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text("Born in ${p.place}", color = Dim, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text("${p.credits.size} roles", color = Dim, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (p.bio.isNotBlank()) item {
|
||||||
|
Text(p.bio, color = Color(0xFFCCCCCC), fontSize = 14.sp, lineHeight = 20.sp,
|
||||||
|
maxLines = if (showBio) Int.MAX_VALUE else 3, overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp).clickable { showBio = !showBio })
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Box(Modifier.fillMaxWidth().padding(16.dp).background(if (seen.isNotEmpty()) Accent else Panel).padding(14.dp)) {
|
||||||
|
Text(
|
||||||
|
if (seen.isNotEmpty()) "You've seen them in ${seen.size} thing${if (seen.size == 1) "" else "s"} you marked as watched"
|
||||||
|
else "None of the shows or movies you've marked as watched. Their best-known roles are below.",
|
||||||
|
color = if (seen.isNotEmpty()) Color.Black else Color.White, fontSize = 15.sp, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
androidx.compose.foundation.lazy.LazyRow(Modifier.padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
|
||||||
|
item { Chip("Best known", mode == "known") { mode = "known"; showAll = false } }
|
||||||
|
item { Chip("Newest first", mode == "new") { mode = "new"; showAll = false } }
|
||||||
|
item { Chip("Movies", mode == "movie") { mode = "movie"; showAll = false } }
|
||||||
|
item { Chip("TV", mode == "tv") { mode = "tv"; showAll = false } }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
}
|
||||||
|
if (seen.isNotEmpty()) {
|
||||||
|
item { Section("YOU'VE WATCHED") }
|
||||||
|
items(seen, key = { "s${it.type}${it.id}" }) { c -> CreditRow(c, true) { onTitle(c.type, c.id) } }
|
||||||
|
}
|
||||||
|
item { Section(when (mode) {
|
||||||
|
"new" -> "NEWEST FIRST (${rest.size})"
|
||||||
|
"movie" -> "MOVIES (${rest.size})"
|
||||||
|
"tv" -> "TV SHOWS (${rest.size})"
|
||||||
|
else -> if (seen.isNotEmpty()) "EVERYTHING ELSE, BEST KNOWN FIRST" else "BEST KNOWN FOR"
|
||||||
|
}) }
|
||||||
|
items(shown, key = { "r${it.type}${it.id}" }) { c -> CreditRow(c, "${c.type}:${c.id}" in watched) { onTitle(c.type, c.id) } }
|
||||||
|
if (!showAll && rest.size > shown.size) item {
|
||||||
|
Box(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
|
SecondaryBtn("Show all ${rest.size}", Modifier.fillMaxWidth()) { showAll = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(24.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Section(t: String) {
|
||||||
|
Text(t, color = Dim, fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 16.dp, top = 6.dp, bottom = 6.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CreditRow(c: Tmdb.Credit, seen: Boolean, onClick: () -> Unit) {
|
||||||
|
Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 7.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Pic(Tmdb.img(c.poster, "w154"), c.title, Modifier.width(54.dp).height(81.dp))
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(c.title + if (c.year.isNotBlank()) " (${c.year})" else " (upcoming)", color = Color.White, fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
if (c.character.isNotBlank()) Text("as ${c.character}", color = Accent, fontSize = 14.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
val bits = listOfNotNull(if (c.type == "tv") "TV" else "Movie", if (c.type == "tv" && c.episodes > 0) "${c.episodes} ep" else null)
|
||||||
|
Text(bits.joinToString(" · "), color = Dim, fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
if (seen) Text("WATCHED", color = Color.Black, fontSize = 11.sp, fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.background(Accent).border(1.dp, Accent).padding(horizontal = 6.dp, vertical = 3.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package space.bloggin.whosthat
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private const val STEPS = 6
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SetupScreen(canCancel: Boolean, onDone: (String) -> Unit, onCancel: () -> Unit) {
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
var step by remember { mutableIntStateOf(0) }
|
||||||
|
fun open(url: String) = ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||||
|
fun copy(label: String, text: String) {
|
||||||
|
(ctx.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(ClipData.newPlainText(label, text))
|
||||||
|
Toast.makeText(ctx, "Copied", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().imePadding()) {
|
||||||
|
TopBar("Set up Who's That", onBack = if (canCancel) onCancel else null)
|
||||||
|
// progress bar made of square blocks
|
||||||
|
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
repeat(STEPS) { i ->
|
||||||
|
Box(Modifier.weight(1f).height(6.dp).background(if (i <= step) Accent else Line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Column(Modifier.widthIn(max = 640.dp).fillMaxWidth()) {
|
||||||
|
when (step) {
|
||||||
|
0 -> {
|
||||||
|
H("Welcome")
|
||||||
|
P("Who's That shows you the cast of whatever movie or TV show you're watching, and tells you where else you've seen each actor.")
|
||||||
|
P("The movie and TV information comes from TMDB (The Movie Database). It's free, but each person needs their own free TMDB key. Getting one takes about five minutes, and this setup walks you through every step.")
|
||||||
|
Note("Tip: the TMDB sign-up pages work best on a computer. You can do the steps there and type or paste the code into this phone at the end.")
|
||||||
|
}
|
||||||
|
1 -> {
|
||||||
|
H("Step 1: Make a TMDB account")
|
||||||
|
P("Tap the button below to open TMDB's sign-up page. Pick a username and password and enter your email.")
|
||||||
|
PrimaryBtn("Open TMDB sign up", Modifier.fillMaxWidth()) { open("https://www.themoviedb.org/signup") }
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
P("TMDB will email you a verification link. Open that email and tap the link, then log in.")
|
||||||
|
Note("Come back to this app when you're logged in.")
|
||||||
|
}
|
||||||
|
2 -> {
|
||||||
|
H("Step 2: Open the API page")
|
||||||
|
P("While logged in, tap the button below. It opens your TMDB settings on the API page. (Or, on the website: click your profile picture at the top right, choose Settings, then click API on the left.)")
|
||||||
|
PrimaryBtn("Open TMDB API settings", Modifier.fillMaxWidth()) { open("https://www.themoviedb.org/settings/api") }
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
P("You'll see an overview page with a Create button. Tap Create.")
|
||||||
|
P("If it asks which kind of key you want, choose Developer. That's the free one for personal use.")
|
||||||
|
P("Read the terms of use, scroll to the bottom and tap Accept.")
|
||||||
|
}
|
||||||
|
3 -> {
|
||||||
|
H("Step 3: Fill in the form")
|
||||||
|
P("TMDB asks a few questions about your app. Use these answers (tap Copy to copy each one):")
|
||||||
|
Field("Type of use", "Personal", ::copy)
|
||||||
|
Field("Application name", "Who's That", ::copy)
|
||||||
|
Field("Application URL", "https://www.themoviedb.org", ::copy)
|
||||||
|
Field("Application summary",
|
||||||
|
"Personal app for looking up the cast of movies and TV shows I'm watching, and what else the actors have been in. Not commercial, no ads.", ::copy)
|
||||||
|
P("It also asks for your name, address and phone number. That's only for TMDB's records and isn't shown to anyone.")
|
||||||
|
Note("The button at the bottom may say Subscribe. That's normal. The page should say FREE. If it ever asks for a credit card, stop and go back, because you picked the paid option by mistake.")
|
||||||
|
}
|
||||||
|
4 -> {
|
||||||
|
H("Step 4: Find your codes")
|
||||||
|
P("After you submit the form, TMDB shows two codes:")
|
||||||
|
P("API Read Access Token: a very long code that starts with eyJ. This is the one you want.")
|
||||||
|
P("API Key: a shorter code of 32 letters and numbers. This one works too if it's easier to copy.")
|
||||||
|
Note("You can always find these again on the same TMDB settings page under API.")
|
||||||
|
PrimaryBtn("Open TMDB API settings", Modifier.fillMaxWidth()) { open("https://www.themoviedb.org/settings/api") }
|
||||||
|
}
|
||||||
|
5 -> KeyEntry(onDone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (step < STEPS - 1) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
if (step > 0) SecondaryBtn("Back", Modifier.weight(1f)) { step-- }
|
||||||
|
PrimaryBtn(if (step == 0) "Let's go" else "Next", Modifier.weight(1f)) { step++ }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
|
SecondaryBtn("Back", Modifier.fillMaxWidth()) { step-- }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KeyEntry(onDone: (String) -> Unit) {
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var text by remember { mutableStateOf("") }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
var error by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
H("Step 5: Paste your code")
|
||||||
|
P("Copy the API Read Access Token (or the API Key) from TMDB and paste it here.")
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text, onValueChange = { text = it.trim(); error = "" },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(140.dp),
|
||||||
|
placeholder = { Text("eyJhbGciOi...", color = Dim) },
|
||||||
|
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = Color.White),
|
||||||
|
shape = Square,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = Accent, unfocusedBorderColor = Line, cursorColor = Accent)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
SecondaryBtn("Paste") {
|
||||||
|
val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
val t = cm.primaryClip?.getItemAt(0)?.coerceToText(ctx)?.toString()?.trim().orEmpty()
|
||||||
|
if (t.isNotBlank()) { text = t; error = "" }
|
||||||
|
}
|
||||||
|
SecondaryBtn("Clear") { text = ""; error = "" }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
PrimaryBtn(if (busy) "Checking..." else "Test & save", Modifier.fillMaxWidth(), enabled = !busy && text.length >= 32) {
|
||||||
|
busy = true; error = ""
|
||||||
|
scope.launch {
|
||||||
|
val ok = Tmdb.validate(text)
|
||||||
|
busy = false
|
||||||
|
if (ok) onDone(text) else error = "That code didn't work. Make sure you copied the whole thing, with no spaces at the start or end. The Read Access Token is the long one that starts with eyJ."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error.isNotBlank()) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(error, color = Color(0xFFFF6B6B), fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable private fun H(t: String) {
|
||||||
|
Text(t, color = Accent, fontSize = 26.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable private fun P(t: String) {
|
||||||
|
Text(t, color = Color.White, fontSize = 17.sp, lineHeight = 24.sp)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable private fun Note(t: String) {
|
||||||
|
Text(t, color = Color.White, fontSize = 15.sp, lineHeight = 21.sp,
|
||||||
|
modifier = Modifier.fillMaxWidth().background(Panel).border(1.dp, Line).padding(14.dp))
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable private fun Field(label: String, value: String, copy: (String, String) -> Unit) {
|
||||||
|
Column(Modifier.fillMaxWidth().background(Panel).border(1.dp, Line).padding(12.dp)) {
|
||||||
|
Text(label, color = Dim, fontSize = 13.sp)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(value, color = Color.White, fontSize = 16.sp, modifier = Modifier.weight(1f))
|
||||||
|
SecondaryBtn("Copy") { copy(label, value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package space.bloggin.whosthat
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
|
||||||
|
/** Small local storage: the TMDB credential, recently opened titles, and what you've watched. */
|
||||||
|
class Store(ctx: Context) {
|
||||||
|
private val p = ctx.getSharedPreferences("whosthat", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
var cred: String
|
||||||
|
get() = p.getString("cred", "") ?: ""
|
||||||
|
set(v) { p.edit().putString("cred", v).apply() }
|
||||||
|
|
||||||
|
fun recent(): List<Tmdb.Hit> = try {
|
||||||
|
val a = JSONArray(p.getString("recent", "[]"))
|
||||||
|
(0 until a.length()).map { Tmdb.Hit.fromJson(a.getJSONObject(it)) }
|
||||||
|
} catch (e: Exception) { emptyList() }
|
||||||
|
|
||||||
|
fun addRecent(h: Tmdb.Hit) {
|
||||||
|
val list = (listOf(h) + recent().filterNot { it.type == h.type && it.id == h.id }).take(24)
|
||||||
|
val a = JSONArray(); list.forEach { a.put(it.toJson()) }
|
||||||
|
p.edit().putString("recent", a.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearRecent() { p.edit().remove("recent").apply() }
|
||||||
|
|
||||||
|
fun watched(): Set<String> = p.getStringSet("watched", emptySet()) ?: emptySet()
|
||||||
|
fun isWatched(type: String, id: Int) = "$type:$id" in watched()
|
||||||
|
fun setWatched(type: String, id: Int, on: Boolean) {
|
||||||
|
val s = watched().toMutableSet()
|
||||||
|
if (on) s += "$type:$id" else s -= "$type:$id"
|
||||||
|
p.edit().putStringSet("watched", s).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package space.bloggin.whosthat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.aspectRatio
|
||||||
|
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.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TitleScreen(store: Store, type: String, id: Int, onBack: () -> Unit, onPerson: (Int, String, String) -> Unit) {
|
||||||
|
var info by remember { mutableStateOf<Tmdb.TitleInfo?>(null) }
|
||||||
|
var error by remember { mutableStateOf("") }
|
||||||
|
var reload by remember { mutableIntStateOf(0) }
|
||||||
|
var watched by remember { mutableStateOf(store.isWatched(type, id)) }
|
||||||
|
|
||||||
|
var season by rememberSaveable { mutableIntStateOf(0) } // 0 = whole series
|
||||||
|
var episode by rememberSaveable { mutableIntStateOf(0) }
|
||||||
|
var episodes by remember { mutableStateOf<List<Tmdb.Episode>>(emptyList()) }
|
||||||
|
var epCast by remember { mutableStateOf<Pair<List<Tmdb.CastMember>, List<Tmdb.CastMember>>?>(null) }
|
||||||
|
var epLoading by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(reload) {
|
||||||
|
error = ""
|
||||||
|
try {
|
||||||
|
val t = if (type == "movie") Tmdb.movie(id) else Tmdb.tv(id)
|
||||||
|
info = t
|
||||||
|
store.addRecent(Tmdb.Hit(type, id, t.title, t.year, t.poster, if (type == "movie") "Movie" else "TV show"))
|
||||||
|
} catch (e: Exception) { error = e.message ?: "Couldn't reach TMDB." }
|
||||||
|
}
|
||||||
|
LaunchedEffect(season) {
|
||||||
|
episodes = emptyList()
|
||||||
|
if (season > 0) try { episodes = Tmdb.episodes(id, season) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
LaunchedEffect(season, episode) {
|
||||||
|
epCast = null
|
||||||
|
if (season > 0 && episode > 0) {
|
||||||
|
epLoading = true
|
||||||
|
try { epCast = Tmdb.episodeCast(id, season, episode) } catch (_: Exception) {}
|
||||||
|
epLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val t = info
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
TopBar(t?.title ?: "", onBack = onBack)
|
||||||
|
when {
|
||||||
|
error.isNotBlank() && t == null -> ErrorBox(error) { reload++ }
|
||||||
|
t == null -> Loading()
|
||||||
|
else -> {
|
||||||
|
val contextYear = t.year
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(118.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 24.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Header(t, watched) { watched = !watched; store.setWatched(type, id, watched) }
|
||||||
|
}
|
||||||
|
if (t.type == "tv" && t.seasons.isNotEmpty()) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Column {
|
||||||
|
Label("SEASON")
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
item { Chip("Whole series", season == 0) { season = 0; episode = 0 } }
|
||||||
|
items(t.seasons) { s -> Chip("Season ${s.number}", season == s.number) { season = s.number; episode = 0 } }
|
||||||
|
}
|
||||||
|
if (season > 0) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Label("EPISODE")
|
||||||
|
if (episodes.isEmpty()) Text("Loading episodes...", color = Dim, fontSize = 14.sp)
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
item { Chip("All", episode == 0) { episode = 0 } }
|
||||||
|
items(episodes) { e ->
|
||||||
|
Chip("${e.number}. ${e.name.take(22)}", episode == e.number) { episode = e.number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val ec = epCast
|
||||||
|
if (season > 0 && episode > 0) {
|
||||||
|
if (epLoading || ec == null) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) { Text("Loading this episode's cast...", color = Dim, modifier = Modifier.padding(8.dp)) }
|
||||||
|
} else {
|
||||||
|
if (ec.first.isNotEmpty()) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) { Label("GUEST STARS IN THIS EPISODE (${ec.first.size})") }
|
||||||
|
items(ec.first, key = { "g${it.id}" }) { m -> CastCard(m) { onPerson(m.id, contextYear, t.title) } }
|
||||||
|
}
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) { Label("REGULARS (${ec.second.size})") }
|
||||||
|
items(ec.second, key = { "r${it.id}" }) { m -> CastCard(m) { onPerson(m.id, contextYear, t.title) } }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Label(if (t.type == "tv") "SERIES CAST (${t.cast.size}). PICK A SEASON AND EPISODE FOR GUEST STARS" else "CAST (${t.cast.size})")
|
||||||
|
}
|
||||||
|
items(t.cast, key = { "c${it.id}" }) { m -> CastCard(m) { onPerson(m.id, contextYear, t.title) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Label(t: String) {
|
||||||
|
Text(t, color = Dim, fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 10.dp, bottom = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Header(t: Tmdb.TitleInfo, watched: Boolean, onToggle: () -> Unit) {
|
||||||
|
Column {
|
||||||
|
Box(Modifier.fillMaxWidth().aspectRatio(16f / 7f)) {
|
||||||
|
Pic(Tmdb.img(t.backdrop, "w780"), t.title, Modifier.fillMaxSize())
|
||||||
|
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, Bg))))
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 4.dp), verticalAlignment = Alignment.Top) {
|
||||||
|
Pic(Tmdb.img(t.poster, "w185"), t.title, Modifier.width(92.dp).height(138.dp))
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(t.title, color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold, lineHeight = 26.sp)
|
||||||
|
Text(listOf(t.year, if (t.type == "movie") "Movie" else "TV show").filter { it.isNotBlank() }.joinToString(" · "),
|
||||||
|
color = Dim, fontSize = 14.sp)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
if (watched) PrimaryBtn("✓ Watched", onClick = onToggle) else SecondaryBtn("Mark as watched", onClick = onToggle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t.overview.isNotBlank()) {
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(t.overview, color = Color(0xFFCCCCCC), fontSize = 14.sp, lineHeight = 20.sp, maxLines = 4, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package space.bloggin.whosthat
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/** Tiny TMDB client. Works with either the long Read Access Token or the short v3 API key. */
|
||||||
|
object Tmdb {
|
||||||
|
@Volatile var cred: String = ""
|
||||||
|
|
||||||
|
fun isV3Key(c: String) = c.length == 32 && c.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
|
||||||
|
|
||||||
|
class TmdbError(msg: String, val code: Int) : IOException(msg)
|
||||||
|
|
||||||
|
suspend fun get(path: String, params: Map<String, String> = emptyMap(), credential: String = cred): JSONObject =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val c = credential.trim()
|
||||||
|
val all = if (isV3Key(c)) params + ("api_key" to c) else params
|
||||||
|
val qs = all.entries.joinToString("&") { "${it.key}=${URLEncoder.encode(it.value, "UTF-8")}" }
|
||||||
|
val url = URL("https://api.themoviedb.org/3$path" + if (qs.isNotEmpty()) "?$qs" else "")
|
||||||
|
val conn = url.openConnection() as HttpURLConnection
|
||||||
|
conn.connectTimeout = 15000
|
||||||
|
conn.readTimeout = 20000
|
||||||
|
conn.setRequestProperty("Accept", "application/json")
|
||||||
|
if (!isV3Key(c)) conn.setRequestProperty("Authorization", "Bearer $c")
|
||||||
|
try {
|
||||||
|
val code = conn.responseCode
|
||||||
|
if (code != 200) {
|
||||||
|
val msg = when (code) {
|
||||||
|
401 -> "TMDB didn't accept that key or token."
|
||||||
|
404 -> "TMDB couldn't find that."
|
||||||
|
429 -> "Too many lookups at once. Wait a few seconds."
|
||||||
|
else -> "TMDB error $code"
|
||||||
|
}
|
||||||
|
throw TmdbError(msg, code)
|
||||||
|
}
|
||||||
|
JSONObject(conn.inputStream.bufferedReader().use { it.readText() })
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun validate(credential: String): Boolean = try {
|
||||||
|
get("/configuration", credential = credential); true
|
||||||
|
} catch (e: Exception) { false }
|
||||||
|
|
||||||
|
fun img(path: String?, size: String = "w342"): String? =
|
||||||
|
if (path.isNullOrBlank() || path == "null") null else "https://image.tmdb.org/t/p/$size$path"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- models
|
||||||
|
|
||||||
|
data class Hit(val type: String, val id: Int, val title: String, val year: String, val poster: String?, val sub: String) {
|
||||||
|
fun toJson() = JSONObject().put("type", type).put("id", id).put("title", title).put("year", year)
|
||||||
|
.put("poster", poster ?: "").put("sub", sub)
|
||||||
|
companion object {
|
||||||
|
fun fromJson(o: JSONObject) = Hit(o.getString("type"), o.getInt("id"), o.getString("title"),
|
||||||
|
o.optString("year"), o.optString("poster").ifBlank { null }, o.optString("sub"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CastMember(val id: Int, val name: String, val character: String, val profile: String?, val note: String = "")
|
||||||
|
|
||||||
|
data class Season(val number: Int, val name: String, val episodes: Int)
|
||||||
|
data class Episode(val number: Int, val name: String, val still: String?)
|
||||||
|
|
||||||
|
data class TitleInfo(
|
||||||
|
val type: String, val id: Int, val title: String, val year: String, val poster: String?,
|
||||||
|
val backdrop: String?, val overview: String, val cast: List<CastMember>, val seasons: List<Season>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Credit(
|
||||||
|
val type: String, val id: Int, val title: String, val year: String, val poster: String?,
|
||||||
|
val character: String, val votes: Int, val episodes: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Person(
|
||||||
|
val id: Int, val name: String, val profile: String?, val birthday: String?, val deathday: String?,
|
||||||
|
val place: String, val bio: String, val credits: List<Credit>
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun JSONObject.s(k: String) = optString(k).let { if (it == "null") "" else it }
|
||||||
|
private fun yearOf(d: String) = if (d.length >= 4) d.substring(0, 4) else ""
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- calls
|
||||||
|
|
||||||
|
suspend fun search(q: String): List<Hit> {
|
||||||
|
val r = get("/search/multi", mapOf("query" to q, "include_adult" to "false"))
|
||||||
|
val arr = r.optJSONArray("results") ?: JSONArray()
|
||||||
|
val out = ArrayList<Hit>()
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
when (o.s("media_type")) {
|
||||||
|
"movie" -> out += Hit("movie", o.getInt("id"), o.s("title"), yearOf(o.s("release_date")), o.s("poster_path").ifBlank { null }, "Movie")
|
||||||
|
"tv" -> out += Hit("tv", o.getInt("id"), o.s("name"), yearOf(o.s("first_air_date")), o.s("poster_path").ifBlank { null }, "TV show")
|
||||||
|
"person" -> {
|
||||||
|
val kf = o.optJSONArray("known_for")
|
||||||
|
val names = (0 until (kf?.length() ?: 0)).map { kf!!.getJSONObject(it) }
|
||||||
|
.map { it.s("title").ifBlank { it.s("name") } }.filter { it.isNotBlank() }.take(2)
|
||||||
|
out += Hit("person", o.getInt("id"), o.s("name"), "", o.s("profile_path").ifBlank { null },
|
||||||
|
if (names.isEmpty()) "Actor" else "Actor, " + names.joinToString(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun castFrom(arr: JSONArray?, note: String = ""): List<CastMember> {
|
||||||
|
if (arr == null) return emptyList()
|
||||||
|
return (0 until arr.length()).map { arr.getJSONObject(it) }.map { o ->
|
||||||
|
val ch = o.s("character").ifBlank {
|
||||||
|
val roles = o.optJSONArray("roles")
|
||||||
|
if (roles != null && roles.length() > 0) roles.getJSONObject(0).s("character") else ""
|
||||||
|
}
|
||||||
|
val eps = o.optInt("total_episode_count", 0)
|
||||||
|
CastMember(o.getInt("id"), o.s("name"), ch, o.s("profile_path").ifBlank { null },
|
||||||
|
if (eps > 0) "$eps episodes" else note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun movie(id: Int): TitleInfo {
|
||||||
|
val o = get("/movie/$id", mapOf("append_to_response" to "credits"))
|
||||||
|
return TitleInfo("movie", id, o.s("title"), yearOf(o.s("release_date")), o.s("poster_path").ifBlank { null },
|
||||||
|
o.s("backdrop_path").ifBlank { null }, o.s("overview"),
|
||||||
|
castFrom(o.optJSONObject("credits")?.optJSONArray("cast")), emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun tv(id: Int): TitleInfo {
|
||||||
|
val o = get("/tv/$id", mapOf("append_to_response" to "aggregate_credits"))
|
||||||
|
val seasonsArr = o.optJSONArray("seasons") ?: JSONArray()
|
||||||
|
val seasons = (0 until seasonsArr.length()).map { seasonsArr.getJSONObject(it) }
|
||||||
|
.filter { it.optInt("season_number") > 0 }
|
||||||
|
.map { Season(it.optInt("season_number"), it.s("name"), it.optInt("episode_count")) }
|
||||||
|
return TitleInfo("tv", id, o.s("name"), yearOf(o.s("first_air_date")), o.s("poster_path").ifBlank { null },
|
||||||
|
o.s("backdrop_path").ifBlank { null }, o.s("overview"),
|
||||||
|
castFrom(o.optJSONObject("aggregate_credits")?.optJSONArray("cast")).take(60), seasons)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun episodes(tvId: Int, season: Int): List<Episode> {
|
||||||
|
val o = get("/tv/$tvId/season/$season")
|
||||||
|
val arr = o.optJSONArray("episodes") ?: JSONArray()
|
||||||
|
return (0 until arr.length()).map { arr.getJSONObject(it) }
|
||||||
|
.map { Episode(it.optInt("episode_number"), it.s("name"), it.s("still_path").ifBlank { null }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns guest stars first, then the regulars who were in that episode. */
|
||||||
|
suspend fun episodeCast(tvId: Int, season: Int, ep: Int): Pair<List<CastMember>, List<CastMember>> {
|
||||||
|
val o = get("/tv/$tvId/season/$season/episode/$ep/credits")
|
||||||
|
return castFrom(o.optJSONArray("guest_stars"), "Guest star") to castFrom(o.optJSONArray("cast"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val skipGenres = setOf(10767, 10763) // talk shows, news
|
||||||
|
|
||||||
|
suspend fun person(id: Int): Person {
|
||||||
|
val o = get("/person/$id", mapOf("append_to_response" to "combined_credits"))
|
||||||
|
val arr = o.optJSONObject("combined_credits")?.optJSONArray("cast") ?: JSONArray()
|
||||||
|
val merged = LinkedHashMap<String, Credit>()
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val c = arr.getJSONObject(i)
|
||||||
|
val type = c.s("media_type")
|
||||||
|
if (type != "movie" && type != "tv") continue
|
||||||
|
val g = c.optJSONArray("genre_ids")
|
||||||
|
if (g != null && (0 until g.length()).any { g.optInt(it) in skipGenres }) continue
|
||||||
|
val ch = c.s("character")
|
||||||
|
if (ch.contains("Self", true) || ch.contains("Himself", true) || ch.contains("Herself", true)) continue
|
||||||
|
val key = "$type:${c.getInt("id")}"
|
||||||
|
val cr = Credit(type, c.getInt("id"), c.s("title").ifBlank { c.s("name") },
|
||||||
|
yearOf(c.s("release_date").ifBlank { c.s("first_air_date") }), c.s("poster_path").ifBlank { null },
|
||||||
|
ch, c.optInt("vote_count"), c.optInt("episode_count"))
|
||||||
|
val prev = merged[key]
|
||||||
|
merged[key] = if (prev == null) cr else prev.copy(
|
||||||
|
character = listOf(prev.character, cr.character).filter { it.isNotBlank() }.distinct().joinToString(" / "),
|
||||||
|
episodes = prev.episodes + cr.episodes)
|
||||||
|
}
|
||||||
|
return Person(id, o.s("name"), o.s("profile_path").ifBlank { null }, o.s("birthday").ifBlank { null },
|
||||||
|
o.s("deathday").ifBlank { null }, o.s("place_of_birth"), o.s("biography"),
|
||||||
|
merged.values.sortedByDescending { it.votes })
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user