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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user