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
|
||||
buildConfigField("String", "SMITHFLIX_SERVER_URL", "\"https://media.smithflix.us\"")
|
||||
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)
|
||||
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 currentRow: ListRow? = null
|
||||
private var justLoaded = true
|
||||
private var suggestionsRow: org.jellyfin.androidtv.smithflix.SmithflixSuggestionsRow? = null
|
||||
|
||||
// Special rows
|
||||
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_BUTTONS -> rows.add(HomeFragmentViewsRow(small = true))
|
||||
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_BOOK -> Unit // Books are not (yet) supported
|
||||
HomeSectionType.ACTIVE_RECORDINGS -> rows.add(helper.loadLatestLiveTvRecordings())
|
||||
|
|
@ -163,6 +171,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
|||
onItemViewClickedListener = CompositeClickedListener().apply {
|
||||
registerListener(ItemViewClickedListener())
|
||||
registerListener(notificationsRow::onItemClicked)
|
||||
registerListener { vh, item, rvh, row -> suggestionsRow?.onItemClicked(vh, item, rvh, row) }
|
||||
}
|
||||
|
||||
onItemViewSelectedListener = CompositeSelectedListener().apply {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ class SearchFragment : Fragment() {
|
|||
textInputFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
launch {
|
||||
viewModel.requestableFlow.collect { titles -> searchFragmentDelegate.showRequestable(titles) }
|
||||
}
|
||||
viewModel.searchResultsFlow.collect { results ->
|
||||
searchFragmentDelegate.showResults(results)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,22 @@ class SearchFragmentDelegate(
|
|||
) {
|
||||
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>) {
|
||||
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>()
|
||||
for ((labelRes, baseItems) in searchResultGroups) {
|
||||
val adapter = ItemRowAdapter(
|
||||
|
|
@ -41,6 +55,10 @@ class SearchFragmentDelegate(
|
|||
}
|
||||
|
||||
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
|
||||
row as ListRow
|
||||
val adapter = row.adapter as ItemRowAdapter
|
||||
|
|
@ -48,7 +66,7 @@ class SearchFragmentDelegate(
|
|||
}
|
||||
|
||||
val onItemViewSelectedListener = OnItemViewSelectedListener { _, item, _, _ ->
|
||||
val baseItem = item?.let { (item as BaseRowItem).baseItem }
|
||||
val baseItem = (item as? BaseRowItem)?.baseItem
|
||||
if (baseItem != null) {
|
||||
backgroundService.setBackground(baseItem)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ class SearchViewModel(
|
|||
private val _searchResultsFlow = MutableStateFlow<Collection<SearchResultGroup>>(emptyList())
|
||||
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 searchDebounced(query: String, debounce: Duration = debounceDuration): Boolean {
|
||||
|
|
@ -56,12 +60,16 @@ class SearchViewModel(
|
|||
|
||||
if (trimmed.isBlank()) {
|
||||
_searchResultsFlow.value = emptyList()
|
||||
_requestableFlow.value = emptyList()
|
||||
return true
|
||||
}
|
||||
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(debounce)
|
||||
|
||||
_requestableFlow.value = emptyList()
|
||||
launch { _requestableFlow.value = org.jellyfin.androidtv.smithflix.SmithflixHub.search(trimmed) }
|
||||
|
||||
_searchResultsFlow.value = groups.map { (stringRes, itemKinds) ->
|
||||
async {
|
||||
val result = searchRepository.search(trimmed, itemKinds)
|
||||
|
|
|
|||
Loading…
Reference in a new issue