Add app/src/main/java/space/bloggin/whosthat

This commit is contained in:
2026-10-20 23:25:03 +00:00
parent 906f604b50
commit 6b382b9431
8 changed files with 1131 additions and 0 deletions
@@ -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 })
}
}