Fix episode parsing, add missing categories, improve search stability

- Add old_ent (추억의 예능) and old_drama (추억의 드라마) categories
- Fix episode parsing to prioritize #epListScroll selector
- Add pagination logic to fetch more episodes when 25+ episodes
- Fix extractSeriesUrl to include old_ent and old_drama
- Add crash protection in search with try-catch
- Add showSearchDialog to MainActivity for better search UX
- Fix SearchResultsAdapter to use Presenter pattern correctly
- Remove unused scale variable in SearchCardPresenter
This commit is contained in:
tvmon-dev
2026-04-16 09:54:02 +09:00
parent 453c0d3ec1
commit 0c8bc6252b
7 changed files with 492 additions and 253 deletions

View File

@@ -55,19 +55,18 @@
android:theme="@style/Theme.Tvmon.Search" /> android:theme="@style/Theme.Tvmon.Search" />
<activity <activity
android:name=".ui.search.SearchActivity" android:name=".ui.search.SearchActivity"
android:exported="true" android:exported="true"
android:label="@string/search_title" android:label="@string/search_title"
android:screenOrientation="landscape" android:screenOrientation="landscape"
android:theme="@style/Theme.Tvmon.Search" android:theme="@style/Theme.Tvmon.Search">
android:launchMode="singleTop"> <intent-filter>
<intent-filter> <action android:name="android.intent.action.SEARCH" />
<action android:name="android.intent.action.SEARCH" /> </intent-filter>
</intent-filter> <meta-data
<meta-data android:name="android.app.searchable"
android:name="android.app.searchable" android:resource="@xml/searchable" />
android:resource="@xml/searchable" /> </activity>
</activity>
</application> </application>

View File

@@ -15,18 +15,20 @@ class TvmonScraper {
const val BASE_URL = "https://tvmon.site" const val BASE_URL = "https://tvmon.site"
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
val CATEGORIES = mapOf( val CATEGORIES = mapOf(
"popular" to Category("popular", "인기영상", "/popular"), "popular" to Category("popular", "인기영상", "/popular"),
"movie" to Category("movie", "영화", "/movie"), "movie" to Category("movie", "영화", "/movie"),
"kor_movie" to Category("kor_movie", "한국영화", "/kor_movie"), "kor_movie" to Category("kor_movie", "한국영화", "/kor_movie"),
"drama" to Category("drama", "드라마", "/drama"), "drama" to Category("drama", "드라마", "/drama"),
"ent" to Category("ent", "예능프로그램", "/ent"), "ent" to Category("ent", "예능프로그램", "/ent"),
"sisa" to Category("sisa", "시사/다큐", "/sisa"), "sisa" to Category("sisa", "시사/다큐", "/sisa"),
"world" to Category("world", "해외드라마", "/world"), "world" to Category("world", "해외드라마", "/world"),
"ott_ent" to Category("ott_ent", "해외 (예능/다큐)", "/ott_ent"), "ott_ent" to Category("ott_ent", "해외 (예능/다큐)", "/ott_ent"),
"ani_movie" to Category("ani_movie", "[극장판] 애니메이션", "/ani_movie"), "ani_movie" to Category("ani_movie", "[극장판] 애니메이션", "/ani_movie"),
"animation" to Category("animation", "일반 애니메이션", "/animation") "animation" to Category("animation", "일반 애니메이션", "/animation"),
) "old_ent" to Category("old_ent", "추억의 예능", "/old_ent"),
"old_drama" to Category("old_drama", "추억의 드라마", "/old_drama")
)
private val NAV_PATTERNS = listOf("/login", "/logout", "/register", "/mypage", "/bbs/", "/menu", "/faq", "/privacy") private val NAV_PATTERNS = listOf("/login", "/logout", "/register", "/mypage", "/bbs/", "/menu", "/faq", "/privacy")
} }
@@ -61,14 +63,14 @@ class TvmonScraper {
} }
} }
private fun extractSeriesUrl(url: String): String { private fun extractSeriesUrl(url: String): String {
val pattern = Pattern.compile("(/(drama|movie|ent|world|animation|kor_movie|sisa|ott_ent|ani_movie)/\\d+)/\\d+") val pattern = Pattern.compile("(/(drama|movie|ent|world|animation|kor_movie|sisa|ott_ent|ani_movie|old_ent|old_drama)/\\d+)/\\d+")
val matcher = pattern.matcher(url) val matcher = pattern.matcher(url)
if (matcher.find()) { if (matcher.find()) {
return BASE_URL + matcher.group(1) return BASE_URL + matcher.group(1)
}
return url
} }
return url
}
suspend fun getHomepage(): Map<String, Any> = withContext(Dispatchers.IO) { suspend fun getHomepage(): Map<String, Any> = withContext(Dispatchers.IO) {
val html = get("$BASE_URL/") ?: return@withContext mapOf("success" to false) val html = get("$BASE_URL/") ?: return@withContext mapOf("success" to false)
@@ -407,91 +409,153 @@ class TvmonScraper {
} }
} }
val episodes = mutableListOf<Episode>() val episodes = mutableListOf<Episode>()
val videoLinks = mutableListOf<VideoLink>() val videoLinks = mutableListOf<VideoLink>()
val seenEpisodeIds = mutableSetOf<String>() val seenEpisodeIds = mutableSetOf<String>()
val seriesId = seriesUrl.substringAfterLast("/").substringBefore("?") val seriesId = seriesUrl.substringAfterLast("/").substringBefore("?")
val allEpisodeLinks = doc.select("a[href*='/$seriesId/'], a[href*='/${seriesId}/']") // Parse episodes from #epListScroll first (ep-item class)
val epListScroll = doc.select("#epListScroll")
val allEpisodeLinks = if (epListScroll.isNotEmpty()) {
epListScroll.select("a.ep-item[href*='/$seriesId/']")
} else {
doc.select("a[href*='/$seriesId/'], a[href*='/${seriesId}/']")
}
var episodeIndex = 0 var episodeIndex = 0
for (link in allEpisodeLinks) { for (link in allEpisodeLinks) {
val href = link.attr("href") val href = link.attr("href")
if (href.isBlank()) continue if (href.isBlank()) continue
if (!href.contains("/$seriesId/") && !href.contains("/${seriesId}/")) continue if (!href.contains("/$seriesId/") && !href.contains("/${seriesId}/")) continue
val fullUrl = resolveUrl(href) val fullUrl = resolveUrl(href)
val episodeIdMatch = Pattern.compile("/$seriesId/(\\d+)").matcher(href) val episodeIdMatch = Pattern.compile("/$seriesId/(\\d+)").matcher(href)
if (!episodeIdMatch.find()) continue if (!episodeIdMatch.find()) continue
val episodeId = episodeIdMatch.group(1) ?: "" val episodeId = episodeIdMatch.group(1) ?: ""
if (episodeId in seenEpisodeIds) continue if (episodeId in seenEpisodeIds) continue
seenEpisodeIds.add(episodeId) seenEpisodeIds.add(episodeId)
val linkText = link.text().trim() val linkText = link.text().trim()
if (linkText.isBlank()) continue if (linkText.isBlank()) continue
if (linkText.contains("로그인") || linkText.contains("비밀번호") || if (linkText.contains("로그인") || linkText.contains("비밀번호") ||
linkText.contains("마이페이지") || linkText.contains("전체 목록")) { linkText.contains("마이페이지") || linkText.contains("전체 목록")) {
continue continue
} }
val cleanLinkText = linkText val cleanLinkText = linkText
.replace("시청중", "") .replace("시청중", "")
.replace("NEW", "") .replace("NEW", "")
.trim() .trim()
val datePattern = Pattern.compile("^(\\d{2})[./](\\d{2})[./](\\d{2,4})\\s+(.+)$|^(\\d{2})[./](\\d{2})[./](\\d{2,4})(.+)$") val datePattern = Pattern.compile("^(\\d{2})[./](\\d{2})[./](\\d{2,4})\\s+(.+)$|^(\\d{2})[./](\\d{2})[./](\\d{2,4})(.+)$")
val dateMatch = datePattern.matcher(linkText) val dateMatch = datePattern.matcher(linkText)
var dateStr = "" var dateStr = ""
var episodeTitleStr = cleanLinkText var episodeTitleStr = cleanLinkText
if (dateMatch.find()) { if (dateMatch.find()) {
val day = dateMatch.group(1) ?: dateMatch.group(5) val day = dateMatch.group(1) ?: dateMatch.group(5)
val month = dateMatch.group(2) ?: dateMatch.group(6) val month = dateMatch.group(2) ?: dateMatch.group(6)
val year = dateMatch.group(3) ?: dateMatch.group(7) val year = dateMatch.group(3) ?: dateMatch.group(7)
val titlePart = dateMatch.group(4) ?: dateMatch.group(8) val titlePart = dateMatch.group(4) ?: dateMatch.group(8)
if (day != null && month != null && year != null) { if (day != null && month != null && year != null) {
dateStr = if (year.length == 2) { dateStr = if (year.length == 2) {
"20$year/$month/$day" "20$year/$month/$day"
} else { } else {
"$year/$month/$day" "$year/$month/$day"
} }
episodeTitleStr = titlePart?.trim() ?: cleanLinkText episodeTitleStr = titlePart?.trim() ?: cleanLinkText
}
}
val episodeNumMatch = Pattern.compile("(\\d+)\\s*화|(\\d+)\\s*회|EP\\.?(\\d+)|제\\s*(\\d+)\\s*부").matcher(episodeTitleStr)
val episodeTitle = if (episodeNumMatch.find()) {
episodeNumMatch.group(1) ?: episodeNumMatch.group(2) ?: episodeNumMatch.group(3) ?: episodeNumMatch.group(4)
} else {
null
}
val finalNumber = if (!episodeTitle.isNullOrBlank()) {
episodeTitle
} else {
episodeIndex++.toString()
}
episodes.add(Episode(
number = dateStr.ifBlank { finalNumber },
title = episodeTitleStr.ifBlank { finalNumber },
url = fullUrl,
type = "webview",
date = dateStr
))
videoLinks.add(VideoLink(
type = "play_page",
url = fullUrl,
title = linkText.ifBlank { finalNumber }
))
} }
}
val episodeNumMatch = Pattern.compile("(\\d+)\\s*화|(\\d+)\\s*회|EP\\.?(\\d+)|제\\s*(\\d+)\\s*부").matcher(episodeTitleStr)
val episodeTitle = if (episodeNumMatch.find()) {
episodeNumMatch.group(1) ?: episodeNumMatch.group(2) ?: episodeNumMatch.group(3) ?: episodeNumMatch.group(4)
} else {
null
}
val finalNumber = if (!episodeTitle.isNullOrBlank()) {
episodeTitle
} else {
episodeIndex++.toString()
}
episodes.add(Episode(
number = dateStr.ifBlank { finalNumber },
title = episodeTitleStr.ifBlank { finalNumber },
url = fullUrl,
type = "webview",
date = dateStr
))
videoLinks.add(VideoLink(
type = "play_page",
url = fullUrl,
title = linkText.ifBlank { finalNumber }
))
}
// If we have 25+ episodes, try to fetch more episodes from the series list page
if (episodes.size >= 25) {
val category = getCategoryFromUrl(seriesUrl)
val listPageUrl = "$BASE_URL/$category/$seriesId"
val listHtml = get(listPageUrl)
if (listHtml != null) {
val listDoc = Jsoup.parse(listHtml)
val listEpLinks = listDoc.select("a[href*='/$seriesId/']")
for (link in listEpLinks) {
val href = link.attr("href")
if (href.isBlank()) continue
val fullUrl = resolveUrl(href)
if (seenEpisodeIds.contains(fullUrl)) continue
val episodeIdMatch = Pattern.compile("/$seriesId/(\\d+)").matcher(href)
if (!episodeIdMatch.find()) continue
val episodeId = episodeIdMatch.group(1) ?: ""
if (episodeId in seenEpisodeIds) continue
seenEpisodeIds.add(episodeId)
val linkText = link.text().trim()
if (linkText.isBlank()) continue
if (linkText.contains("로그인") || linkText.contains("비밀번호") ||
linkText.contains("마이페이지") || linkText.contains("전체 목록")) {
continue
}
val cleanLinkText = linkText.replace("시청중", "").replace("NEW", "").trim()
val episodeNumMatch = Pattern.compile("(\\d+)\\s*화|(\\d+)\\s*회|EP\\.?(\\d+)|제\\s*(\\d+)\\s*부").matcher(cleanLinkText)
val episodeTitle = if (episodeNumMatch.find()) {
episodeNumMatch.group(1) ?: episodeNumMatch.group(2) ?: episodeNumMatch.group(3) ?: episodeNumMatch.group(4)
} else {
null
}
val finalNumber = episodeTitle ?: (episodes.size + 1).toString()
episodes.add(Episode(
number = finalNumber,
title = cleanLinkText.ifBlank { finalNumber },
url = fullUrl,
type = "webview",
date = ""
))
videoLinks.add(VideoLink(
type = "play_page",
url = fullUrl,
title = linkText
))
}
}
}
episodes.sortByDescending { episode -> episodes.sortByDescending { episode ->
val numberStr = episode.number val numberStr = episode.number

View File

@@ -1,13 +1,126 @@
package com.example.tvmon.ui.main package com.example.tvmon.ui.main
import android.app.Dialog
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.text.InputType
import android.view.Window
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.example.tvmon.R import com.example.tvmon.R
class MainActivity : FragmentActivity() { class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
}
fun showSearchDialog(onSearch: (String) -> Unit) {
val layout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(60, 40, 60, 40)
setBackgroundColor(android.graphics.Color.parseColor("#1A1A1A"))
} }
val titleText = TextView(this).apply {
text = "검색"
textSize = 28f
setTextColor(android.graphics.Color.WHITE)
setPadding(0, 0, 0, 30)
}
val editText = EditText(this).apply {
hint = "검색어를 입력하세요"
textSize = 22f
setTextColor(android.graphics.Color.WHITE)
setHintTextColor(android.graphics.Color.GRAY)
setBackgroundColor(android.graphics.Color.parseColor("#333333"))
setPadding(30, 25, 30, 25)
inputType = InputType.TYPE_CLASS_TEXT
imeOptions = EditorInfo.IME_ACTION_SEARCH
isFocusable = true
isFocusableInTouchMode = true
requestFocus()
}
val buttonLayout = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, 30, 0, 0)
gravity = android.view.Gravity.CENTER
}
val cancelBtn = Button(this).apply {
text = "취소"
textSize = 18f
setBackgroundColor(android.graphics.Color.parseColor("#555555"))
setTextColor(android.graphics.Color.WHITE)
setPadding(40, 20, 40, 20)
}
val searchBtn = Button(this).apply {
text = "검색"
textSize = 18f
setBackgroundColor(android.graphics.Color.parseColor("#E50914"))
setTextColor(android.graphics.Color.WHITE)
setPadding(40, 20, 40, 20)
}
buttonLayout.addView(cancelBtn)
buttonLayout.addView(searchBtn)
layout.addView(titleText)
layout.addView(editText)
layout.addView(buttonLayout)
val dialog = Dialog(this, android.R.style.Theme_Translucent_NoTitleBar)
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
dialog.setContentView(layout)
dialog.window?.setLayout(
(resources.displayMetrics.widthPixels * 0.8).toInt(),
LinearLayout.LayoutParams.WRAP_CONTENT
)
dialog.window?.setBackgroundDrawableResource(android.R.color.black)
cancelBtn.setOnClickListener {
dialog.dismiss()
}
searchBtn.setOnClickListener {
val query = editText.text.toString().trim()
if (query.isNotEmpty()) {
onSearch(query)
dialog.dismiss()
}
}
editText.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEARCH || actionId == EditorInfo.IME_ACTION_DONE) {
val query = editText.text.toString().trim()
if (query.isNotEmpty()) {
onSearch(query)
dialog.dismiss()
}
true
} else {
false
}
}
dialog.setOnShowListener {
editText.post {
editText.requestFocus()
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
}
dialog.show()
}
} }

View File

@@ -57,9 +57,15 @@ class MainFragment : BrowseSupportFragment(), OnItemViewClickedListener, OnItemV
onItemViewClickedListener = this onItemViewClickedListener = this
onItemViewSelectedListener = this onItemViewSelectedListener = this
setOnSearchClickedListener { setOnSearchClickedListener {
startActivity(Intent(requireContext(), com.example.tvmon.ui.search.SearchActivity::class.java)) val activity = requireActivity() as? MainActivity
activity?.showSearchDialog { query ->
val intent = Intent(requireContext(), com.example.tvmon.ui.search.SearchActivity::class.java).apply {
putExtra("initial_query", query)
} }
startActivity(intent)
}
}
Log.w(TAG, "setupUI: UI setup complete, adapter set") Log.w(TAG, "setupUI: UI setup complete, adapter set")
} }

View File

@@ -1,5 +1,6 @@
package com.example.tvmon.ui.presenter package com.example.tvmon.ui.presenter
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.leanback.widget.ImageCardView import androidx.leanback.widget.ImageCardView
import androidx.leanback.widget.Presenter import androidx.leanback.widget.Presenter
@@ -10,44 +11,107 @@ import com.example.tvmon.data.model.Category
class ContentCardPresenter : Presenter() { class ContentCardPresenter : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = ImageCardView(parent.context).apply { val cardView = ImageCardView(parent.context).apply {
isFocusable = true isFocusable = true
isFocusableInTouchMode = true isFocusableInTouchMode = true
setBackgroundColor(parent.context.getColor(R.color.default_background)) setBackgroundColor(parent.context.getColor(R.color.default_background))
} }
return ViewHolder(cardView) return ViewHolder(cardView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val content = item as Content
val cardView = viewHolder.view as ImageCardView
val res = cardView.context.resources
cardView.titleText = content.title
cardView.contentText = null
val width = res.getDimensionPixelSize(R.dimen.card_width)
val height = res.getDimensionPixelSize(R.dimen.card_height)
cardView.setMainImageDimensions(width, height)
if (content.thumbnail.isNotBlank()) {
Glide.with(cardView.context)
.load(content.thumbnail)
.error(R.drawable.default_background)
.placeholder(R.drawable.default_background)
.centerCrop()
.into(cardView.mainImageView)
} else {
cardView.mainImageView.setImageResource(R.drawable.default_background)
}
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
val cardView = viewHolder.view as ImageCardView
cardView.badgeImage = null
cardView.mainImage = null
}
}
class SearchCardPresenter : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = ImageCardView(parent.context).apply {
isFocusable = true
isFocusableInTouchMode = true
setBackgroundColor(parent.context.getColor(R.color.default_background))
cardType = ImageCardView.CARD_TYPE_INFO_UNDER
infoVisibility = View.VISIBLE
} }
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) { cardView.setOnFocusChangeListener { v, hasFocus ->
val content = item as Content if (hasFocus) {
val cardView = viewHolder.view as ImageCardView v.animate()
val res = cardView.context.resources .scaleX(1.1f)
.scaleY(1.1f)
cardView.titleText = content.title .translationZ(10f)
cardView.contentText = null .setDuration(100)
.start()
val width = res.getDimensionPixelSize(R.dimen.card_width) } else {
val height = res.getDimensionPixelSize(R.dimen.card_height) v.animate()
cardView.setMainImageDimensions(width, height) .scaleX(1.0f)
.scaleY(1.0f)
if (content.thumbnail.isNotBlank()) { .translationZ(0f)
Glide.with(cardView.context) .setDuration(100)
.load(content.thumbnail) .start()
.error(R.drawable.default_background) }
.placeholder(R.drawable.default_background)
.centerCrop()
.into(cardView.mainImageView)
} else {
cardView.mainImageView.setImageResource(R.drawable.default_background)
}
} }
override fun onUnbindViewHolder(viewHolder: ViewHolder) { return ViewHolder(cardView)
val cardView = viewHolder.view as ImageCardView }
cardView.badgeImage = null
cardView.mainImage = null override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val content = item as Content
val cardView = viewHolder.view as ImageCardView
val res = cardView.context.resources
cardView.titleText = content.title
cardView.contentText = content.category
val width = res.getDimensionPixelSize(R.dimen.card_width)
val height = res.getDimensionPixelSize(R.dimen.card_height)
cardView.setMainImageDimensions(width, height)
if (content.thumbnail.isNotBlank()) {
Glide.with(cardView.context)
.load(content.thumbnail)
.error(R.drawable.default_background)
.placeholder(R.drawable.default_background)
.centerCrop()
.into(cardView.mainImageView)
} else {
cardView.mainImageView.setImageResource(R.drawable.default_background)
} }
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
val cardView = viewHolder.view as ImageCardView
cardView.badgeImage = null
cardView.mainImage = null
}
} }
class CategoryCardPresenter : Presenter() { class CategoryCardPresenter : Presenter() {

View File

@@ -2,85 +2,102 @@ package com.example.tvmon.ui.search
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.example.tvmon.R import com.example.tvmon.R
import com.example.tvmon.data.model.Content import com.example.tvmon.data.model.Content
import com.example.tvmon.data.scraper.TvmonScraper import com.example.tvmon.data.scraper.TvmonScraper
import com.example.tvmon.ui.detail.DetailsActivity import com.example.tvmon.ui.detail.DetailsActivity
import com.example.tvmon.ui.presenter.SearchCardPresenter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
class SearchActivity : AppCompatActivity() { class SearchActivity : AppCompatActivity() {
companion object { companion object {
private const val TAG = "TVMON_SEARCH" private const val TAG = "TVMON_SEARCH"
private const val NUM_COLUMNS = 4
}
private val scraper: TvmonScraper by inject()
private lateinit var searchView: SearchView
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: SearchResultsAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_search)
setupUI()
val initialQuery = intent.getStringExtra("initial_query")
if (!initialQuery.isNullOrBlank()) {
searchView.setQuery(initialQuery, true)
}
}
private fun setupUI() {
searchView = findViewById(R.id.search_view)
recyclerView = findViewById(R.id.search_results)
adapter = SearchResultsAdapter { content ->
openDetail(content)
} }
private val scraper: TvmonScraper by inject() recyclerView.layoutManager = GridLayoutManager(this, NUM_COLUMNS)
private lateinit var searchView: SearchView recyclerView.adapter = adapter
private lateinit var recyclerView: RecyclerView recyclerView.setHasFixedSize(true)
private lateinit var adapter: SearchResultsAdapter
override fun onCreate(savedInstanceState: Bundle?) { searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
super.onCreate(savedInstanceState) override fun onQueryTextSubmit(query: String?): Boolean {
setContentView(R.layout.activity_search) query?.let {
if (it.isNotBlank()) {
setupUI() search(it)
} }
private fun setupUI() {
searchView = findViewById(R.id.search_view)
recyclerView = findViewById(R.id.search_results)
adapter = SearchResultsAdapter { content ->
openDetail(content)
} }
return true
}
recyclerView.layoutManager = LinearLayoutManager(this) override fun onQueryTextChange(newText: String?): Boolean {
recyclerView.adapter = adapter newText?.let {
if (it.length >= 2) {
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { search(it)
override fun onQueryTextSubmit(query: String?): Boolean { }
query?.let {
if (it.isNotBlank()) {
search(it)
}
}
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
newText?.let {
if (it.length >= 2) {
search(it)
}
}
return true
}
})
searchView.requestFocus()
}
private fun search(query: String) {
lifecycleScope.launch {
val result = scraper.search(query)
runOnUiThread {
if (result.success) {
adapter.updateResults(result.results)
}
}
} }
} return true
}
})
private fun openDetail(content: Content) { searchView.requestFocus()
val intent = Intent(this, DetailsActivity::class.java).apply { }
putExtra(DetailsActivity.EXTRA_CONTENT, content)
private fun search(query: String) {
lifecycleScope.launch {
try {
val result = scraper.search(query)
runOnUiThread {
if (result.success && result.results.isNotEmpty()) {
adapter.updateResults(result.results)
} else {
adapter.updateResults(emptyList())
}
} }
startActivity(intent) } catch (e: Exception) {
runOnUiThread {
adapter.updateResults(emptyList())
}
}
} }
}
private fun openDetail(content: Content) {
val intent = Intent(this, DetailsActivity::class.java).apply {
putExtra(DetailsActivity.EXTRA_CONTENT, content)
}
startActivity(intent)
}
} }

View File

@@ -1,61 +1,37 @@
package com.example.tvmon.ui.search package com.example.tvmon.ui.search
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import androidx.leanback.widget.Presenter
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.tvmon.R
import com.example.tvmon.data.model.Content import com.example.tvmon.data.model.Content
import com.example.tvmon.ui.presenter.SearchCardPresenter
class SearchResultsAdapter( class SearchResultsAdapter(
private val onItemClick: (Content) -> Unit private val onItemClick: (Content) -> Unit
) : RecyclerView.Adapter<SearchResultsAdapter.ViewHolder>() { ) : RecyclerView.Adapter<SearchResultsAdapter.ViewHolder>() {
private var results = mutableListOf<Content>() private val presenter = SearchCardPresenter()
private var results = mutableListOf<Content>()
fun updateResults(newResults: List<Content>) { fun updateResults(newResults: List<Content>) {
results.clear() results.clear()
results.addAll(newResults) results.addAll(newResults)
notifyDataSetChanged() notifyDataSetChanged()
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context) return ViewHolder(presenter.onCreateViewHolder(parent))
.inflate(R.layout.item_search_result, parent, false) }
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val content = results[position] val content = results[position]
holder.bind(content, onItemClick) presenter.onBindViewHolder(holder.viewHolder, content)
} holder.viewHolder.view.setOnClickListener { onItemClick(content) }
}
override fun getItemCount(): Int = results.size
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val thumbnailView: ImageView = view.findViewById(R.id.thumbnail)
private val titleView: TextView = view.findViewById(R.id.title)
private val categoryView: TextView = view.findViewById(R.id.category)
fun bind(content: Content, onClick: (Content) -> Unit) { override fun getItemCount(): Int = results.size
titleView.text = content.title
categoryView.text = content.category
if (content.thumbnail.isNotBlank()) { class ViewHolder(val viewHolder: Presenter.ViewHolder) : RecyclerView.ViewHolder(viewHolder.view)
Glide.with(itemView.context)
.load(content.thumbnail)
.placeholder(R.drawable.default_background)
.error(R.drawable.default_background)
.centerCrop()
.into(thumbnailView)
} else {
thumbnailView.setImageResource(R.drawable.default_background)
}
itemView.setOnClickListener { onClick(content) }
}
}
} }