Requests: 'Not on Smithflix yet' home row and 'Request on Smithflix' search row via the Smithflix Hub
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TdkZRTCV92L7gbQX2Z5Ay
This commit is contained in:
parent
76a93b2efe
commit
7aa0c873a3
8 changed files with 344 additions and 2 deletions
|
|
@ -21,6 +21,8 @@ android {
|
||||||
// Smithflix: the server offered on first launch, and its address on the home network
|
// Smithflix: the server offered on first launch, and its address on the home network
|
||||||
buildConfigField("String", "SMITHFLIX_SERVER_URL", "\"https://media.smithflix.us\"")
|
buildConfigField("String", "SMITHFLIX_SERVER_URL", "\"https://media.smithflix.us\"")
|
||||||
buildConfigField("String", "SMITHFLIX_HOME_URL", "\"http://192.168.86.121:8096\"")
|
buildConfigField("String", "SMITHFLIX_HOME_URL", "\"http://192.168.86.121:8096\"")
|
||||||
|
// Smithflix Hub: requests and "Not on Smithflix yet" suggestions
|
||||||
|
buildConfigField("String", "SMITHFLIX_HUB_URL", "\"https://app.smithflix.us\"")
|
||||||
|
|
||||||
// Smithflix: where the app looks for updates (published by the build workflow)
|
// Smithflix: where the app looks for updates (published by the build workflow)
|
||||||
buildConfigField(
|
buildConfigField(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
package org.jellyfin.androidtv.smithflix
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.androidtv.BuildConfig
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/** A movie or show from the Smithflix Hub (TMDB data via Seerr), usually one that isn't on the server yet. */
|
||||||
|
data class HubTitle(
|
||||||
|
val tmdbId: Int,
|
||||||
|
val type: String,
|
||||||
|
val title: String,
|
||||||
|
val year: Int?,
|
||||||
|
val overview: String,
|
||||||
|
val poster: String?,
|
||||||
|
val backdrop: String?,
|
||||||
|
val reason: String,
|
||||||
|
val status: String,
|
||||||
|
) {
|
||||||
|
val isRequested get() = status in setOf("requested", "processing", "partial", "available")
|
||||||
|
val label get() = if (year != null) "$title ($year)" else title
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HubResult(val ok: Boolean, val message: String, val status: String? = null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to the Smithflix Hub (app.smithflix.us). The hub checks the person's Jellyfin sign-in and
|
||||||
|
* then talks to Seerr on their behalf; the app never holds the Seerr key.
|
||||||
|
*/
|
||||||
|
object SmithflixHub : KoinComponent {
|
||||||
|
private val api by inject<ApiClient>()
|
||||||
|
|
||||||
|
private fun open(path: String, method: String = "GET"): HttpURLConnection {
|
||||||
|
val conn = URL(BuildConfig.SMITHFLIX_HUB_URL.trimEnd('/') + path).openConnection() as HttpURLConnection
|
||||||
|
conn.requestMethod = method
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 30_000
|
||||||
|
conn.setRequestProperty("Accept", "application/json")
|
||||||
|
api.accessToken?.let { conn.setRequestProperty("Authorization", "MediaBrowser Token=\"$it\"") }
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun read(conn: HttpURLConnection): Pair<Int, JSONObject> {
|
||||||
|
val code = conn.responseCode
|
||||||
|
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
||||||
|
val text = stream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||||
|
return code to (runCatching { JSONObject(text) }.getOrNull() ?: JSONObject())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(arr: JSONArray?): List<HubTitle> {
|
||||||
|
if (arr == null) return emptyList()
|
||||||
|
return (0 until arr.length()).mapNotNull { i ->
|
||||||
|
val o = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||||
|
HubTitle(
|
||||||
|
tmdbId = o.optInt("tmdbId"),
|
||||||
|
type = o.optString("type", "movie"),
|
||||||
|
title = o.optString("title"),
|
||||||
|
year = o.optInt("year").takeIf { it > 0 },
|
||||||
|
overview = o.optString("overview"),
|
||||||
|
poster = o.optString("poster").takeIf { it.isNotBlank() && it != "null" },
|
||||||
|
backdrop = o.optString("backdrop").takeIf { it.isNotBlank() && it != "null" },
|
||||||
|
reason = o.optString("reason").takeIf { it != "null" }.orEmpty(),
|
||||||
|
status = o.optString("status", "none"),
|
||||||
|
)
|
||||||
|
}.filter { it.tmdbId > 0 && it.title.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tonight's AI picks of titles that aren't on Smithflix yet. Empty if anything goes wrong. */
|
||||||
|
suspend fun suggestions(): List<HubTitle> = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val (code, body) = read(open("/api/suggestions"))
|
||||||
|
if (code == 200) parse(body.optJSONArray("results")) else emptyList()
|
||||||
|
}.onFailure { Timber.w(it, "Smithflix Hub: suggestions failed") }.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Movies and shows matching a search, excluding ones already on Smithflix. */
|
||||||
|
suspend fun search(query: String): List<HubTitle> = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val (code, body) = read(open("/api/search?q=" + URLEncoder.encode(query, "UTF-8")))
|
||||||
|
if (code == 200) parse(body.optJSONArray("results")).filter { it.status != "available" && it.status != "partial" }
|
||||||
|
else emptyList()
|
||||||
|
}.onFailure { Timber.w(it, "Smithflix Hub: search failed") }.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun request(title: HubTitle): HubResult = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val conn = open("/api/request", "POST")
|
||||||
|
conn.doOutput = true
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json")
|
||||||
|
conn.outputStream.use {
|
||||||
|
it.write(JSONObject().put("type", title.type).put("tmdbId", title.tmdbId).toString().toByteArray())
|
||||||
|
}
|
||||||
|
val (code, body) = read(conn)
|
||||||
|
if (code in 200..299) HubResult(true, body.optString("message", "Requested!"), body.optString("status"))
|
||||||
|
else HubResult(false, body.optString("error", "The request didn't go through."))
|
||||||
|
}.getOrElse {
|
||||||
|
Timber.w(it, "Smithflix Hub: request failed")
|
||||||
|
HubResult(false, "Couldn't reach Smithflix requests. Check your connection and try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
package org.jellyfin.androidtv.smithflix
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContextWrapper
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.leanback.widget.ArrayObjectAdapter
|
||||||
|
import androidx.leanback.widget.Presenter
|
||||||
|
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||||
|
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
|
||||||
|
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||||
|
import androidx.compose.ui.platform.findViewTreeCompositionContext
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.androidtv.ui.base.SmithflixColors
|
||||||
|
import org.jellyfin.androidtv.ui.base.Text
|
||||||
|
import org.jellyfin.androidtv.ui.composable.AsyncImage
|
||||||
|
|
||||||
|
/** Poster card for a title that isn't on Smithflix yet. */
|
||||||
|
class HubTitlePresenter(private val heightDp: Int = 187) : Presenter() {
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
|
||||||
|
val item = MutableStateFlow<HubTitle?>(null)
|
||||||
|
val focused = MutableStateFlow(false)
|
||||||
|
val view = ComposeView(parent.context).apply {
|
||||||
|
setParentCompositionContext(parent.findViewTreeCompositionContext())
|
||||||
|
setViewTreeLifecycleOwner(parent.findViewTreeLifecycleOwner())
|
||||||
|
setViewTreeSavedStateRegistryOwner(parent.findViewTreeSavedStateRegistryOwner())
|
||||||
|
isFocusable = true
|
||||||
|
isFocusableInTouchMode = true
|
||||||
|
setOnFocusChangeListener { _, f -> focused.value = f }
|
||||||
|
setContent {
|
||||||
|
val t by item.collectAsState()
|
||||||
|
val f by focused.collectAsState()
|
||||||
|
t?.let { HubTitleCard(it, f, heightDp) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Holder(view, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any?) {
|
||||||
|
(viewHolder as Holder).item.value = item as? HubTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
|
||||||
|
(viewHolder as Holder).item.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Holder(view: ComposeView, val item: MutableStateFlow<HubTitle?>) : ViewHolder(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
@androidx.compose.runtime.Composable
|
||||||
|
private fun HubTitleCard(t: HubTitle, focused: Boolean, heightDp: Int) {
|
||||||
|
val w = (heightDp * 2 / 3).dp
|
||||||
|
val shape = RoundedCornerShape(8.dp)
|
||||||
|
Column(modifier = Modifier.width(w).padding(bottom = 6.dp)) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(w)
|
||||||
|
.height(heightDp.dp)
|
||||||
|
.scale(if (focused) 1.05f else 1f)
|
||||||
|
.clip(shape)
|
||||||
|
.background(SmithflixColors.GlassPanel)
|
||||||
|
.then(if (focused) Modifier.border(3.dp, SmithflixColors.FocusOutline, shape) else Modifier),
|
||||||
|
) {
|
||||||
|
if (t.poster != null) {
|
||||||
|
AsyncImage(modifier = Modifier.fillMaxSize(), url = t.poster, aspectRatio = 2f / 3f,
|
||||||
|
scaleType = ImageView.ScaleType.CENTER_CROP)
|
||||||
|
} else {
|
||||||
|
Text(t.title, modifier = Modifier.align(Alignment.Center).padding(10.dp), color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
val badge = if (t.isRequested) "REQUESTED" else "REQUEST"
|
||||||
|
Text(
|
||||||
|
badge,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(6.dp)
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.background(if (t.isRequested) Color(0xCC1E7A4A) else Color(0xCC0A63F2))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
color = Color.White, fontSize = 10.sp, fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(t.title, modifier = Modifier.padding(top = 6.dp), color = Color.White, fontSize = 13.sp,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(t.year?.toString(), if (t.type == "tv") "Series" else "Movie").joinToString(" · "),
|
||||||
|
color = Color(0xFF9AA6BF), fontSize = 11.sp, maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Request this?" popup. Updates the card in [adapter] once requested. */
|
||||||
|
object SmithflixRequestDialog {
|
||||||
|
private fun Context.activity(): FragmentActivity? {
|
||||||
|
var c: Context? = this
|
||||||
|
while (c is ContextWrapper) {
|
||||||
|
if (c is FragmentActivity) return c
|
||||||
|
c = c.baseContext
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun show(context: Context, title: HubTitle, adapter: ArrayObjectAdapter? = null) {
|
||||||
|
val activity = context.activity() ?: return
|
||||||
|
val message = buildString {
|
||||||
|
if (title.reason.isNotBlank()) append("Why it's here: ").append(title.reason).append("\n\n")
|
||||||
|
append(title.overview.ifBlank { "No description available." })
|
||||||
|
if (title.isRequested) append("\n\nThis one has already been requested.")
|
||||||
|
}
|
||||||
|
val builder = AlertDialog.Builder(activity)
|
||||||
|
.setTitle(title.label + if (title.type == "tv") " · Series" else "")
|
||||||
|
.setMessage(message)
|
||||||
|
.setNegativeButton("Close", null)
|
||||||
|
if (!title.isRequested) {
|
||||||
|
builder.setPositiveButton(if (title.type == "tv") "Request series" else "Request movie") { _, _ ->
|
||||||
|
activity.lifecycleScope.launch {
|
||||||
|
val result = SmithflixHub.request(title)
|
||||||
|
Toast.makeText(activity, result.message, Toast.LENGTH_LONG).show()
|
||||||
|
if (result.ok && adapter != null) {
|
||||||
|
val i = adapter.indexOf(title)
|
||||||
|
if (i >= 0) adapter.replace(i, title.copy(status = "requested"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.show().getButton(AlertDialog.BUTTON_POSITIVE)?.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package org.jellyfin.androidtv.smithflix
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.leanback.widget.ArrayObjectAdapter
|
||||||
|
import androidx.leanback.widget.HeaderItem
|
||||||
|
import androidx.leanback.widget.ListRow
|
||||||
|
import androidx.leanback.widget.OnItemViewClickedListener
|
||||||
|
import androidx.leanback.widget.Presenter
|
||||||
|
import androidx.leanback.widget.Row
|
||||||
|
import androidx.leanback.widget.RowPresenter
|
||||||
|
import org.jellyfin.androidtv.ui.home.HomeFragmentRow
|
||||||
|
import org.jellyfin.androidtv.ui.presentation.CardPresenter
|
||||||
|
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
|
||||||
|
|
||||||
|
/** Home row: the AI's picks of titles that aren't on Smithflix yet, each one requestable. */
|
||||||
|
class SmithflixSuggestionsRow(private val items: List<HubTitle>) : HomeFragmentRow, OnItemViewClickedListener {
|
||||||
|
private val adapter = ArrayObjectAdapter(HubTitlePresenter()).apply { addAll(0, items) }
|
||||||
|
|
||||||
|
override fun addToRowsAdapter(context: Context, cardPresenter: CardPresenter, rowsAdapter: MutableObjectAdapter<Row>) {
|
||||||
|
if (items.isEmpty()) return
|
||||||
|
rowsAdapter.add(ListRow(HeaderItem(TITLE), adapter))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemClicked(itemViewHolder: Presenter.ViewHolder?, item: Any?, rowViewHolder: RowPresenter.ViewHolder?, row: Row?) {
|
||||||
|
if (item !is HubTitle) return
|
||||||
|
val context = itemViewHolder?.view?.context ?: return
|
||||||
|
SmithflixRequestDialog.show(context, item, adapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TITLE = "Not on Smithflix yet"
|
||||||
|
|
||||||
|
/** Loads tonight's suggestions; returns null when there are none (or the hub isn't set up). */
|
||||||
|
suspend fun load(): SmithflixSuggestionsRow? =
|
||||||
|
SmithflixHub.suggestions().takeIf { it.isNotEmpty() }?.let { SmithflixSuggestionsRow(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||||
private var currentItem: BaseRowItem? = null
|
private var currentItem: BaseRowItem? = null
|
||||||
private var currentRow: ListRow? = null
|
private var currentRow: ListRow? = null
|
||||||
private var justLoaded = true
|
private var justLoaded = true
|
||||||
|
private var suggestionsRow: org.jellyfin.androidtv.smithflix.SmithflixSuggestionsRow? = null
|
||||||
|
|
||||||
// Special rows
|
// Special rows
|
||||||
private val notificationsRow by lazy { NotificationsHomeFragmentRow(lifecycleScope, notificationsRepository) }
|
private val notificationsRow by lazy { NotificationsHomeFragmentRow(lifecycleScope, notificationsRepository) }
|
||||||
|
|
@ -124,7 +125,14 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||||
HomeSectionType.LIBRARY_TILES_SMALL -> rows.add(HomeFragmentViewsRow(small = false))
|
HomeSectionType.LIBRARY_TILES_SMALL -> rows.add(HomeFragmentViewsRow(small = false))
|
||||||
HomeSectionType.LIBRARY_BUTTONS -> rows.add(HomeFragmentViewsRow(small = true))
|
HomeSectionType.LIBRARY_BUTTONS -> rows.add(HomeFragmentViewsRow(small = true))
|
||||||
HomeSectionType.RESUME -> rows.add(helper.loadResumeVideo())
|
HomeSectionType.RESUME -> rows.add(helper.loadResumeVideo())
|
||||||
HomeSectionType.AI_PICKS -> rows.addAll(helper.loadAiRows(api, currentUser.id))
|
HomeSectionType.AI_PICKS -> {
|
||||||
|
rows.addAll(helper.loadAiRows(api, currentUser.id))
|
||||||
|
// Smithflix: AI picks of titles that aren't on the server yet, with a Request button
|
||||||
|
org.jellyfin.androidtv.smithflix.SmithflixSuggestionsRow.load()?.let {
|
||||||
|
suggestionsRow = it
|
||||||
|
rows.add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
HomeSectionType.RESUME_AUDIO -> rows.add(helper.loadResumeAudio())
|
HomeSectionType.RESUME_AUDIO -> rows.add(helper.loadResumeAudio())
|
||||||
HomeSectionType.RESUME_BOOK -> Unit // Books are not (yet) supported
|
HomeSectionType.RESUME_BOOK -> Unit // Books are not (yet) supported
|
||||||
HomeSectionType.ACTIVE_RECORDINGS -> rows.add(helper.loadLatestLiveTvRecordings())
|
HomeSectionType.ACTIVE_RECORDINGS -> rows.add(helper.loadLatestLiveTvRecordings())
|
||||||
|
|
@ -163,6 +171,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||||
onItemViewClickedListener = CompositeClickedListener().apply {
|
onItemViewClickedListener = CompositeClickedListener().apply {
|
||||||
registerListener(ItemViewClickedListener())
|
registerListener(ItemViewClickedListener())
|
||||||
registerListener(notificationsRow::onItemClicked)
|
registerListener(notificationsRow::onItemClicked)
|
||||||
|
registerListener { vh, item, rvh, row -> suggestionsRow?.onItemClicked(vh, item, rvh, row) }
|
||||||
}
|
}
|
||||||
|
|
||||||
onItemViewSelectedListener = CompositeSelectedListener().apply {
|
onItemViewSelectedListener = CompositeSelectedListener().apply {
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,9 @@ class SearchFragment : Fragment() {
|
||||||
textInputFocusRequester.requestFocus()
|
textInputFocusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
viewModel.requestableFlow.collect { titles -> searchFragmentDelegate.showRequestable(titles) }
|
||||||
|
}
|
||||||
viewModel.searchResultsFlow.collect { results ->
|
viewModel.searchResultsFlow.collect { results ->
|
||||||
searchFragmentDelegate.showResults(results)
|
searchFragmentDelegate.showResults(results)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,22 @@ class SearchFragmentDelegate(
|
||||||
) {
|
) {
|
||||||
val rowsAdapter = MutableObjectAdapter<Row>(CustomListRowPresenter())
|
val rowsAdapter = MutableObjectAdapter<Row>(CustomListRowPresenter())
|
||||||
|
|
||||||
|
// Smithflix: "Request on Smithflix" row for titles that aren't on the server yet
|
||||||
|
private val requestAdapter = androidx.leanback.widget.ArrayObjectAdapter(org.jellyfin.androidtv.smithflix.HubTitlePresenter(150))
|
||||||
|
private val requestRow = ListRow(HeaderItem("Request on Smithflix"), requestAdapter)
|
||||||
|
|
||||||
|
fun showRequestable(titles: List<org.jellyfin.androidtv.smithflix.HubTitle>) {
|
||||||
|
requestAdapter.clear()
|
||||||
|
requestAdapter.addAll(0, titles)
|
||||||
|
val index = rowsAdapter.indexOf(requestRow)
|
||||||
|
if (titles.isEmpty() && index >= 0) rowsAdapter.removeAt(index)
|
||||||
|
if (titles.isNotEmpty() && index < 0) rowsAdapter.add(requestRow)
|
||||||
|
}
|
||||||
|
|
||||||
fun showResults(searchResultGroups: Collection<SearchResultGroup>) {
|
fun showResults(searchResultGroups: Collection<SearchResultGroup>) {
|
||||||
rowsAdapter.clear()
|
rowsAdapter.clear()
|
||||||
|
// the request row comes back (at the end) once the hub answers for this search
|
||||||
|
if (requestAdapter.size() > 0) rowsAdapter.add(requestRow)
|
||||||
val adapters = mutableListOf<ItemRowAdapter>()
|
val adapters = mutableListOf<ItemRowAdapter>()
|
||||||
for ((labelRes, baseItems) in searchResultGroups) {
|
for ((labelRes, baseItems) in searchResultGroups) {
|
||||||
val adapter = ItemRowAdapter(
|
val adapter = ItemRowAdapter(
|
||||||
|
|
@ -41,6 +55,10 @@ class SearchFragmentDelegate(
|
||||||
}
|
}
|
||||||
|
|
||||||
val onItemViewClickedListener = OnItemViewClickedListener { _, item, _, row ->
|
val onItemViewClickedListener = OnItemViewClickedListener { _, item, _, row ->
|
||||||
|
if (item is org.jellyfin.androidtv.smithflix.HubTitle) {
|
||||||
|
org.jellyfin.androidtv.smithflix.SmithflixRequestDialog.show(context, item, requestAdapter)
|
||||||
|
return@OnItemViewClickedListener
|
||||||
|
}
|
||||||
if (item !is BaseRowItem) return@OnItemViewClickedListener
|
if (item !is BaseRowItem) return@OnItemViewClickedListener
|
||||||
row as ListRow
|
row as ListRow
|
||||||
val adapter = row.adapter as ItemRowAdapter
|
val adapter = row.adapter as ItemRowAdapter
|
||||||
|
|
@ -48,7 +66,7 @@ class SearchFragmentDelegate(
|
||||||
}
|
}
|
||||||
|
|
||||||
val onItemViewSelectedListener = OnItemViewSelectedListener { _, item, _, _ ->
|
val onItemViewSelectedListener = OnItemViewSelectedListener { _, item, _, _ ->
|
||||||
val baseItem = item?.let { (item as BaseRowItem).baseItem }
|
val baseItem = (item as? BaseRowItem)?.baseItem
|
||||||
if (baseItem != null) {
|
if (baseItem != null) {
|
||||||
backgroundService.setBackground(baseItem)
|
backgroundService.setBackground(baseItem)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,10 @@ class SearchViewModel(
|
||||||
private val _searchResultsFlow = MutableStateFlow<Collection<SearchResultGroup>>(emptyList())
|
private val _searchResultsFlow = MutableStateFlow<Collection<SearchResultGroup>>(emptyList())
|
||||||
val searchResultsFlow = _searchResultsFlow.asStateFlow()
|
val searchResultsFlow = _searchResultsFlow.asStateFlow()
|
||||||
|
|
||||||
|
// Smithflix: titles that aren't on the server yet, from the Smithflix Hub (Seerr), for the Request row
|
||||||
|
private val _requestableFlow = MutableStateFlow<List<org.jellyfin.androidtv.smithflix.HubTitle>>(emptyList())
|
||||||
|
val requestableFlow = _requestableFlow.asStateFlow()
|
||||||
|
|
||||||
fun searchImmediately(query: String) = searchDebounced(query, 0.milliseconds)
|
fun searchImmediately(query: String) = searchDebounced(query, 0.milliseconds)
|
||||||
|
|
||||||
fun searchDebounced(query: String, debounce: Duration = debounceDuration): Boolean {
|
fun searchDebounced(query: String, debounce: Duration = debounceDuration): Boolean {
|
||||||
|
|
@ -56,12 +60,16 @@ class SearchViewModel(
|
||||||
|
|
||||||
if (trimmed.isBlank()) {
|
if (trimmed.isBlank()) {
|
||||||
_searchResultsFlow.value = emptyList()
|
_searchResultsFlow.value = emptyList()
|
||||||
|
_requestableFlow.value = emptyList()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
searchJob = viewModelScope.launch {
|
searchJob = viewModelScope.launch {
|
||||||
delay(debounce)
|
delay(debounce)
|
||||||
|
|
||||||
|
_requestableFlow.value = emptyList()
|
||||||
|
launch { _requestableFlow.value = org.jellyfin.androidtv.smithflix.SmithflixHub.search(trimmed) }
|
||||||
|
|
||||||
_searchResultsFlow.value = groups.map { (stringRes, itemKinds) ->
|
_searchResultsFlow.value = groups.map { (stringRes, itemKinds) ->
|
||||||
async {
|
async {
|
||||||
val result = searchRepository.search(trimmed, itemKinds)
|
val result = searchRepository.search(trimmed, itemKinds)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue