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