PoC UI for search & discover

This commit is contained in:
Damontecres 2025-12-07 20:25:24 -05:00
parent d08bd40a68
commit 0281d00907
No known key found for this signature in database
6 changed files with 109 additions and 17 deletions

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.api.seerr
import com.github.damontecres.wholphin.api.seerr.infrastructure.ApiClient import com.github.damontecres.wholphin.api.seerr.infrastructure.ApiClient
import okhttp3.Call import okhttp3.Call
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import timber.log.Timber
class SeerrApiClient( class SeerrApiClient(
val baseUrl: String, val baseUrl: String,
@ -13,6 +14,7 @@ class SeerrApiClient(
okHttpClient okHttpClient
.newBuilder() .newBuilder()
.addInterceptor { .addInterceptor {
Timber.d("SeerrApiClient: ${it.request().method} ${it.request().url}")
it.proceed( it.proceed(
it it
.request() .request()

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -21,6 +22,8 @@ class SeerrApi(
) )
private set private set
val active: Boolean get() = api.baseUrl.isNotNullOrBlank() && api.apiKey.isNotNullOrBlank()
fun update( fun update(
baseUrl: String, baseUrl: String,
apiKey: String, apiKey: String,

View file

@ -5,7 +5,7 @@ import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResul
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
typealias SearchResult = SearchGet200ResponseResultsInner typealias SeerrSearchResult = SearchGet200ResponseResultsInner
@Singleton @Singleton
class SeerrService class SeerrService
@ -15,6 +15,8 @@ class SeerrService
) { ) {
private val api: SeerrApiClient get() = seerApi.api private val api: SeerrApiClient get() = seerApi.api
val active: Boolean get() = seerApi.active
suspend fun init() { suspend fun init() {
api.searchApi.discoverTvGet(1) api.searchApi.discoverTvGet(1)
} }
@ -22,7 +24,7 @@ class SeerrService
suspend fun search( suspend fun search(
query: String, query: String,
page: Int = 1, page: Int = 1,
): List<SearchResult> = ): List<SeerrSearchResult> =
api.searchApi api.searchApi
.searchGet(query = query, page = page) .searchGet(query = query, page = page)
.results .results

View file

@ -42,6 +42,8 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrSearchResult
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.EpisodeCard import com.github.damontecres.wholphin.ui.cards.EpisodeCard
@ -51,7 +53,9 @@ import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -73,11 +77,13 @@ class SearchViewModel
constructor( constructor(
val api: ApiClient, val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val seerrService: SeerrService,
) : ViewModel() { ) : ViewModel() {
val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery) val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery) val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery) val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery) val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
private var currentQuery: String? = null private var currentQuery: String? = null
@ -95,11 +101,13 @@ class SearchViewModel
searchInternal(query, BaseItemKind.SERIES, series) searchInternal(query, BaseItemKind.SERIES, series)
searchInternal(query, BaseItemKind.EPISODE, episodes) searchInternal(query, BaseItemKind.EPISODE, episodes)
searchInternal(query, BaseItemKind.BOX_SET, collections) searchInternal(query, BaseItemKind.BOX_SET, collections)
searchSeerr(query)
} else { } else {
movies.value = SearchResult.NoQuery movies.value = SearchResult.NoQuery
series.value = SearchResult.NoQuery series.value = SearchResult.NoQuery
episodes.value = SearchResult.NoQuery episodes.value = SearchResult.NoQuery
collections.value = SearchResult.NoQuery collections.value = SearchResult.NoQuery
seerrResults.value = SearchResult.NoQuery
} }
} }
@ -133,6 +141,16 @@ class SearchViewModel
} }
} }
private fun searchSeerr(query: String) {
if (seerrService.active) {
viewModelScope.launchIO {
seerrResults.setValueOnMain(SearchResult.Searching)
val results = seerrService.search(query)
seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results))
}
}
}
fun getHints(query: String) { fun getHints(query: String) {
// TODO // TODO
// api.searchApi.getSearchHints() // api.searchApi.getSearchHints()
@ -151,12 +169,17 @@ sealed interface SearchResult {
data class Success( data class Success(
val items: List<BaseItem?>, val items: List<BaseItem?>,
) : SearchResult ) : SearchResult
data class SuccessSeerr(
val items: List<SeerrSearchResult>,
) : SearchResult
} }
private const val MOVIE_ROW = 0 private const val MOVIE_ROW = 0
private const val COLLECTION_ROW = MOVIE_ROW + 1 private const val COLLECTION_ROW = MOVIE_ROW + 1
private const val SERIES_ROW = COLLECTION_ROW + 1 private const val SERIES_ROW = COLLECTION_ROW + 1
private const val EPISODE_ROW = SERIES_ROW + 1 private const val EPISODE_ROW = SERIES_ROW + 1
private const val SEERR_ROW = EPISODE_ROW + 1
@Composable @Composable
fun SearchPage( fun SearchPage(
@ -171,6 +194,7 @@ fun SearchPage(
val collections by viewModel.collections.observeAsState(SearchResult.NoQuery) val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
val series by viewModel.series.observeAsState(SearchResult.NoQuery) val series by viewModel.series.observeAsState(SearchResult.NoQuery)
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery) val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
val query = rememberTextFieldState() val query = rememberTextFieldState()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -287,6 +311,18 @@ fun SearchPage(
) )
}, },
) )
searchResultRow(
title = context.getString(R.string.discover),
result = seerrResults,
rowIndex = SEERR_ROW,
position = position,
focusRequester = focusRequester,
onClickItem = { index, item ->
// TODO seerr
},
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
)
} }
} }
@ -364,6 +400,40 @@ fun LazyListScope.searchResultRow(
) )
} }
} }
is SearchResult.SuccessSeerr -> {
if (r.items.isEmpty()) {
SearchResultPlaceholder(
title = title,
message = stringResource(R.string.no_results),
modifier = modifier,
)
} else {
ItemRow(
title = title,
items = r.items,
onClickItem = { index, item -> },
onLongClickItem = { _, _ -> },
modifier = modifier,
cardContent = { index: Int, item: SeerrSearchResult?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
SeasonCard(
title = item?.title ?: item?.name,
subtitle = null,
name = item?.title ?: item?.name,
imageUrl = item?.posterPath?.let { "https://image.tmdb.org/t/p/w500$it" }, // TODO
isFavorite = false,
isPlayed = false,
unplayedItemCount = -1,
playedPercentage = -1.0,
onClick = onClick,
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
modifier = mod,
)
},
)
}
}
} }
} }
} }

View file

@ -2,9 +2,7 @@ package com.github.damontecres.wholphin.ui.seerr
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
@ -14,10 +12,12 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.api.seerr.model.MovieResult import com.github.damontecres.wholphin.api.seerr.model.MovieResult
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -47,17 +47,32 @@ fun SeerrDiscoverPage(
) { ) {
val movies by viewModel.discoverMovies.observeAsState(listOf()) val movies by viewModel.discoverMovies.observeAsState(listOf())
Column( Column(
modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp),
modifier =
modifier
.padding(16.dp),
) { ) {
LazyRow( ItemRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), title = "Movies",
modifier = Modifier.fillMaxWidth(), items = movies,
) { onClickItem = { index, item -> },
items(movies) { movie -> onLongClickItem = { index, item -> },
Text( cardContent = { index: Int, item: MovieResult?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
text = movie.title, SeasonCard(
title = item?.title,
subtitle = null,
name = item?.title,
imageUrl = item?.posterPath?.let { "https://image.tmdb.org/t/p/w500$it" }, // TODO
isFavorite = false,
isPlayed = false,
unplayedItemCount = -1,
playedPercentage = -1.0,
onClick = onClick,
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
modifier = mod,
) )
} },
} )
} }
} }

View file

@ -715,7 +715,7 @@ components:
required: required:
- id - id
- mediaType - mediaType
- title # - title
properties: properties:
id: id:
type: integer type: integer