From 0c8bc6252bdb182128ba894f81a03c8bf10e4c99 Mon Sep 17 00:00:00 2001 From: tvmon-dev Date: Thu, 16 Apr 2026 09:54:02 +0900 Subject: [PATCH] Fix episode parsing, add missing categories, improve search stability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- tvmon-app/app/src/main/AndroidManifest.xml | 25 +- .../tvmon/data/scraper/TvmonScraper.kt | 244 +++++++++++------- .../com/example/tvmon/ui/main/MainActivity.kt | 121 ++++++++- .../com/example/tvmon/ui/main/MainFragment.kt | 10 +- .../tvmon/ui/presenter/CardPresenters.kt | 130 +++++++--- .../example/tvmon/ui/search/SearchActivity.kt | 135 +++++----- .../tvmon/ui/search/SearchResultsAdapter.kt | 80 ++---- 7 files changed, 492 insertions(+), 253 deletions(-) diff --git a/tvmon-app/app/src/main/AndroidManifest.xml b/tvmon-app/app/src/main/AndroidManifest.xml index c68c3be..c39d08b 100644 --- a/tvmon-app/app/src/main/AndroidManifest.xml +++ b/tvmon-app/app/src/main/AndroidManifest.xml @@ -55,19 +55,18 @@ android:theme="@style/Theme.Tvmon.Search" /> - - - - - + android:name=".ui.search.SearchActivity" + android:exported="true" + android:label="@string/search_title" + android:screenOrientation="landscape" + android:theme="@style/Theme.Tvmon.Search"> + + + + + diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt index e08d3e6..bcfb2cd 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt @@ -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 = withContext(Dispatchers.IO) { val html = get("$BASE_URL/") ?: return@withContext mapOf("success" to false) @@ -407,91 +409,153 @@ class TvmonScraper { } } - val episodes = mutableListOf() - val videoLinks = mutableListOf() - val seenEpisodeIds = mutableSetOf() +val episodes = mutableListOf() + val videoLinks = mutableListOf() + val seenEpisodeIds = mutableSetOf() - 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 diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainActivity.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainActivity.kt index 4fc3fb1..384feb9 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainActivity.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainActivity.kt @@ -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() + } } diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt index 409cb9a..aee7981 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt @@ -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") } diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt index c8ec8e8..b29a394 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt @@ -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() { diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchActivity.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchActivity.kt index 8b9477e..a559532 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchActivity.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchActivity.kt @@ -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) + } } diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchResultsAdapter.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchResultsAdapter.kt index f62d740..5c10722 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchResultsAdapter.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/search/SearchResultsAdapter.kt @@ -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() { - - private var results = mutableListOf() - - fun updateResults(newResults: List) { - 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() + + fun updateResults(newResults: List) { + 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) }