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

View File

@@ -15,18 +15,20 @@ class TvmonScraper {
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"
val CATEGORIES = mapOf(
"popular" to Category("popular", "인기영상", "/popular"),
"movie" to Category("movie", "영화", "/movie"),
"kor_movie" to Category("kor_movie", "한국영화", "/kor_movie"),
"drama" to Category("drama", "드라마", "/drama"),
"ent" to Category("ent", "예능프로그램", "/ent"),
"sisa" to Category("sisa", "시사/다큐", "/sisa"),
"world" to Category("world", "해외드라마", "/world"),
"ott_ent" to Category("ott_ent", "해외 (예능/다큐)", "/ott_ent"),
"ani_movie" to Category("ani_movie", "[극장판] 애니메이션", "/ani_movie"),
"animation" to Category("animation", "일반 애니메이션", "/animation")
)
val CATEGORIES = mapOf(
"popular" to Category("popular", "인기영상", "/popular"),
"movie" to Category("movie", "영화", "/movie"),
"kor_movie" to Category("kor_movie", "한국영화", "/kor_movie"),
"drama" to Category("drama", "드라마", "/drama"),
"ent" to Category("ent", "예능프로그램", "/ent"),
"sisa" to Category("sisa", "시사/다큐", "/sisa"),
"world" to Category("world", "해외드라마", "/world"),
"ott_ent" to Category("ott_ent", "해외 (예능/다큐)", "/ott_ent"),
"ani_movie" to Category("ani_movie", "[극장판] 애니메이션", "/ani_movie"),
"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")
}
@@ -61,14 +63,14 @@ class TvmonScraper {
}
}
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 matcher = pattern.matcher(url)
if (matcher.find()) {
return BASE_URL + matcher.group(1)
}
return url
private fun extractSeriesUrl(url: String): String {
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)
if (matcher.find()) {
return BASE_URL + matcher.group(1)
}
return url
}
suspend fun getHomepage(): Map<String, Any> = withContext(Dispatchers.IO) {
val html = get("$BASE_URL/") ?: return@withContext mapOf("success" to false)
@@ -407,91 +409,153 @@ class TvmonScraper {
}
}
val episodes = mutableListOf<Episode>()
val videoLinks = mutableListOf<VideoLink>()
val seenEpisodeIds = mutableSetOf<String>()
val episodes = mutableListOf<Episode>()
val videoLinks = mutableListOf<VideoLink>()
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) {
val href = link.attr("href")
if (href.isBlank()) continue
if (!href.contains("/$seriesId/") && !href.contains("/${seriesId}/")) continue
for (link in allEpisodeLinks) {
val href = link.attr("href")
if (href.isBlank()) 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)
if (!episodeIdMatch.find()) 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 episodeId = episodeIdMatch.group(1) ?: ""
if (episodeId in seenEpisodeIds) continue
seenEpisodeIds.add(episodeId)
val linkText = link.text().trim()
if (linkText.isBlank()) continue
val linkText = link.text().trim()
if (linkText.isBlank()) continue
if (linkText.contains("로그인") || linkText.contains("비밀번호") ||
linkText.contains("마이페이지") || linkText.contains("전체 목록")) {
continue
}
if (linkText.contains("로그인") || linkText.contains("비밀번호") ||
linkText.contains("마이페이지") || linkText.contains("전체 목록")) {
continue
}
val cleanLinkText = linkText
.replace("시청중", "")
.replace("NEW", "")
.trim()
val cleanLinkText = linkText
.replace("시청중", "")
.replace("NEW", "")
.trim()
val datePattern = Pattern.compile("^(\\d{2})[./](\\d{2})[./](\\d{2,4})\\s+(.+)$|^(\\d{2})[./](\\d{2})[./](\\d{2,4})(.+)$")
val dateMatch = datePattern.matcher(linkText)
var dateStr = ""
var episodeTitleStr = cleanLinkText
if (dateMatch.find()) {
val day = dateMatch.group(1) ?: dateMatch.group(5)
val month = dateMatch.group(2) ?: dateMatch.group(6)
val year = dateMatch.group(3) ?: dateMatch.group(7)
val titlePart = dateMatch.group(4) ?: dateMatch.group(8)
if (day != null && month != null && year != null) {
dateStr = if (year.length == 2) {
"20$year/$month/$day"
} else {
"$year/$month/$day"
}
episodeTitleStr = titlePart?.trim() ?: cleanLinkText
}
}
val datePattern = Pattern.compile("^(\\d{2})[./](\\d{2})[./](\\d{2,4})\\s+(.+)$|^(\\d{2})[./](\\d{2})[./](\\d{2,4})(.+)$")
val dateMatch = datePattern.matcher(linkText)
var dateStr = ""
var episodeTitleStr = 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
}
if (dateMatch.find()) {
val day = dateMatch.group(1) ?: dateMatch.group(5)
val month = dateMatch.group(2) ?: dateMatch.group(6)
val year = dateMatch.group(3) ?: dateMatch.group(7)
val titlePart = dateMatch.group(4) ?: dateMatch.group(8)
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 (day != null && month != null && year != null) {
dateStr = if (year.length == 2) {
"20$year/$month/$day"
} else {
"$year/$month/$day"
}
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 }
))
}
// 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 ->
val numberStr = episode.number

View File

@@ -1,13 +1,126 @@
package com.example.tvmon.ui.main
import android.app.Dialog
import android.content.Context
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 com.example.tvmon.R
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
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
onItemViewSelectedListener = this
setOnSearchClickedListener {
startActivity(Intent(requireContext(), com.example.tvmon.ui.search.SearchActivity::class.java))
setOnSearchClickedListener {
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")
}

View File

@@ -1,5 +1,6 @@
package com.example.tvmon.ui.presenter
import android.view.View
import android.view.ViewGroup
import androidx.leanback.widget.ImageCardView
import androidx.leanback.widget.Presenter
@@ -10,44 +11,107 @@ import com.example.tvmon.data.model.Category
class ContentCardPresenter : 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))
}
return ViewHolder(cardView)
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = ImageCardView(parent.context).apply {
isFocusable = true
isFocusableInTouchMode = true
setBackgroundColor(parent.context.getColor(R.color.default_background))
}
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) {
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)
}
cardView.setOnFocusChangeListener { v, hasFocus ->
if (hasFocus) {
v.animate()
.scaleX(1.1f)
.scaleY(1.1f)
.translationZ(10f)
.setDuration(100)
.start()
} else {
v.animate()
.scaleX(1.0f)
.scaleY(1.0f)
.translationZ(0f)
.setDuration(100)
.start()
}
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
val cardView = viewHolder.view as ImageCardView
cardView.badgeImage = null
cardView.mainImage = null
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 = 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() {

View File

@@ -2,85 +2,102 @@ package com.example.tvmon.ui.search
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.tvmon.R
import com.example.tvmon.data.model.Content
import com.example.tvmon.data.scraper.TvmonScraper
import com.example.tvmon.ui.detail.DetailsActivity
import com.example.tvmon.ui.presenter.SearchCardPresenter
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
class SearchActivity : AppCompatActivity() {
companion object {
private const val TAG = "TVMON_SEARCH"
companion object {
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()
private lateinit var searchView: SearchView
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: SearchResultsAdapter
recyclerView.layoutManager = GridLayoutManager(this, NUM_COLUMNS)
recyclerView.adapter = adapter
recyclerView.setHasFixedSize(true)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_search)
setupUI()
}
private fun setupUI() {
searchView = findViewById(R.id.search_view)
recyclerView = findViewById(R.id.search_results)
adapter = SearchResultsAdapter { content ->
openDetail(content)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
query?.let {
if (it.isNotBlank()) {
search(it)
}
}
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = adapter
return true
}
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
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)
}
}
override fun onQueryTextChange(newText: String?): Boolean {
newText?.let {
if (it.length >= 2) {
search(it)
}
}
}
return true
}
})
private fun openDetail(content: Content) {
val intent = Intent(this, DetailsActivity::class.java).apply {
putExtra(DetailsActivity.EXTRA_CONTENT, content)
searchView.requestFocus()
}
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
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.leanback.widget.Presenter
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.ui.presenter.SearchCardPresenter
class SearchResultsAdapter(
private val onItemClick: (Content) -> Unit
private val onItemClick: (Content) -> Unit
) : RecyclerView.Adapter<SearchResultsAdapter.ViewHolder>() {
private var results = mutableListOf<Content>()
fun updateResults(newResults: List<Content>) {
results.clear()
results.addAll(newResults)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_search_result, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val content = results[position]
holder.bind(content, onItemClick)
}
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) {
titleView.text = content.title
categoryView.text = content.category
if (content.thumbnail.isNotBlank()) {
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) }
}
}
private val presenter = SearchCardPresenter()
private var results = mutableListOf<Content>()
fun updateResults(newResults: List<Content>) {
results.clear()
results.addAll(newResults)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(presenter.onCreateViewHolder(parent))
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val content = results[position]
presenter.onBindViewHolder(holder.viewHolder, content)
holder.viewHolder.view.setOnClickListener { onItemClick(content) }
}
override fun getItemCount(): Int = results.size
class ViewHolder(val viewHolder: Presenter.ViewHolder) : RecyclerView.ViewHolder(viewHolder.view)
}