Initial commit: tvmon v1.0.0 - Android TV app with pagination fix and cast display

This commit is contained in:
tvmon-dev
2026-04-15 15:00:19 +09:00
commit 387517fd47
54 changed files with 3921 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Environment files (sensitive)
.env.local
.env2.local
# Gradle
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
# Android Studio / IntelliJ
.idea/
*.iml
# Local config
local.properties
# Python virtual environment
venv/
__pycache__/
*.pyc
.pytest_cache/
# OS
.DS_Store
Thumbs.db
# APK (will be added separately for release)
*.apk

View File

@@ -0,0 +1,81 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
id 'kotlin-parcelize'
}
android {
namespace 'com.example.tvmon'
compileSdk 34
defaultConfig {
applicationId "com.example.tvmon"
minSdk 28
targetSdk 34
versionCode 1
versionName "1.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
minifyEnabled false
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.leanback:leanback:1.0.0'
implementation 'androidx.leanback:leanback-preference:1.0.0'
implementation 'androidx.tv:tv-foundation:1.0.0-alpha10'
implementation 'androidx.tv:tv-material:1.0.0-alpha10'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
implementation 'org.jsoup:jsoup:1.17.2'
implementation 'com.github.bumptech.glide:glide:4.16.0'
kapt 'com.github.bumptech.glide:compiler:4.16.0'
implementation 'androidx.room:room-runtime:2.6.1'
implementation 'androidx.room:room-ktx:2.6.1'
kapt 'androidx.room:room-compiler:2.6.1'
implementation 'io.insert-koin:koin-android:3.5.3'
implementation 'io.insert-koin:koin-androidx-compose:3.5.3'
implementation 'io.insert-koin:koin-core:3.5.3'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}

37
tvmon-app/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,37 @@
# Add project specific ProGuard rules here.
-keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
# Jsoup
-keep class org.jsoup.** { *; }
-dontwarn org.jsoup.**
# Glide
-keep public class * implements com.bumptech.glide.module.GlideModule
-keep class * extends com.bumptech.glide.module.AppGlideModule {
<init>(...);
}
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
**[] $VALUES;
public *;
}
-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder {
*** rewind(android.os.ParcelFileDescriptor);
}
# Kotlin Coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
# Keep Parcelables
-keepclassmembers class * implements android.os.Parcelable {
static ** CREATOR;
}
# Keep Models
-keep class com.example.tvmon.data.model.** { *; }

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-feature
android:name="android.hardware.type.tv"
android:required="true" />
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.faketouch"
android:required="false" />
<application
android:name=".TvmonApplication"
android:allowBackup="true"
android:banner="@drawable/app_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:logo="@drawable/app_logo"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Tvmon"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".ui.main.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.detail.DetailsActivity"
android:exported="false"
android:screenOrientation="landscape" />
<activity
android:name=".ui.playback.PlaybackActivity"
android:exported="false"
android:screenOrientation="landscape"
android:theme="@style/Theme.Tvmon.Search" />
<activity
android:name=".ui.search.SearchActivity"
android:exported="true"
android:label="@string/search_title"
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>
</manifest>

View File

@@ -0,0 +1,21 @@
package com.example.tvmon
import android.app.Application
import com.example.tvmon.di.appModules
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import org.koin.core.logger.Level
class TvmonApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger(Level.ERROR)
androidContext(this@TvmonApplication)
modules(appModules)
}
}
}

View File

@@ -0,0 +1,19 @@
package com.example.tvmon
import android.content.Context
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
import com.bumptech.glide.load.engine.cache.LruResourceCache
import com.bumptech.glide.module.AppGlideModule
@GlideModule
class TvmonGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) {
val memoryCacheSizeBytes = 1024 * 1024 * 50
builder.setMemoryCache(LruResourceCache(memoryCacheSizeBytes.toLong()))
val diskCacheSizeBytes = 1024L * 1024L * 200L
builder.setDiskCache(InternalCacheDiskCacheFactory(context, "image_cache", diskCacheSizeBytes))
}
}

View File

@@ -0,0 +1,25 @@
package com.example.tvmon.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import com.example.tvmon.data.local.dao.BookmarkDao
import com.example.tvmon.data.local.dao.SearchHistoryDao
import com.example.tvmon.data.local.dao.WatchHistoryDao
import com.example.tvmon.data.local.entity.Bookmark
import com.example.tvmon.data.local.entity.SearchHistory
import com.example.tvmon.data.local.entity.WatchHistory
@Database(
entities = [
WatchHistory::class,
Bookmark::class,
SearchHistory::class
],
version = 1,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
abstract fun watchHistoryDao(): WatchHistoryDao
abstract fun bookmarkDao(): BookmarkDao
abstract fun searchHistoryDao(): SearchHistoryDao
}

View File

@@ -0,0 +1,40 @@
package com.example.tvmon.data.local.dao
import androidx.room.*
import com.example.tvmon.data.local.entity.Bookmark
import kotlinx.coroutines.flow.Flow
@Dao
interface BookmarkDao {
@Query("SELECT * FROM bookmarks ORDER BY timestamp DESC")
fun getAllFlow(): Flow<List<Bookmark>>
@Query("SELECT * FROM bookmarks WHERE contentId = :contentId")
suspend fun getById(contentId: String): Bookmark?
@Query("SELECT EXISTS(SELECT 1 FROM bookmarks WHERE contentId = :contentId)")
suspend fun isBookmarked(contentId: String): Boolean
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(bookmark: Bookmark)
@Delete
suspend fun delete(bookmark: Bookmark)
@Query("DELETE FROM bookmarks WHERE contentId = :contentId")
suspend fun deleteById(contentId: String)
@Query("DELETE FROM bookmarks")
suspend fun deleteAll()
@Transaction
suspend fun toggleBookmark(bookmark: Bookmark) {
val existing = getById(bookmark.contentId)
if (existing != null) {
deleteById(bookmark.contentId)
} else {
insert(bookmark)
}
}
}

View File

@@ -0,0 +1,30 @@
package com.example.tvmon.data.local.dao
import androidx.room.*
import com.example.tvmon.data.local.entity.SearchHistory
import kotlinx.coroutines.flow.Flow
@Dao
interface SearchHistoryDao {
@Query("SELECT * FROM search_history ORDER BY timestamp DESC LIMIT :limit")
fun getRecentFlow(limit: Int = 20): Flow<List<SearchHistory>>
@Query("SELECT keyword FROM search_history ORDER BY timestamp DESC LIMIT :limit")
fun getRecentKeywordsFlow(limit: Int = 10): Flow<List<String>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(history: SearchHistory)
@Query("DELETE FROM search_history WHERE keyword = :keyword")
suspend fun deleteByKeyword(keyword: String)
@Query("DELETE FROM search_history")
suspend fun deleteAll()
@Transaction
suspend fun addSearchKeyword(keyword: String) {
deleteByKeyword(keyword)
insert(SearchHistory(keyword = keyword, timestamp = System.currentTimeMillis()))
}
}

View File

@@ -0,0 +1,46 @@
package com.example.tvmon.data.local.dao
import androidx.room.*
import com.example.tvmon.data.local.entity.WatchHistory
import kotlinx.coroutines.flow.Flow
@Dao
interface WatchHistoryDao {
@Query("SELECT * FROM watch_history ORDER BY timestamp DESC")
fun getAllFlow(): Flow<List<WatchHistory>>
@Query("SELECT * FROM watch_history ORDER BY timestamp DESC LIMIT :limit")
fun getRecentFlow(limit: Int = 10): Flow<List<WatchHistory>>
@Query("SELECT * FROM watch_history WHERE contentId = :contentId")
suspend fun getById(contentId: String): WatchHistory?
@Query("SELECT * FROM watch_history WHERE contentId IN (:contentIds)")
suspend fun getByIds(contentIds: List<String>): List<WatchHistory>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(history: WatchHistory)
@Update
suspend fun update(history: WatchHistory)
@Delete
suspend fun delete(history: WatchHistory)
@Query("DELETE FROM watch_history WHERE contentId = :contentId")
suspend fun deleteById(contentId: String)
@Query("DELETE FROM watch_history")
suspend fun deleteAll()
@Transaction
suspend fun upsert(history: WatchHistory) {
val existing = getById(history.contentId)
if (existing != null) {
update(history)
} else {
insert(history)
}
}
}

View File

@@ -0,0 +1,58 @@
package com.example.tvmon.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.example.tvmon.data.model.Content
@Entity(tableName = "watch_history")
data class WatchHistory(
@PrimaryKey
val contentId: String,
val title: String,
val url: String,
val thumbnail: String,
val category: String,
val lastEpisodeNumber: String,
val lastEpisodeUrl: String,
val lastPosition: Long = 0,
val timestamp: Long = System.currentTimeMillis()
) {
fun toContent(): Content {
return Content(
id = contentId,
title = title,
url = url,
thumbnail = thumbnail,
category = category
)
}
}
@Entity(tableName = "bookmarks")
data class Bookmark(
@PrimaryKey
val contentId: String,
val title: String,
val url: String,
val thumbnail: String,
val category: String,
val timestamp: Long = System.currentTimeMillis()
) {
fun toContent(): Content {
return Content(
id = contentId,
title = title,
url = url,
thumbnail = thumbnail,
category = category
)
}
}
@Entity(tableName = "search_history")
data class SearchHistory(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val keyword: String,
val timestamp: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,86 @@
package com.example.tvmon.data.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class Category(
val key: String,
val name: String,
val path: String
) : Parcelable
@Parcelize
data class Content(
val id: String,
val title: String,
val url: String,
val thumbnail: String = "",
val category: String = ""
) : Parcelable {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Content) return false
return id == other.id
}
override fun hashCode(): Int = id.hashCode()
}
@Parcelize
data class ContentDetail(
val success: Boolean,
val url: String,
val title: String,
val thumbnail: String,
val poster: String = "",
val overview: String = "",
val country: String = "",
val year: String = "",
val cast: List<CastMember> = emptyList(),
val episodes: List<Episode>,
val videoLinks: List<VideoLink>,
val playUrl: String = ""
) : Parcelable
@Parcelize
data class CastMember(
val name: String,
val character: String = "",
val profilePath: String = ""
) : Parcelable
@Parcelize
data class Episode(
val number: String,
val title: String,
val url: String,
val type: String = "webview"
) : Parcelable
@Parcelize
data class VideoLink(
val type: String,
val url: String,
val title: String = ""
) : Parcelable
data class CategoryResult(
val success: Boolean,
val category: String,
val items: List<Content>,
val page: Int,
val pagination: Pagination
)
data class Pagination(
val current: Int,
val maxPage: Int
)
data class SearchResult(
val success: Boolean,
val keyword: String,
val results: List<Content>,
val page: Int
)

View File

@@ -0,0 +1,49 @@
package com.example.tvmon.data.repository
import com.example.tvmon.data.local.dao.BookmarkDao
import com.example.tvmon.data.local.entity.Bookmark
import com.example.tvmon.data.model.Content
import kotlinx.coroutines.flow.Flow
class BookmarkRepository(private val dao: BookmarkDao) {
fun getAllBookmarks(): Flow<List<Bookmark>> {
return dao.getAllFlow()
}
suspend fun isBookmarked(contentId: String): Boolean {
return dao.isBookmarked(contentId)
}
suspend fun addBookmark(content: Content) {
val bookmark = Bookmark(
contentId = content.id,
title = content.title,
url = content.url,
thumbnail = content.thumbnail,
category = content.category,
timestamp = System.currentTimeMillis()
)
dao.insert(bookmark)
}
suspend fun removeBookmark(contentId: String) {
dao.deleteById(contentId)
}
suspend fun toggleBookmark(content: Content) {
val bookmark = Bookmark(
contentId = content.id,
title = content.title,
url = content.url,
thumbnail = content.thumbnail,
category = content.category,
timestamp = System.currentTimeMillis()
)
dao.toggleBookmark(bookmark)
}
suspend fun clearAllBookmarks() {
dao.deleteAll()
}
}

View File

@@ -0,0 +1,57 @@
package com.example.tvmon.data.repository
import com.example.tvmon.data.local.dao.BookmarkDao
import com.example.tvmon.data.local.dao.WatchHistoryDao
import com.example.tvmon.data.local.entity.Bookmark
import com.example.tvmon.data.local.entity.WatchHistory
import com.example.tvmon.data.model.Content
import com.example.tvmon.data.model.Episode
import kotlinx.coroutines.flow.Flow
class WatchHistoryRepository(private val dao: WatchHistoryDao) {
fun getRecentHistory(limit: Int = 10): Flow<List<WatchHistory>> {
return dao.getRecentFlow(limit)
}
fun getAllHistory(): Flow<List<WatchHistory>> {
return dao.getAllFlow()
}
suspend fun addToHistory(
content: Content,
episode: Episode,
position: Long = 0
) {
val history = WatchHistory(
contentId = content.id,
title = content.title,
url = content.url,
thumbnail = content.thumbnail,
category = content.category,
lastEpisodeNumber = episode.number,
lastEpisodeUrl = episode.url,
lastPosition = position,
timestamp = System.currentTimeMillis()
)
dao.upsert(history)
}
suspend fun updatePosition(contentId: String, position: Long) {
dao.getById(contentId)?.let { history ->
dao.update(history.copy(lastPosition = position))
}
}
suspend fun getHistory(contentId: String): WatchHistory? {
return dao.getById(contentId)
}
suspend fun deleteHistory(contentId: String) {
dao.deleteById(contentId)
}
suspend fun clearAllHistory() {
dao.deleteAll()
}
}

View File

@@ -0,0 +1,558 @@
package com.example.tvmon.data.scraper
import com.example.tvmon.data.model.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup
import java.util.concurrent.TimeUnit
import java.util.regex.Pattern
class TvmonScraper {
companion object {
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(
"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")
)
private val NAV_PATTERNS = listOf("/login", "/logout", "/register", "/mypage", "/bbs/", "/menu", "/faq", "/privacy")
}
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
private suspend fun get(url: String): String? = withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url(url)
.header("User-Agent", USER_AGENT)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7")
.header("Referer", BASE_URL)
.build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
response.body?.string()
} else {
null
}
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
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
}
suspend fun getHomepage(): Map<String, Any> = withContext(Dispatchers.IO) {
val html = get("$BASE_URL/") ?: return@withContext mapOf("success" to false)
val doc = Jsoup.parse(html)
val result = mutableMapOf<String, Any>(
"success" to true,
"popular" to mutableListOf<Map<String, String>>(),
"latest" to mutableMapOf<String, List<Map<String, String>>>()
)
val popularLinks = doc.select("a[href*='/drama/'], a[href*='/movie/'], a[href*='/kor_movie/'], a[href*='/world/'], a[href*='/animation/']")
val popularList = mutableListOf<Map<String, String>>()
for (link in popularLinks.take(10)) {
val href = link.attr("href")
val title = link.text()
if (title.isNotBlank() && href.isNotBlank()) {
popularList.add(mapOf(
"title" to title,
"url" to resolveUrl(href),
"category" to getCategoryFromUrl(href)
))
}
}
result["popular"] = popularList
val latest = mutableMapOf<String, List<Map<String, String>>>()
listOf("영화", "드라마", "예능", "해외드라마", "애니메이션").forEach { section ->
latest[section] = emptyList()
}
val movieItems = doc.select("a[href*='/movie/']")
val movieList = mutableListOf<Map<String, String>>()
for (item in movieItems.take(6)) {
val href = item.attr("href")
val title = item.text()
val img = item.selectFirst("img")
val imgUrl = img?.attr("src") ?: img?.attr("data-src") ?: ""
if (title.isNotBlank() && href.isNotBlank() && "/movie/" in href) {
movieList.add(mapOf(
"title" to title,
"url" to resolveUrl(href),
"thumbnail" to imgUrl
))
}
}
latest["영화"] = movieList
val dramaItems = doc.select("a[href*='/drama/']")
val dramaList = mutableListOf<Map<String, String>>()
for (item in dramaItems.take(6)) {
val href = item.attr("href")
val title = item.text()
val img = item.selectFirst("img")
val imgUrl = img?.attr("src") ?: img?.attr("data-src") ?: ""
if (title.isNotBlank() && href.isNotBlank() && "/drama/" in href) {
dramaList.add(mapOf(
"title" to title,
"url" to resolveUrl(href),
"thumbnail" to imgUrl
))
}
}
latest["드라마"] = dramaList
result["latest"] = latest
result
}
suspend fun getCategory(categoryKey: String, page: Int = 1): CategoryResult = withContext(Dispatchers.IO) {
val catInfo = CATEGORIES[categoryKey] ?: return@withContext CategoryResult(
success = false,
category = "Unknown",
items = emptyList(),
page = page,
pagination = Pagination(1, 1)
)
val url = if (page == 1) "$BASE_URL${catInfo.path}" else "$BASE_URL${catInfo.path}?page=$page"
val html = get(url) ?: return@withContext CategoryResult(
success = false,
category = catInfo.name,
items = emptyList(),
page = page,
pagination = Pagination(page, 1)
)
val doc = Jsoup.parse(html)
val items = mutableListOf<Content>()
val seen = mutableSetOf<String>()
val titleLinks = doc.select("a.title[href*=/$categoryKey/]")
for (titleLink in titleLinks) {
val href = titleLink.attr("href")
if (href.isBlank() || "/$categoryKey/" !in href) continue
val fullUrl = resolveUrl(href)
if (fullUrl in seen) continue
seen.add(fullUrl)
val idMatch = Pattern.compile("/$categoryKey/(\\d+)/").matcher(href)
val contentId = if (idMatch.find()) idMatch.group(1) ?: "" else ""
val title = titleLink.text().trim()
val imgLink = doc.selectFirst("a.img[href=$href]")
val imgTag = imgLink?.selectFirst("img")
val imgUrl = imgTag?.attr("src") ?: ""
if (title.isNotBlank()) {
items.add(Content(
id = contentId,
title = title,
url = fullUrl,
thumbnail = if (imgUrl.startsWith("http")) imgUrl else BASE_URL + imgUrl,
category = categoryKey
))
}
}
var maxPage = 1
val pageLinks = doc.select("a[href*='/page/'], a[href*='page=']")
for (pageLink in pageLinks) {
val pageMatch = Pattern.compile("[/&]?page[=/](\\d+)").matcher(pageLink.attr("href"))
if (pageMatch.find()) {
val pageNum = pageMatch.group(1).toInt()
if (pageNum > maxPage) maxPage = pageNum
}
}
CategoryResult(
success = true,
category = catInfo.name,
items = items,
page = page,
pagination = Pagination(page, maxPage)
)
}
suspend fun getDetail(urlOrId: String, category: String? = null): ContentDetail = withContext(Dispatchers.IO) {
val seriesUrl = extractSeriesUrl(
if (urlOrId.startsWith("http")) urlOrId
else if (category != null) "$BASE_URL/$category/$urlOrId" else "$BASE_URL/movie/$urlOrId"
)
val html = get(seriesUrl) ?: return@withContext ContentDetail(
success = false,
url = seriesUrl,
title = "",
thumbnail = "",
poster = "",
overview = "",
country = "",
year = "",
cast = emptyList(),
episodes = emptyList(),
videoLinks = emptyList(),
playUrl = ""
)
val doc = Jsoup.parse(html)
var title = ""
val titleTag = doc.selectFirst("#bo_v_title, h1, h2.title, .content-title, title")
if (titleTag != null) {
val titleText = titleTag.text()
title = titleText
.replace("다시보기", "")
.replace(" - 티비몬", "")
.replace(" - tvmon", "")
.trim()
}
if (title.isBlank()) {
val ogTitle = doc.selectFirst("meta[property=og:title]")?.attr("content") ?: ""
if (ogTitle.isNotBlank()) {
title = ogTitle.split(" - ").firstOrNull()?.trim() ?: ogTitle
}
}
var poster = ""
val posterImg = doc.selectFirst("#bo_v_poster img")
if (posterImg != null) {
val posterSrc = posterImg.attr("src")
if (posterSrc.isNotBlank() && posterSrc != "/img/no3.png") {
poster = resolveUrl(posterSrc)
}
}
if (poster.isBlank()) {
val ogImage = doc.selectFirst("meta[property=og:image]")?.attr("content") ?: ""
if (ogImage.isNotBlank() && ogImage != "$BASE_URL/") {
poster = ogImage
}
}
var country = ""
var year = ""
var language = ""
val infoDls = doc.select("#bo_v_movinfo dl.bo_v_info, dl.bo_v_info")
for (dl in infoDls) {
val dds = dl.select("dd")
for (dd in dds) {
val text = dd.text().trim()
if (text.contains(":")) {
val parts = text.split(":", limit = 2)
val key = parts[0].trim()
val value = parts.getOrNull(1)?.trim() ?: ""
when {
key.contains("국가") -> country = value
key.contains("언어") -> language = value
key.contains("개봉") || key.contains("방영") || key.contains("년도") -> {
val yearMatch = Pattern.compile("(\\d{4})").matcher(value)
if (yearMatch.find()) {
year = yearMatch.group(1)
}
}
}
}
}
}
if (country.isNullOrBlank()) country = "대한민국"
var overview = ""
val metaDesc = doc.selectFirst("meta[name=description], meta[property=og:description]")
if (metaDesc != null) {
overview = metaDesc.attr("content").trim()
}
val contentDiv = doc.selectFirst("#bo_v_atc, .bo_v_atc, #bo_v_content")
if (contentDiv != null) {
val contentText = contentDiv.text().trim()
if (contentText.isNotBlank() && contentText.length > overview.length) {
overview = contentText
}
}
val castList = mutableListOf<CastMember>()
val seriesHtml = get(seriesUrl)
if (seriesHtml != null) {
val seriesDoc = Jsoup.parse(seriesHtml)
val castLinks = seriesDoc.select(".cast-list a, .bo_v_info a[href*='/actor/'], .cast-item a, .cast a, #bo_v_info a")
for (link in castLinks) {
val href = link.attr("href")
val name = link.text().trim()
if (name.isNotBlank() && name.length > 1) {
val profilePath = resolveUrl(link.selectFirst("img")?.attr("src") ?: "")
if (name !in castList.map { it.name }) {
castList.add(CastMember(name = name, character = "", profilePath = profilePath))
}
}
}
if (castList.isEmpty()) {
val infoText = seriesDoc.selectFirst(".bo_v_info, .info-area, #bo_v_info")?.text() ?: ""
val patterns = listOf(
"출연[^:]*[:\\s]*(.+?)(?:\\n|$)",
"출演[^:]*[:\\s]*(.+?)(?:\\n|$)",
"배우[^:]*[:\\s]*(.+?)(?:\\n|$)",
"Cast[^:]*[:\\s]*(.+?)(?:\\n|$)"
)
for (pattern in patterns) {
val castMatch = Pattern.compile(pattern, Pattern.CASE_INSENSITIVE).matcher(infoText)
if (castMatch.find()) {
val castStr = castMatch.group(1).trim()
val castNames = castStr.split(",", " ", "·", "").map { it.trim() }.filter { it.isNotBlank() && it.length > 1 }
castNames.take(10).forEach { name ->
if (name !in castList.map { it.name }) {
castList.add(CastMember(name = name, character = ""))
}
}
if (castList.isNotEmpty()) break
}
}
}
}
val episodes = mutableListOf<Episode>()
val videoLinks = mutableListOf<VideoLink>()
val seenEpisodeIds = mutableSetOf<String>()
val episodeLinks = doc.select(".next-ep-list-scroll .ep-item, .ep-item")
for (link in episodeLinks) {
val href = link.attr("href")
if (href.isBlank()) continue
val fullUrl = resolveUrl(href)
val episodeIdMatch = Pattern.compile("/(\\d+)/(\\d+)$").matcher(href)
if (!episodeIdMatch.find()) continue
val episodeId = episodeIdMatch.group(2) ?: ""
if (episodeId in seenEpisodeIds) continue
seenEpisodeIds.add(episodeId)
val titleEl = link.selectFirst(".ep-item-title")
val linkText = titleEl?.text()?.trim() ?: link.text().trim()
val episodeNumMatch = Pattern.compile("(\\d+ 화|\\d+ 회|EP?\\d+|제?\\d+부?|\\d+)").matcher(linkText)
val episodeTitle = if (episodeNumMatch.find()) {
episodeNumMatch.group(1)
} else {
"Episode ${episodes.size + 1}"
}
episodes.add(Episode(
number = episodeTitle ?: "Episode ${episodes.size + 1}",
title = linkText.ifBlank { episodeTitle ?: "Episode ${episodes.size + 1}" },
url = fullUrl,
type = "webview"
))
videoLinks.add(VideoLink(
type = "play_page",
url = fullUrl,
title = linkText.ifBlank { episodeTitle ?: "Episode ${videoLinks.size + 1}" }
))
}
episodes.sortByDescending { episode ->
val numberStr = episode.number
val pattern = Pattern.compile("\\d+")
val matcher = pattern.matcher(numberStr)
if (matcher.find()) {
matcher.group().toIntOrNull() ?: 0
} else {
0
}
}
val videoLinkMap = videoLinks.associateBy { it.url }
videoLinks.clear()
episodes.forEach { episode ->
videoLinkMap[episode.url]?.let { videoLinks.add(it) }
}
val playUrl = episodes.firstOrNull()?.url ?: ""
ContentDetail(
success = true,
url = seriesUrl,
title = title,
thumbnail = poster.ifBlank {
episodes.firstOrNull()?.let {
""
} ?: ""
},
poster = poster,
overview = overview,
country = country,
year = year,
cast = castList,
episodes = episodes,
videoLinks = videoLinks,
playUrl = playUrl
)
}
suspend fun search(keyword: String, page: Int = 1): SearchResult = withContext(Dispatchers.IO) {
val encoded = java.net.URLEncoder.encode(keyword, "UTF-8")
val url = if (page == 1) "$BASE_URL/search?stx=$encoded" else "$BASE_URL/search?stx=$encoded&page=$page"
val html = get(url) ?: return@withContext SearchResult(
success = false,
keyword = keyword,
results = emptyList(),
page = page
)
val doc = Jsoup.parse(html)
val results = mutableListOf<Content>()
val seen = mutableSetOf<String>()
val allLinks = doc.select("a[href*='/movie/'], a[href*='/drama/'], a[href*='/ent/'], a[href*='/world/'], a[href*='/animation/'], a[href*='/kor_movie/']")
for (link in allLinks) {
val href = link.attr("href")
if (href.isBlank()) continue
val fullUrl = resolveUrl(href)
if (fullUrl in seen) continue
if (NAV_PATTERNS.any { it in fullUrl }) continue
seen.add(fullUrl)
val idMatch = Pattern.compile("/(\\d+)(?:/|\\$|\\?)").matcher(href)
val contentId = if (idMatch.find()) idMatch.group(1) else ""
val category = getCategoryFromUrl(href)
val imgTag = link.selectFirst("img")
val imgUrl = imgTag?.attr("src") ?: imgTag?.attr("data-src") ?: ""
var title = link.text()
if (title.isBlank()) {
val titleTag = link.selectFirst(".title, .movie-title")
title = titleTag?.text() ?: ""
}
if (title.isNotBlank()) {
results.add(Content(
id = contentId ?: "",
title = title,
url = fullUrl,
thumbnail = imgUrl,
category = category
))
}
}
SearchResult(
success = true,
keyword = keyword,
results = results,
page = page
)
}
suspend fun getPopular(): List<Content> = withContext(Dispatchers.IO) {
val html = get("$BASE_URL/popular") ?: return@withContext emptyList()
val doc = Jsoup.parse(html)
val items = mutableListOf<Content>()
val seen = mutableSetOf<String>()
val allLinks = doc.select("a[href*='/movie/'], a[href*='/drama/'], a[href*='/ent/'], a[href*='/world/'], a[href*='/animation/'], a[href*='/kor_movie/']")
for (link in allLinks) {
val href = link.attr("href")
if (href.isBlank()) continue
val fullUrl = resolveUrl(href)
if (fullUrl in seen) continue
if (NAV_PATTERNS.any { it in fullUrl }) continue
seen.add(fullUrl)
val idMatch = Pattern.compile("/(\\d+)(?:/|\\$|\\?)").matcher(href)
val contentId = if (idMatch.find()) idMatch.group(1) else ""
val category = getCategoryFromUrl(href)
val imgTag = link.selectFirst("img")
val imgUrl = imgTag?.attr("src") ?: imgTag?.attr("data-src") ?: ""
var title = link.text()
if (title.isBlank()) {
val titleTag = link.selectFirst(".title, .movie-title")
title = titleTag?.text() ?: ""
}
if (title.isNotBlank()) {
items.add(Content(
id = contentId ?: "",
title = title,
url = fullUrl,
thumbnail = imgUrl,
category = category
))
}
}
items
}
private fun resolveUrl(href: String): String {
return if (href.startsWith("http")) {
href
} else {
BASE_URL + (if (href.startsWith("/")) href else "/$href")
}
}
private fun getCategoryFromUrl(url: String): String {
for (catKey in CATEGORIES.keys) {
if ("/$catKey/" in url) {
return catKey
}
}
return "unknown"
}
}

View File

@@ -0,0 +1,40 @@
package com.example.tvmon.di
import androidx.room.Room
import com.example.tvmon.data.local.AppDatabase
import com.example.tvmon.data.repository.BookmarkRepository
import com.example.tvmon.data.repository.WatchHistoryRepository
import com.example.tvmon.data.scraper.TvmonScraper
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val databaseModule = module {
single {
Room.databaseBuilder(
androidContext(),
AppDatabase::class.java,
"tvmon_database"
)
.fallbackToDestructiveMigration()
.build()
}
single { get<AppDatabase>().watchHistoryDao() }
single { get<AppDatabase>().bookmarkDao() }
single { get<AppDatabase>().searchHistoryDao() }
}
val repositoryModule = module {
single { WatchHistoryRepository(get()) }
single { BookmarkRepository(get()) }
}
val scraperModule = module {
single { TvmonScraper() }
}
val appModules = listOf(
databaseModule,
repositoryModule,
scraperModule
)

View File

@@ -0,0 +1,32 @@
package com.example.tvmon.ui.detail
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.FragmentActivity
import com.example.tvmon.R
import com.example.tvmon.data.model.Content
class DetailsActivity : FragmentActivity() {
companion object {
const val EXTRA_CONTENT = "extra_content"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_details)
val content = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(EXTRA_CONTENT, Content::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(EXTRA_CONTENT)
}
if (savedInstanceState == null && content != null) {
supportFragmentManager.beginTransaction()
.replace(R.id.details_fragment, DetailsFragment.newInstance(content))
.commitNow()
}
}
}

View File

@@ -0,0 +1,443 @@
package com.example.tvmon.ui.detail
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.leanback.app.DetailsSupportFragment
import androidx.leanback.widget.*
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.example.tvmon.R
import com.example.tvmon.data.model.Content
import com.example.tvmon.data.model.Episode
import com.example.tvmon.data.model.ContentDetail
import com.example.tvmon.data.model.CastMember
import com.example.tvmon.data.repository.WatchHistoryRepository
import com.example.tvmon.data.scraper.TvmonScraper
import com.example.tvmon.ui.playback.PlaybackActivity
import com.example.tvmon.ui.presenter.EpisodePresenter
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
class DetailsFragment : DetailsSupportFragment() {
companion object {
private const val TAG = "TVMON_DETAIL"
private const val ARG_CONTENT = "content"
private const val ACTION_PLAY = 1L
private const val ACTION_BOOKMARK = 2L
private const val ACTION_REFRESH = 3L
fun newInstance(content: Content): DetailsFragment {
val fragment = DetailsFragment()
val args = Bundle()
args.putParcelable(ARG_CONTENT, content)
fragment.arguments = args
return fragment
}
}
private val scraper: TvmonScraper by inject()
private val watchHistoryRepository: WatchHistoryRepository by inject()
private lateinit var content: Content
private lateinit var rowsAdapter: ArrayObjectAdapter
private var cachedDetail: ContentDetail? = null
private var isDataLoaded = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.w(TAG, "=== DetailsFragment onCreate ===")
content = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
requireArguments().getParcelable(ARG_CONTENT, Content::class.java)!!
} else {
@Suppress("DEPRECATION")
requireArguments().getParcelable(ARG_CONTENT)!!
}
Log.w(TAG, "Content: ${content.title}, url=${content.url}")
setupDetails()
loadDetailInfo()
onItemViewClickedListener = ItemViewClickedListener()
}
override fun onResume() {
super.onResume()
if (isDataLoaded && cachedDetail != null) {
refreshDetailInfo()
}
}
private fun setupDetails() {
Log.w(TAG, "setupDetails: Setting up details UI")
val presenterSelector = ClassPresenterSelector()
val detailsPresenter = FullWidthDetailsOverviewRowPresenter(DetailsDescriptionPresenter())
detailsPresenter.actionsBackgroundColor = ContextCompat.getColor(requireContext(), R.color.primary)
detailsPresenter.backgroundColor = ContextCompat.getColor(requireContext(), R.color.detail_background)
presenterSelector.addClassPresenter(DetailsOverviewRow::class.java, detailsPresenter)
presenterSelector.addClassPresenter(ListRow::class.java, ListRowPresenter())
rowsAdapter = ArrayObjectAdapter(presenterSelector)
val detailsOverview = DetailsOverviewRow(content).apply {
imageDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.default_background)
if (content.thumbnail.isNotBlank()) {
Glide.with(requireContext())
.load(content.thumbnail)
.centerCrop()
.error(R.drawable.default_background)
.into(object : CustomTarget<android.graphics.drawable.Drawable>() {
override fun onResourceReady(
resource: android.graphics.drawable.Drawable,
transition: Transition<in android.graphics.drawable.Drawable>?
) {
imageDrawable = resource
}
override fun onLoadCleared(placeholder: android.graphics.drawable.Drawable?) {
}
})
}
}
val actionAdapter = ArrayObjectAdapter()
actionAdapter.add(
Action(
ACTION_PLAY,
getString(R.string.play),
null
)
)
actionAdapter.add(
Action(
ACTION_BOOKMARK,
getString(R.string.bookmark),
null
)
)
actionAdapter.add(
Action(
ACTION_REFRESH,
"새로고침",
null
)
)
detailsOverview.actionsAdapter = actionAdapter
rowsAdapter.add(detailsOverview)
adapter = rowsAdapter
episodesRowIndex = 1
Log.w(TAG, "setupDetails: Done, rowsAdapter size=${rowsAdapter.size()}, episodesRowIndex=$episodesRowIndex")
}
private var episodesRowIndex = -1
private fun loadDetailInfo() {
Log.w(TAG, "loadDetailInfo: Fetching detail for ${content.url}")
lifecycleScope.launch {
try {
val detail = scraper.getDetail(content.url, content.category)
Log.w(TAG, "loadDetailInfo: success=${detail.success}, episodes=${detail.episodes.size}")
cachedDetail = detail
if (detail.success && detail.episodes.isNotEmpty()) {
Log.w(TAG, "loadDetailInfo: Adding ${detail.episodes.size} episodes")
updateDetailsOverview(detail)
addEpisodesRow(detail.episodes)
if (detail.cast.isNotEmpty()) {
addCastRow(detail.cast)
}
isDataLoaded = true
} else if (detail.success && detail.episodes.isEmpty()) {
Log.w(TAG, "loadDetailInfo: No episodes, opening playback directly")
openPlayback(content.url, content.title)
} else {
Log.e(TAG, "loadDetailInfo: Failed to load detail, success=${detail.success}")
}
} catch (e: Exception) {
Log.e(TAG, "loadDetailInfo: ERROR - ${e.javaClass.simpleName}: ${e.message}", e)
}
}
}
private fun refreshDetailInfo() {
Log.w(TAG, "refreshDetailInfo: Refreshing detail for ${content.url}")
lifecycleScope.launch {
try {
val detail = scraper.getDetail(content.url, content.category)
Log.w(TAG, "refreshDetailInfo: success=${detail.success}, episodes=${detail.episodes.size}")
if (detail.success) {
cachedDetail = detail
updateDetailsOverview(detail)
if (detail.episodes.isNotEmpty()) {
updateEpisodesRow(detail.episodes)
}
if (detail.cast.isNotEmpty()) {
updateCastRow(detail.cast)
}
}
} catch (e: Exception) {
Log.e(TAG, "refreshDetailInfo: ERROR - ${e.javaClass.simpleName}: ${e.message}", e)
}
}
}
private fun updateDetailsOverview(detail: ContentDetail) {
activity?.runOnUiThread {
val detailsOverview = rowsAdapter.get(0) as? DetailsOverviewRow ?: return@runOnUiThread
val updatedContent = Content(
id = content.id,
title = detail.title.ifBlank { content.title },
url = content.url,
thumbnail = detail.poster.ifBlank { detail.thumbnail }.ifBlank { content.thumbnail },
category = content.category
)
detailsOverview.item = updatedContent
if (detail.poster.isNotBlank() || detail.thumbnail.isNotBlank()) {
val imageUrl = detail.poster.ifBlank { detail.thumbnail }
Glide.with(requireContext())
.load(imageUrl)
.centerCrop()
.error(R.drawable.default_background)
.into(object : CustomTarget<android.graphics.drawable.Drawable>() {
override fun onResourceReady(
resource: android.graphics.drawable.Drawable,
transition: Transition<in android.graphics.drawable.Drawable>?
) {
detailsOverview.imageDrawable = resource
}
override fun onLoadCleared(placeholder: android.graphics.drawable.Drawable?) {}
})
}
rowsAdapter.notifyArrayItemRangeChanged(0, 1)
}
}
private fun addEpisodesRow(episodes: List<Episode>) {
val episodesRowAdapter = ArrayObjectAdapter(EpisodePresenter())
episodes.forEachIndexed { index, episode ->
Log.d(TAG, " Episode $index: ${episode.number} - ${episode.title}")
episodesRowAdapter.add(episode)
}
val header = HeaderItem(getString(R.string.episodes))
val row = ListRow(header, episodesRowAdapter)
activity?.runOnUiThread {
if (episodesRowIndex >= 0 && episodesRowIndex < rowsAdapter.size()) {
rowsAdapter.add(episodesRowIndex, row)
} else {
rowsAdapter.add(row)
}
Log.w(TAG, "loadDetailInfo: Added episodes row at index $episodesRowIndex, rowsAdapter size=${rowsAdapter.size()}")
}
}
private fun updateEpisodesRow(episodes: List<Episode>) {
activity?.runOnUiThread {
val existingRowIndex = findRowIndexByHeader(getString(R.string.episodes))
if (existingRowIndex >= 0) {
rowsAdapter.removeItems(existingRowIndex, 1)
}
val episodesRowAdapter = ArrayObjectAdapter(EpisodePresenter())
episodes.forEach { episode ->
episodesRowAdapter.add(episode)
}
val header = HeaderItem(getString(R.string.episodes))
val row = ListRow(header, episodesRowAdapter)
if (existingRowIndex >= 0 && existingRowIndex < rowsAdapter.size()) {
rowsAdapter.add(existingRowIndex, row)
} else {
rowsAdapter.add(row)
}
}
}
private fun addCastRow(cast: List<CastMember>) {
if (cast.isEmpty()) return
val castAdapter = ArrayObjectAdapter(CastPresenter())
cast.forEach { member ->
castAdapter.add(member)
}
val header = HeaderItem("출연진")
val row = ListRow(header, castAdapter)
activity?.runOnUiThread {
rowsAdapter.add(row)
}
}
private fun updateCastRow(cast: List<CastMember>) {
activity?.runOnUiThread {
val existingRowIndex = findRowIndexByHeader("출연진")
if (existingRowIndex >= 0) {
rowsAdapter.removeItems(existingRowIndex, 1)
}
if (cast.isNotEmpty()) {
addCastRow(cast)
}
}
}
private fun findRowIndexByHeader(headerText: String): Int {
for (i in 0 until rowsAdapter.size()) {
val row = rowsAdapter.get(i) as? ListRow ?: continue
if (row.headerItem.name == headerText) {
return i
}
}
return -1
}
private inner class ItemViewClickedListener : OnItemViewClickedListener {
override fun onItemClicked(
itemViewHolder: Presenter.ViewHolder?,
item: Any?,
rowViewHolder: RowPresenter.ViewHolder?,
row: Row?
) {
Log.w(TAG, "onItemClicked: item=$item")
when (item) {
is Action -> {
when (item.id) {
ACTION_PLAY.toLong() -> {
lifecycleScope.launch {
val detail = cachedDetail ?: scraper.getDetail(content.url, content.category)
if (detail.playUrl.isNotBlank()) {
detail.episodes.firstOrNull()?.let { ep ->
watchHistoryRepository.addToHistory(content, ep)
}
openPlayback(detail.playUrl, detail.title)
} else {
openPlayback(content.url, content.title)
}
}
}
ACTION_BOOKMARK.toLong() -> {
}
ACTION_REFRESH.toLong() -> {
refreshDetailInfo()
}
}
}
is Episode -> {
lifecycleScope.launch {
watchHistoryRepository.addToHistory(content, item)
}
openPlayback(item.url, item.title)
}
is CastMember -> {
}
}
}
}
private fun openPlayback(url: String, title: String) {
Log.w(TAG, "openPlayback: url=$url, title=$title")
val intent = Intent(requireContext(), PlaybackActivity::class.java).apply {
putExtra(PlaybackActivity.EXTRA_URL, url)
putExtra(PlaybackActivity.EXTRA_TITLE, title)
}
startActivity(intent)
}
private inner class DetailsDescriptionPresenter : AbstractDetailsDescriptionPresenter() {
override fun onBindDescription(viewHolder: ViewHolder, item: Any) {
val contentItem = item as Content
viewHolder.title.text = cachedDetail?.title?.ifBlank { contentItem.title } ?: contentItem.title
val subtitle = buildString {
cachedDetail?.let { detail ->
if (detail.year.isNotBlank()) {
append(detail.year)
append("")
}
if (detail.country.isNotBlank()) {
if (isNotEmpty()) append(" | ")
append(detail.country)
}
}
}
viewHolder.subtitle.text = subtitle
val body = buildString {
cachedDetail?.let { detail ->
if (detail.overview.isNotBlank()) {
append(detail.overview)
}
}
}
viewHolder.body.text = body
}
}
private inner class CastPresenter : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): Presenter.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: Presenter.ViewHolder, item: Any) {
val castMember = item as CastMember
val cardView = viewHolder.view as ImageCardView
val res = cardView.context.resources
cardView.titleText = castMember.name
cardView.contentText = if (castMember.character.isNotBlank()) castMember.character else ""
val width = res.getDimensionPixelSize(R.dimen.cast_card_width)
val height = res.getDimensionPixelSize(R.dimen.cast_card_height)
cardView.setMainImageDimensions(width, height)
if (castMember.profilePath.isNotBlank()) {
Glide.with(cardView.context)
.load(castMember.profilePath)
.centerCrop()
.error(R.drawable.default_background)
.into(cardView.mainImageView)
} else {
cardView.mainImageView.setImageResource(R.drawable.default_background)
}
}
override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder) {
val cardView = viewHolder.view as ImageCardView
cardView.badgeImage = null
cardView.mainImage = null
}
}
}

View File

@@ -0,0 +1,13 @@
package com.example.tvmon.ui.main
import android.os.Bundle
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)
}
}

View File

@@ -0,0 +1,273 @@
package com.example.tvmon.ui.main
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.leanback.app.BrowseSupportFragment
import androidx.leanback.widget.*
import androidx.lifecycle.lifecycleScope
import com.example.tvmon.R
import com.example.tvmon.data.model.Category
import com.example.tvmon.data.model.Content
import com.example.tvmon.data.repository.WatchHistoryRepository
import com.example.tvmon.data.scraper.TvmonScraper
import com.example.tvmon.ui.detail.DetailsActivity
import com.example.tvmon.ui.presenter.ContentCardPresenter
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
class MainFragment : BrowseSupportFragment(), OnItemViewClickedListener, OnItemViewSelectedListener {
companion object {
private const val TAG = "TVMON_MAIN"
private const val PRELOAD_THRESHOLD = 5 // 로딩 시작 위치
}
private val scraper: TvmonScraper by inject()
private val watchHistoryRepository: WatchHistoryRepository by inject()
private val rowsAdapter = ArrayObjectAdapter(ListRowPresenter())
private val categoryPages = mutableMapOf<String, Int>()
private val categoryLoading = mutableMapOf<String, Boolean>()
private val categoryMaxPage = mutableMapOf<String, Int>()
private val categoryItems = mutableMapOf<String, MutableList<Content>>()
private val categoryRowAdapters = mutableMapOf<String, ArrayObjectAdapter>()
private val handler = Handler(Looper.getMainLooper())
private var currentSelectedRowIndex = -1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.w(TAG, "=== MainFragment onCreate ===")
try {
setupUI()
setupEventListeners()
Log.w(TAG, "setupUI completed successfully")
} catch (e: Exception) {
Log.e(TAG, "Error in onCreate", e)
}
}
override fun onStart() {
super.onStart()
Log.w(TAG, "=== MainFragment onStart ===")
loadData()
}
private fun setupUI() {
Log.w(TAG, "setupUI: Setting up UI")
headersState = HEADERS_ENABLED
title = getString(R.string.browse_title)
brandColor = ContextCompat.getColor(requireContext(), R.color.primary)
searchAffordanceColor = ContextCompat.getColor(requireContext(), R.color.search_opaque)
adapter = rowsAdapter
onItemViewClickedListener = this
onItemViewSelectedListener = this
setOnSearchClickedListener {
startActivity(Intent(requireContext(), com.example.tvmon.ui.search.SearchActivity::class.java))
}
Log.w(TAG, "setupUI: UI setup complete, adapter set")
}
private fun setupEventListeners() {
onItemViewClickedListener = this
}
private fun handleRowSelection(position: Int) {
if (position == currentSelectedRowIndex) return
currentSelectedRowIndex = position
if (position >= 0 && position < rowsAdapter.size()) {
val row = rowsAdapter.get(position) as? ListRow ?: return
val categoryKey = findCategoryKeyForRow(position)
if (categoryKey != null && categoryLoading[categoryKey] != true) {
val currentPage = categoryPages[categoryKey] ?: 1
val maxPage = categoryMaxPage[categoryKey] ?: 1
Log.w(TAG, "handleRowSelection: $categoryKey currentPage=$currentPage maxPage=$maxPage")
if (currentPage < maxPage) {
loadNextPage(categoryKey, currentPage + 1)
}
}
}
}
private fun findCategoryKeyForRow(rowIndex: Int): String? {
for ((key, _) in categoryPages) {
val catIndex = TvmonScraper.CATEGORIES.keys.toList().indexOf(key)
if (catIndex == rowIndex) return key
}
val row = rowsAdapter.get(rowIndex) as? ListRow ?: return null
val headerName = row.headerItem?.name ?: return null
for ((key, cat) in TvmonScraper.CATEGORIES) {
if (cat.name == headerName) return key
}
return null
}
private fun loadNextPage(categoryKey: String, page: Int) {
if (categoryLoading[categoryKey] == true) return
categoryLoading[categoryKey] = true
lifecycleScope.launch {
try {
val result = scraper.getCategory(categoryKey, page)
Log.w(TAG, "loadNextPage: $categoryKey page $page success=${result.success}, items=${result.items.size}")
if (result.success && result.items.isNotEmpty()) {
val items = categoryItems.getOrPut(categoryKey) { mutableListOf() }
items.addAll(result.items)
val adapter = categoryRowAdapters[categoryKey]
adapter?.let {
result.items.forEach { item -> it.add(item) }
}
val loadedPage = page
categoryPages[categoryKey] = loadedPage
Log.w(TAG, "Loaded page $page for $categoryKey, total items=${items.size}")
val currentMaxPage = categoryMaxPage[categoryKey] ?: 1
if (loadedPage < currentMaxPage) {
handler.postDelayed({
loadNextPage(categoryKey, loadedPage + 1)
}, 500)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error loading page $page for $categoryKey", e)
} finally {
categoryLoading[categoryKey] = false
}
}
}
private fun loadData() {
Log.w(TAG, "=== loadData called ===")
lifecycleScope.launch {
try {
loadCategories()
} catch (e: Exception) {
Log.e(TAG, "FATAL ERROR in loadData", e)
activity?.runOnUiThread {
Toast.makeText(requireContext(), "데이터 로드 실패: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
}
private suspend fun loadCategories() {
Log.w(TAG, "=== loadCategories: Starting ===")
Log.w(TAG, "Categories to load: ${TvmonScraper.CATEGORIES.keys}")
var successCount = 0
var failCount = 0
TvmonScraper.CATEGORIES.values.forEach { category ->
Log.w(TAG, "Loading category: ${category.key} - ${category.name}")
val success = loadCategoryRows(category)
if (success) successCount++ else failCount++
}
Log.w(TAG, "=== loadCategories COMPLETE: success=$successCount, fail=$failCount ===")
Log.w(TAG, "rowsAdapter size: ${rowsAdapter.size()}")
activity?.runOnUiThread {
if (rowsAdapter.size() == 0) {
Toast.makeText(requireContext(), "카테고리를 불러오지 못했습니다", Toast.LENGTH_LONG).show()
}
}
}
private suspend fun loadCategoryRows(category: Category): Boolean {
return try {
Log.w(TAG, "Fetching: ${category.key} from ${TvmonScraper.BASE_URL}${category.path}")
val result1 = scraper.getCategory(category.key, page = 1)
Log.w(TAG, "Page 1 for ${category.key}: success=${result1.success}, items=${result1.items.size}")
val maxPage = result1.pagination.maxPage
if (result1.success && result1.items.isNotEmpty()) {
val listRowAdapter = ArrayObjectAdapter(ContentCardPresenter())
result1.items.forEach { content ->
Log.d(TAG, " Item: ${content.title} -> thumb: ${content.thumbnail}")
listRowAdapter.add(content)
}
val header = HeaderItem(category.name)
val row = ListRow(header, listRowAdapter)
activity?.runOnUiThread {
rowsAdapter.add(row)
Log.w(TAG, "ADDED ROW: ${category.name} with ${result1.items.size} items")
}
categoryPages[category.key] = 1
categoryMaxPage[category.key] = maxPage
categoryItems[category.key] = result1.items.toMutableList()
categoryRowAdapters[category.key] = listRowAdapter
if (maxPage > 1) {
categoryLoading[category.key] = false
}
true
} else {
Log.w(TAG, "No items for ${category.key}")
false
}
} catch (e: Exception) {
Log.e(TAG, "ERROR loading ${category.key}: ${e.javaClass.simpleName}: ${e.message}", e)
false
}
}
override fun onItemClicked(
itemViewHolder: Presenter.ViewHolder?,
item: Any?,
rowViewHolder: RowPresenter.ViewHolder?,
row: Row?
) {
Log.w(TAG, "=== onItemClicked: item=$item, itemViewHolder=$itemViewHolder ===")
when (item) {
is Content -> {
Log.w(TAG, "Content clicked: ${item.title}, url=${item.url}")
try {
val intent = Intent(requireContext(), DetailsActivity::class.java).apply {
putExtra(DetailsActivity.EXTRA_CONTENT, item)
}
startActivity(intent)
Log.w(TAG, "Started DetailsActivity successfully")
} catch (e: Exception) {
Log.e(TAG, "ERROR starting DetailsActivity", e)
}
}
else -> {
Log.w(TAG, "Unknown item type: ${item?.javaClass?.simpleName}")
}
}
}
override fun onItemSelected(
itemViewHolder: Presenter.ViewHolder?,
item: Any?,
rowViewHolder: RowPresenter.ViewHolder?,
row: Row?
) {
if (row is ListRow) {
val rowIndex = rowsAdapter.indexOf(row)
if (rowIndex >= 0) {
handleRowSelection(rowIndex)
}
}
}
}

View File

@@ -0,0 +1,692 @@
package com.example.tvmon.ui.playback
import android.annotation.SuppressLint
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.webkit.ConsoleMessage
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity
import com.example.tvmon.R
import java.io.ByteArrayInputStream
class PlaybackActivity : AppCompatActivity() {
companion object {
const val EXTRA_URL = "extra_url"
const val EXTRA_TITLE = "extra_title"
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 lateinit var webView: WebView
private lateinit var loadingOverlay: View
private var isVideoPlaying = false
private val handler = Handler(Looper.getMainLooper())
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@Suppress("DEPRECATION")
window.setFlags(
WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN
)
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
setContentView(R.layout.activity_playback)
val url = intent.getStringExtra(EXTRA_URL) ?: return
val title = intent.getStringExtra(EXTRA_TITLE) ?: ""
loadingOverlay = findViewById(R.id.loading_overlay)
if (title.isNotBlank()) {
setTitle("$title - Tvmon")
} else {
setTitle("Tvmon")
}
val existingWebView = findViewById<WebView>(R.id.webview)
val parent = existingWebView.parent as android.view.ViewGroup
val index = parent.indexOfChild(existingWebView)
val layoutParams = existingWebView.layoutParams
parent.removeView(existingWebView)
webView = object : WebView(this) {
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
android.util.Log.d("WebViewKey", "WebView.onKeyDown: keyCode=$keyCode")
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
return false
}
if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT ||
keyCode == KeyEvent.KEYCODE_DPAD_RIGHT ||
keyCode == KeyEvent.KEYCODE_DPAD_UP ||
keyCode == KeyEvent.KEYCODE_DPAD_DOWN ||
keyCode == KeyEvent.KEYCODE_BACK) {
return false
}
return super.onKeyDown(keyCode, event)
}
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
val keyCode = event?.keyCode ?: return super.dispatchKeyEvent(event)
android.util.Log.d("WebViewKey", "WebView.dispatchKeyEvent: keyCode=$keyCode, action=${event.action}")
return super.dispatchKeyEvent(event)
}
override fun scrollTo(x: Int, y: Int) {}
override fun scrollBy(x: Int, y: Int) {}
}
webView.id = R.id.webview
webView.layoutParams = layoutParams
webView.isFocusable = true
webView.isFocusableInTouchMode = true
parent.addView(webView, index)
webView.setOnKeyListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN) {
when (keyCode) {
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
simulateCenterClick()
toggleVideoPlayback()
true
}
KeyEvent.KEYCODE_DPAD_LEFT -> {
seekVideo(-10000)
android.util.Log.d("PlaybackActivity", "setOnKeyListener: LEFT")
true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
seekVideo(10000)
android.util.Log.d("PlaybackActivity", "setOnKeyListener: RIGHT")
true
}
KeyEvent.KEYCODE_DPAD_UP -> {
adjustVolume(0.1f)
android.util.Log.d("PlaybackActivity", "setOnKeyListener: UP")
true
}
KeyEvent.KEYCODE_DPAD_DOWN -> {
adjustVolume(-0.1f)
android.util.Log.d("PlaybackActivity", "setOnKeyListener: DOWN")
true
}
KeyEvent.KEYCODE_BACK -> {
if (webView.canGoBack()) {
webView.goBack()
}
true
}
else -> false
}
} else {
false
}
}
webView.requestFocus()
setupWebView()
webView.loadUrl(url)
}
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
val webSettings: WebSettings = webView.settings
webSettings.javaScriptEnabled = true
webSettings.domStorageEnabled = true
webSettings.allowFileAccess = true
webSettings.allowContentAccess = true
webSettings.mediaPlaybackRequiresUserGesture = false
webSettings.loadsImagesAutomatically = true
webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
webSettings.userAgentString = USER_AGENT
webSettings.useWideViewPort = true
webSettings.loadWithOverviewMode = true
webSettings.cacheMode = WebSettings.LOAD_NO_CACHE
webSettings.allowUniversalAccessFromFileURLs = true
webSettings.allowFileAccessFromFileURLs = true
webView.addJavascriptInterface(object {
@android.webkit.JavascriptInterface
fun hideLoadingOverlay() {
runOnUiThread {
loadingOverlay.visibility = View.GONE
}
}
@android.webkit.JavascriptInterface
fun isVideoPlaying() {
webView.evaluateJavascript(
"""
(function() {
var videos = document.querySelectorAll('video');
var playing = false;
videos.forEach(function(v) {
if (!v.paused && v.currentTime > 0) {
playing = true;
}
});
return playing ? 'playing' : 'not_playing';
})();
""".trimIndent()
) { result ->
android.util.Log.i("PlaybackActivity", "Video playing status: $result")
if (result == "\"playing\"") {
runOnUiThread {
loadingOverlay.visibility = View.GONE
}
}
}
}
@android.webkit.JavascriptInterface
fun onVideoPlay() {
runOnUiThread {
loadingOverlay.visibility = View.GONE
android.util.Log.d("PlaybackActivity", "Video started playing")
}
}
}, "Android")
val cookieManager = CookieManager.getInstance()
cookieManager.setAcceptCookie(true)
cookieManager.setAcceptThirdPartyCookies(webView, true)
webView.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(cm: ConsoleMessage?): Boolean {
val message = cm?.message() ?: return false
android.util.Log.d("WebViewConsole", "[$${cm.sourceId()}:${cm.lineNumber()}] $message")
if (message.contains("player") || message.contains("video") || message.contains(".m3u8") || message.contains(".mp4")) {
android.util.Log.i("WebViewConsole", "Player info: $message")
}
return true
}
override fun onPermissionRequest(request: android.webkit.PermissionRequest?) {
android.util.Log.d("PlaybackActivity", "Permission request: ${request?.resources?.joinToString()}")
request?.grant(request.resources)
}
}
webView.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
val url = request?.url?.toString() ?: ""
if (url.contains(".css") || url.contains(".js") && url.contains("tvmon.site")) {
try {
val conn = java.net.URL(url).openConnection() as java.net.HttpURLConnection
conn.requestMethod = "GET"
conn.connectTimeout = 8000
conn.readTimeout = 8000
conn.setRequestProperty("User-Agent", USER_AGENT)
conn.setRequestProperty("Referer", "https://tvmon.site/")
val contentType = conn.contentType ?: "text/plain"
val encoding = conn.contentEncoding ?: "UTF-8"
val inputStream = conn.inputStream
val content = inputStream.bufferedReader().use { it.readText() }
inputStream.close()
var modifiedContent = content
if (contentType.contains("text/html", true)) {
modifiedContent = injectFullscreenStyles(content)
}
return WebResourceResponse(
contentType, encoding,
200, "OK",
mapOf("Access-Control-Allow-Origin" to "*"),
ByteArrayInputStream(modifiedContent.toByteArray(charset(encoding)))
)
} catch (e: Exception) {
android.util.Log.w("PlaybackActivity", "Intercept failed for $url: ${e.message}")
}
}
return super.shouldInterceptRequest(view, request)
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
android.util.Log.i("PlaybackActivity", "Page finished: $url")
handler.postDelayed({
injectFullscreenScript()
android.util.Log.i("PlaybackActivity", "Fullscreen script injected")
}, 300)
handler.postDelayed({
injectEnhancedAutoPlayScript()
android.util.Log.i("PlaybackActivity", "Enhanced AutoPlay script injected (first)")
}, 800)
handler.postDelayed({
injectEnhancedAutoPlayScript()
android.util.Log.i("PlaybackActivity", "Enhanced AutoPlay script injected (second)")
}, 2000)
handler.postDelayed({
injectEnhancedAutoPlayScript()
android.util.Log.i("PlaybackActivity", "Enhanced AutoPlay script injected (third)")
}, 1500)
handler.postDelayed({
runOnUiThread {
loadingOverlay.visibility = View.GONE
}
}, 1500)
}
}
webView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
}
private fun injectFullscreenStyles(html: String): String {
val styleInjection = """
<style>
body { margin: 0 !important; padding: 0 !important; background: #000 !important; }
#header_wrap, .header_mobile, #side_wrap, .banner_wrap2, .notice, .bo_v_nav,
.bo_v_option, .bo_v_sns, .bo_v_com, #view_comment, .bo_v_btn, .banner-area,
[class*="banner"], [id*="banner"], .bo_v_act, .bo_v_ex,
.navbar, .sidebar, .footer, .comment, .related, .ads, .ad-banner,
.header, #header, .gnb, #gnb, .snb, #snb { display: none !important; visibility: hidden !important; height: 0 !important; overflow: hidden !important; }
#content_wrap, #body_wrap, #container_wrap { padding: 0 !important; margin: 0 !important; }
.embed-container, .bo_v_mov, #playerArea {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 99999 !important;
background: #000 !important;
margin: 0 !important;
padding: 0 !important;
}
iframe#view_iframe, iframe {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
border: none !important;
z-index: 99999 !important;
}
.iframe-loading { display: none !important; }
</style>
""".trimIndent()
return if (html.contains("</head>", true)) {
html.replace("</head>", styleInjection + "</head>", ignoreCase = true)
} else if (html.contains("<body", true)) {
html.replace("<body", styleInjection + "<body", ignoreCase = true)
} else {
styleInjection + html
}
}
private fun injectFullscreenScript() {
webView.evaluateJavascript(
"""
(function() {
if (window._fullscreenInjected) return;
window._fullscreenInjected = true;
var style = document.createElement('style');
style.textContent = `
body { margin: 0 !important; padding: 0 !important; background: #000 !important; overflow: hidden !important; }
* { visibility: hidden !important; }
video, video *,
.bo_v_mov, .bo_v_mov *,
.embed-container, .embed-container *,
#playerArea, #playerArea *,
[class*="player"], [class*="player"] *,
[id*="player"], [id*="player"] *,
.video-wrap, .video-wrap *,
.player-wrap, .player-wrap *,
iframe, iframe * { visibility: visible !important; }
video {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 99999 !important;
background: #000 !important;
object-fit: contain !important;
}
.bo_v_mov, .embed-container, #playerArea,
[class*="player"], [id*="player"], .video-wrap, .player-wrap {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 99999 !important;
background: #000 !important;
margin: 0 !important;
padding: 0 !important;
}
iframe {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
border: none !important;
z-index: 99999 !important;
background: #000 !important;
}
#header_wrap, .header_mobile, #side_wrap, .banner_wrap2, .notice, .bo_v_nav,
.bo_v_option, .bo_v_sns, .bo_v_com, #view_comment, .bo_v_btn, .banner-area,
[class*="banner"], [id*="banner"], .bo_v_act, .bo_v_ex,
.navbar, .sidebar, .footer, .comment, .related, .ads, .ad-banner,
.header, #header, .gnb, #gnb, .snb, #snb, .bo_v_info, .bo_v_title,
.bo_v_cate, .bo_v_date, .bo_v_cnt, .bo_v_con, .bo_v_tit,
.btn_login, .btn_side, .logo, .iframe-loading, .bo_v_top, .bo_v_info,
.share, .relate, .reply, .good, .view-bottom {
display: none !important;
height: 0 !important;
width: 0 !important;
overflow: hidden !important;
}
#content_wrap, #body_wrap, #container_wrap {
padding: 0 !important;
margin: 0 !important;
}
* { box-sizing: border-box !important; }
`;
document.head.appendChild(style);
var makePlayerFullscreen = function() {
document.querySelectorAll('video').forEach(function(v) {
v.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:99999;background:#000;object-fit:contain;';
v.setAttribute('allowfullscreen', 'true');
v.setAttribute('playsinline', 'true');
v.setAttribute('webkit-playsinline', 'true');
});
var selectors = ['.bo_v_mov', '.embed-container', '#playerArea', '[class*="player"]', '[id*="player"]', '.video-wrap', '.player-wrap'];
selectors.forEach(function(sel) {
document.querySelectorAll(sel).forEach(function(el) {
el.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:99999;background:#000;margin:0;padding:0;';
});
});
document.querySelectorAll('iframe').forEach(function(iframe) {
iframe.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;border:none;z-index:99999;background:#000;';
iframe.setAttribute('allowfullscreen', 'true');
iframe.setAttribute('allow', 'autoplay; fullscreen; encrypted-media; picture-in-picture; playsinline');
});
document.body.style.cssText = 'margin:0;padding:0;background:#000;overflow:hidden;';
var hideSelectors = ['#header_wrap', '.header_mobile', '#side_wrap', '.banner_wrap2', '.notice', '.bo_v_nav', '.bo_v_option', '.bo_v_sns', '.bo_v_com', '#view_comment', '.bo_v_btn', '.banner-area', '[class*="banner"]', '[id*="banner"]', '.bo_v_act', '.bo_v_ex', '.navbar', '.sidebar', '.footer', '.comment', '.related', '.ads', '.ad-banner', '.header', '#header', '.gnb', '#gnb', '.snb', '#snb', '.bo_v_info', '.bo_v_title', '.bo_v_cate', '.bo_v_date', '.bo_v_cnt', '.bo_v_con', '.bo_v_tit', '.btn_login', '.btn_side', '.logo', '.iframe-loading'];
hideSelectors.forEach(function(sel) {
document.querySelectorAll(sel).forEach(function(el) {
el.style.display = 'none';
el.style.height = '0';
el.style.overflow = 'hidden';
});
});
};
makePlayerFullscreen();
setTimeout(makePlayerFullscreen, 500);
setTimeout(makePlayerFullscreen, 1500);
setTimeout(makePlayerFullscreen, 3000);
setInterval(makePlayerFullscreen, 5000);
})();
""".trimIndent(),
null
)
}
private fun injectEnhancedAutoPlayScript() {
webView.evaluateJavascript(
"""
(function() {
console.log('AutoPlay: Starting...');
var tryPlay = function(v) {
console.log('AutoPlay: Trying to play video...');
v.muted = true;
v.setAttribute('playsinline', 'true');
v.play().then(function() {
console.log('AutoPlay: Video play() succeeded');
if (window.Android) window.Android.onVideoPlay();
}).catch(function(e) {
console.log('AutoPlay: Video play() failed: ' + e.message);
});
};
var initVideo = function(v) {
v.muted = true;
v.setAttribute('playsinline', 'true');
v.setAttribute('webkit-playsinline', 'true');
v.addEventListener('canplaythrough', function() {
console.log('AutoPlay: canplaythrough event fired');
tryPlay(v);
}, {once: true});
if (v.readyState >= 2) {
tryPlay(v);
}
};
var videos = document.querySelectorAll('video');
console.log('AutoPlay: Found ' + videos.length + ' videos');
videos.forEach(initVideo);
var iframes = document.querySelectorAll('iframe');
iframes.forEach(function(f) {
try {
var innerDoc = f.contentDocument || f.contentWindow.document;
var innerVideos = innerDoc.querySelectorAll('video');
innerVideos.forEach(initVideo);
} catch(e) {
console.log('AutoPlay: Cannot access iframe');
}
});
})();
""".trimIndent(),
null
)
}
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
val keyCode = event?.keyCode ?: return super.dispatchKeyEvent(event)
val action = event.action
if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER)
&& action == KeyEvent.ACTION_DOWN) {
android.util.Log.d("PlaybackActivity", "OK button pressed, triggering enhanced autoplay")
simulateCenterClick()
injectEnhancedAutoPlayScript()
toggleVideoPlayback()
return true
}
if (keyCode == KeyEvent.KEYCODE_BACK && action == KeyEvent.ACTION_DOWN) {
if (webView.canGoBack()) {
webView.goBack()
return true
}
finish()
return true
}
return super.dispatchKeyEvent(event)
}
private fun handleDpadKey(keyCode: Int) {
android.util.Log.d("PlaybackActivity", "handleDpadKey: $keyCode")
when (keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> seekVideo(-10000)
KeyEvent.KEYCODE_DPAD_RIGHT -> seekVideo(10000)
KeyEvent.KEYCODE_DPAD_UP -> adjustVolume(0.1f)
KeyEvent.KEYCODE_DPAD_DOWN -> adjustVolume(-0.1f)
}
}
private fun adjustVolume(delta: Float) {
webView.evaluateJavascript(
"""
(function() {
var video = document.querySelector('video');
if (video) {
video.volume = Math.max(0, Math.min(1, video.volume + $delta));
return video.volume;
}
return -1;
})();
""".trimIndent(),
null
)
}
private fun toggleVideoPlayback() {
webView.evaluateJavascript(
"""
(function() {
var video = document.querySelector('video');
if (video && video.duration > 0) {
if (video.paused) {
video.play();
return 'played';
} else {
video.pause();
return 'paused';
}
}
var iframes = document.querySelectorAll('iframe');
for (var i = 0; i < iframes.length; i++) {
try {
iframes[i].contentWindow.postMessage({ type: 'toggle', action: 'playpause' }, '*');
iframes[i].contentWindow.postMessage('toggle', '*');
} catch(e) {}
}
var focused = document.activeElement;
if (focused && focused !== document.body) {
try { focused.click(); } catch(e) {}
}
return 'sent';
})();
""".trimIndent()
) { result ->
android.util.Log.d("PlaybackActivity", "Toggle result: $result")
}
}
private fun seekVideo(offsetMs: Int) {
val offsetSec = offsetMs / 1000.0
webView.evaluateJavascript(
"""
(function() {
var video = document.querySelector('video');
if (video && video.duration > 0) {
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + $offsetSec));
return 'seeked';
}
var iframes = document.querySelectorAll('iframe');
for (var i = 0; i < iframes.length; i++) {
try {
iframes[i].contentWindow.postMessage({ type: 'seek', offset: $offsetSec }, '*');
} catch(e) {}
}
return 'sent';
})();
""".trimIndent(),
null
)
}
private fun simulateCenterClick() {
runOnUiThread {
val centerX = webView.width / 2f
val centerY = webView.height / 2f
val downTime = SystemClock.uptimeMillis()
val downEvent = MotionEvent.obtain(
downTime, downTime,
MotionEvent.ACTION_DOWN, centerX, centerY, 0
)
val upEvent = MotionEvent.obtain(
downTime, downTime + 100,
MotionEvent.ACTION_UP, centerX, centerY, 0
)
webView.dispatchTouchEvent(downEvent)
webView.dispatchTouchEvent(upEvent)
downEvent.recycle()
upEvent.recycle()
android.util.Log.d("PlaybackActivity", "Center click simulated at ($centerX, $centerY)")
}
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (::webView.isInitialized && webView.canGoBack()) {
webView.goBack()
} else {
@Suppress("DEPRECATION")
super.onBackPressed()
}
}
override fun onPause() {
super.onPause()
if (::webView.isInitialized) {
webView.onPause()
}
}
override fun onResume() {
super.onResume()
if (::webView.isInitialized) {
webView.onResume()
}
}
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
if (::webView.isInitialized) {
webView.destroy()
}
super.onDestroy()
}
}

View File

@@ -0,0 +1,82 @@
package com.example.tvmon.ui.presenter
import android.view.ViewGroup
import androidx.leanback.widget.ImageCardView
import androidx.leanback.widget.Presenter
import com.bumptech.glide.Glide
import com.example.tvmon.R
import com.example.tvmon.data.model.Content
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 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 CategoryCardPresenter : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = ImageCardView(parent.context).apply {
isFocusable = true
isFocusableInTouchMode = true
setBackgroundColor(parent.context.getColor(R.color.category_background))
}
return ViewHolder(cardView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val category = item as Category
val cardView = viewHolder.view as ImageCardView
val res = cardView.context.resources
cardView.titleText = category.name
val width = res.getDimensionPixelSize(R.dimen.category_card_width)
val height = res.getDimensionPixelSize(R.dimen.category_card_height)
cardView.setMainImageDimensions(width, height)
cardView.mainImageView.setImageResource(R.drawable.category_background)
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
val cardView = viewHolder.view as ImageCardView
cardView.mainImage = null
}
}

View File

@@ -0,0 +1,40 @@
package com.example.tvmon.ui.presenter
import android.view.ViewGroup
import androidx.leanback.widget.ImageCardView
import androidx.leanback.widget.Presenter
import com.example.tvmon.R
import com.example.tvmon.data.model.Episode
class EpisodePresenter : 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 onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val episode = item as Episode
val cardView = viewHolder.view as ImageCardView
val res = cardView.context.resources
cardView.titleText = episode.number
cardView.contentText = episode.title
val width = res.getDimensionPixelSize(R.dimen.episode_card_width)
val height = res.getDimensionPixelSize(R.dimen.episode_card_height)
cardView.setMainImageDimensions(width, height)
cardView.mainImageView.setImageResource(R.drawable.episode_placeholder)
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
val cardView = viewHolder.view as ImageCardView
cardView.badgeImage = null
cardView.mainImage = null
}
}

View File

@@ -0,0 +1,80 @@
package com.example.tvmon.ui.search
import android.app.SearchManager
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
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 kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
class SearchActivity : AppCompatActivity() {
private val scraper: TvmonScraper by inject()
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: SearchResultsAdapter
private lateinit var searchView: SearchView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_search)
setupUI()
if (Intent.ACTION_SEARCH == intent.action) {
val query = intent.getStringExtra(SearchManager.QUERY)
if (!query.isNullOrBlank()) {
search(query)
}
}
}
private fun setupUI() {
searchView = findViewById(R.id.search_view)
recyclerView = findViewById(R.id.search_results)
adapter = SearchResultsAdapter { content ->
openDetail(content)
}
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = adapter
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
if (!query.isNullOrBlank()) {
search(query)
}
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
return false
}
})
searchView.requestFocus()
}
private fun search(query: String) {
lifecycleScope.launch {
val result = scraper.search(query)
if (result.success) {
adapter.updateResults(result.results)
}
}
}
private fun openDetail(content: Content) {
val intent = Intent(this, DetailsActivity::class.java).apply {
putExtra(DetailsActivity.EXTRA_CONTENT, content)
}
startActivity(intent)
}
}

View File

@@ -0,0 +1,61 @@
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.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.tvmon.R
import com.example.tvmon.data.model.Content
class SearchResultsAdapter(
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) }
}
}
}

View File

@@ -0,0 +1,37 @@
# Add project specific ProGuard rules here.
-keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
# Jsoup
-keep class org.jsoup.** { *; }
-dontwarn org.jsoup.**
# Glide
-keep public class * implements com.bumptech.glide.module.GlideModule
-keep class * extends com.bumptech.glide.module.AppGlideModule {
<init>(...);
}
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
**[] $VALUES;
public *;
}
-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder {
*** rewind(android.os.ParcelFileDescriptor);
}
# Kotlin Coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
# Keep Parcelables
-keepclassmembers class * implements android.os.Parcelable {
static ** CREATOR;
}
# Keep Models
-keep class com.example.tvmon.data.model.** { *; }

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FF6B6B" />
<corners android:radius="8dp" />
</shape>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<!-- Background -->
<path
android:fillColor="#1F1F1F"
android:pathData="M0,0h320v180h-320z"/>
<!-- Gradient Overlay -->
<path
android:fillColor="#80000000"
android:pathData="M0,0h320v180h-320z"/>
<!-- TV Icon -->
<group
android:translateX="120"
android:translateY="40">
<!-- TV Screen -->
<path
android:fillColor="#FF6B6B"
android:pathData="M0,0h80v60h-80z"/>
<!-- TV Stand -->
<path
android:fillColor="#FF6B6B"
android:pathData="M20,65h40v5h-40z"/>
<path
android:fillColor="#FF6B6B"
android:pathData="M10,70h60v3h-60z"/>
<!-- Play Icon -->
<path
android:fillColor="#FFFFFF"
android:pathData="M30,18l25,17l-25,17z"/>
</group>
<!-- App Name -->
<path
android:fillColor="#FFFFFF"
android:pathData="M120,130h80v4h-80z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M140,136h40v3h-40z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FF6B6B" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#3A3A3A" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#282828" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#4A4A4A" />
</shape>

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Background Circle -->
<path
android:fillColor="#FF6B6B"
android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"/>
<!-- TV Screen -->
<path
android:fillColor="#FFFFFF"
android:pathData="M34,38h40v28h-40z"/>
<!-- TV Stand -->
<path
android:fillColor="#FFFFFF"
android:pathData="M44,70h20v4h-20z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M40,74h28v2h-28z"/>
<!-- Play Button -->
<path
android:fillColor="#FF6B6B"
android:pathData="M50,44l12,8l-12,8z"/>
<!-- tvmon Text -->
<!-- Letter 't' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M38,82h4v-2h-4v-2h6v10h-2v-6h-4z"/>
<!-- Letter 'v' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M45,78h2l2,6l2,-6h2l-3,8h-2z"/>
<!-- Letter 'm' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,86v-8h2v1c0.5,-0.7 1.2,-1 2,-1c0.9,0 1.6,0.4 2,1.1c0.5,-0.7 1.3,-1.1 2.2,-1.1c1.6,0 2.8,1.2 2.8,2.8v5.2h-2v-5c0,-0.9 -0.7,-1.5 -1.5,-1.5c-0.9,0 -1.5,0.6 -1.5,1.5v5h-2v-5c0,-0.9 -0.7,-1.5 -1.5,-1.5c-0.9,0 -1.5,0.6 -1.5,1.5v5z"/>
<!-- Letter 'o' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M66,82c0,-2.2 1.8,-4 4,-4s4,1.8 4,4s-1.8,4 -4,4s-4,-1.8 -4,-4zm2,0c0,1.1 0.9,2 2,2s2,-0.9 2,-2s-0.9,-2 -2,-2s-2,0.9 -2,2z"/>
<!-- Letter 'n' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M76,86v-8h2v1c0.6,-0.7 1.4,-1 2.3,-1c1.9,0 3.2,1.3 3.2,3.2v4.8h-2v-4.5c0,-1 -0.7,-1.7 -1.7,-1.7c-1,0 -1.8,0.7 -1.8,1.7v4.5z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/details_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
android:id="@+id/main_browse_fragment"
android:name="com.example.tvmon.ui.main.MainFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="true"
android:focusableInTouchMode="true" />
<FrameLayout
android:id="@+id/loading_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000"
android:visibility="visible">
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_gravity="center"
android:indeterminate="true"
android:indeterminateTint="#FFFFFF" />
</FrameLayout>
</FrameLayout>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<androidx.appcompat.widget.SearchView
android:id="@+id/search_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:iconifiedByDefault="false"
android:queryHint="@string/search_hint" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/search_results"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="16dp" />
</LinearLayout>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp">
<ImageView
android:id="@+id/thumbnail"
android:layout_width="120dp"
android:layout_height="80dp"
android:scaleType="centerCrop"
android:src="@drawable/default_background" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:textSize="16sp" />
<TextView
android:id="@+id/category"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@android:color/darker_gray"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#1F1F1F</color>
<color name="primary_dark">#000000</color>
<color name="accent">#FF6B6B</color>
<color name="default_background">#282828</color>
<color name="category_background">#3A3A3A</color>
<color name="detail_background">#1F1F1F</color>
<color name="search_opaque">#AAFFFFFF</color>
</resources>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="card_width">180dp</dimen>
<dimen name="card_height">240dp</dimen>
<dimen name="category_card_width">240dp</dimen>
<dimen name="category_card_height">160dp</dimen>
<dimen name="episode_card_width">200dp</dimen>
<dimen name="episode_card_height">120dp</dimen>
<dimen name="cast_card_width">120dp</dimen>
<dimen name="cast_card_height">160dp</dimen>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1F1F1F</color>
</resources>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Tvmon</string>
<string name="browse_title">Tvmon - TV 다시보기</string>
<string name="search_title">검색</string>
<string name="search_hint">영화, 드라마, 예능 검색…</string>
<string name="play">재생</string>
<string name="bookmark">북마크</string>
<string name="episodes">에피소드</string>
<string name="continue_watching">이어보기</string>
</resources>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Tvmon" parent="Theme.Leanback">
<item name="android:colorPrimary">@color/primary</item>
<item name="android:colorPrimaryDark">@color/primary_dark</item>
<item name="android:colorAccent">@color/accent</item>
<item name="android:windowBackground">@color/default_background</item>
</style>
<style name="Theme.Tvmon.Playback" parent="Theme.Leanback">
<item name="android:windowNoTitle">true</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
<style name="Theme.Tvmon.Search" parent="Theme.AppCompat.NoActionBar">
<item name="android:colorPrimary">@color/primary</item>
<item name="android:colorPrimaryDark">@color/primary_dark</item>
<item name="android:colorAccent">@color/accent</item>
<item name="android:windowBackground">@color/default_background</item>
</style>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android="http://schemas.android.com/apk/res/android"
android:label="@string/search_title"
android:hint="@string/search_hint"
android:voiceSearchMode="showVoiceSearchButton|launchRecognizer" />

21
tvmon-app/build.gradle Normal file
View File

@@ -0,0 +1,21 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.2.2'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22'
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

14
tvmon-app/gradlew vendored Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
APP_BASE_NAME=$(basename "$0")
APP_HOME=$(dirname "$(readlink -f "$0")")
if [ -n "$JAVA_HOME" ]; then
JAVACMD="$JAVA_HOME/bin/java"
else
JAVACMD="java"
fi
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
exec "$JAVACMD" -Xmx2048m -Dfile.encoding=UTF-8 -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

View File

@@ -0,0 +1,2 @@
rootProject.name = "Tvmon"
include(":app")

557
tvmon_scraper.py Normal file
View File

@@ -0,0 +1,557 @@
"""
티비몬 (tvmon.site) 스크래퍼
카테고리: 영화, 한국영화, 드라마, 예능, 시사/다큐, 해외드라마, 해외(예능/다큐), [극장판] 애니메이션, 일반 애니메이션
WebView 기반 재생을 위한 스크래퍼 - 복잡한 MP4 파싱 없이 웹페이지 URL만 제공
"""
import requests
from bs4 import BeautifulSoup
import json
import re
import time
from urllib.parse import urljoin, quote
from typing import Dict, List, Optional, Tuple
BASE_URL = "https://tvmon.site"
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"
HEADERS = {
"User-Agent": USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
"Referer": BASE_URL,
}
# 카테고리 매핑
CATEGORIES = {
"movie": {"name": "영화", "path": "/movie"},
"kor_movie": {"name": "한국영화", "path": "/kor_movie"},
"drama": {"name": "드라마", "path": "/drama"},
"ent": {"name": "예능프로그램", "path": "/ent"},
"sisa": {"name": "시사/다큐", "path": "/sisa"},
"world": {"name": "해외드라마", "path": "/world"},
"ott_ent": {"name": "해외(예능/다큐)", "path": "/ott_ent"},
"ani_movie": {"name": "[극장판] 애니메이션", "path": "/ani_movie"},
"animation": {"name": "일반 애니메이션", "path": "/animation"},
}
class TvmonScraper:
def __init__(self):
self.session = requests.Session()
self.session.headers.update(HEADERS)
def _get(self, url: str, timeout: int = 15) -> Optional[requests.Response]:
"""GET 요청 with 재시도"""
max_retries = 3
for attempt in range(max_retries):
try:
resp = self.session.get(url, timeout=timeout)
resp.raise_for_status()
return resp
except requests.RequestException as e:
if attempt < max_retries - 1:
time.sleep(1)
else:
print(f"❌ 요청 실패: {url} - {e}")
return None
return None
def get_homepage(self) -> Dict:
"""홈페이지 크롤링 - 인기 순위 및 최신 콘텐츠"""
print("=" * 60)
print("1. 홈페이지 크롤링")
print("=" * 60)
resp = self._get(f"{BASE_URL}/")
if not resp:
return {"success": False}
soup = BeautifulSoup(resp.text, "html.parser")
result = {
"success": True,
"popular": [],
"latest": {}
}
# 인기 순위 (무료 다시보기 순위)
popular_section = soup.select_one("h2:contains('무료 다시보기 순위')")
if popular_section:
popular_items = soup.select(".popular-section a, .ranking-item a")
# 실제 구조에 맞게 조정 필요
for item in soup.select("a[href*='/drama/'], a[href*='/movie/'], a[href*='/kor_movie/'], a[href*='/world/'], a[href*='/animation/']")[:10]:
href = item.get("href", "")
title = item.get_text(strip=True)
if title and href:
result["popular"].append({
"title": title,
"url": urljoin(BASE_URL, href),
"category": self._get_category_from_url(href)
})
# 최신 콘텐츠 섹션별
sections = ["영화", "드라마", "예능", "해외드라마", "애니메이션"]
for section in sections:
result["latest"][section] = []
# 최신 영화
movie_items = soup.select("a[href*='/movie/']")
for item in movie_items[:6]:
href = item.get("href", "")
title = item.get_text(strip=True)
img = item.select_one("img")
img_url = img.get("src") or img.get("data-src", "") if img else ""
if title and href and "/movie/" in href:
result["latest"]["영화"].append({
"title": title,
"url": urljoin(BASE_URL, href),
"thumbnail": img_url
})
# 최신 드라마
drama_items = soup.select("a[href*='/drama/']")
for item in drama_items[:6]:
href = item.get("href", "")
title = item.get_text(strip=True)
img = item.select_one("img")
img_url = img.get("src") or img.get("data-src", "") if img else ""
if title and href and "/drama/" in href:
result["latest"]["드라마"].append({
"title": title,
"url": urljoin(BASE_URL, href),
"thumbnail": img_url
})
print(f" 인기 순위: {len(result['popular'])}")
for section, items in result["latest"].items():
print(f" 최신 {section}: {len(items)}")
return result
def get_category(self, category_key: str, page: int = 1) -> Dict:
if category_key not in CATEGORIES:
print(f"❌ 알 수 없는 카테고리: {category_key}")
return {"success": False, "items": []}
cat_info = CATEGORIES[category_key]
print(f"\n{'=' * 60}")
print(f"2. 카테고리 크롤링: {cat_info['name']} (page={page})")
print("=" * 60)
url = f"{BASE_URL}{cat_info['path']}" if page == 1 else f"{BASE_URL}{cat_info['path']}?page={page}"
resp = self._get(url)
if not resp:
return {"success": False, "items": [], "category": cat_info['name']}
soup = BeautifulSoup(resp.text, "html.parser")
items = []
seen = set()
for link in soup.select(f"a[href*='/{category_key}/']"):
href = link.get("href", "")
if not href or f"/{category_key}/" not in href:
continue
full_url = urljoin(BASE_URL, href)
if full_url in seen:
continue
seen.add(full_url)
id_match = re.search(r'/(\d+)(?:/|$|\?)', href)
content_id = id_match.group(1) if id_match else ""
img_tag = link.select_one("img")
img_url = ""
if img_tag:
img_url = img_tag.get("src") or img_tag.get("data-src") or img_tag.get("data-original", "")
title = link.get_text(strip=True)
if not title:
title_tag = link.select_one(".title, .movie-title, .content-title")
title = title_tag.get_text(strip=True) if title_tag else ""
if title:
items.append({
"id": content_id,
"title": title,
"url": full_url,
"thumbnail": img_url,
"category": category_key
})
pagination = {"current": page, "max_page": 1}
for page_link in soup.select("a[href*='/page/'], a[href*='page=']"):
href = page_link.get("href", "")
page_match = re.search(r'[/&]?page[=/](\d+)', href)
if page_match:
page_num = int(page_match.group(1))
if page_num > pagination["max_page"]:
pagination["max_page"] = page_num
print(f" 항목 수: {len(items)}")
print(f" 페이지 정보: 현재 {page} / 최대 {pagination['max_page']}")
for item in items[:5]:
print(f" - [{item['id']}] {item['title'][:50]}")
return {
"success": True,
"category": cat_info['name'],
"items": items,
"page": page,
"pagination": pagination
}
def get_detail(self, url_or_id: str, category: str = None) -> Dict:
print(f"\n{'=' * 60}")
print(f"3. 상세 페이지 크롤링")
print("=" * 60)
if url_or_id.startswith("http"):
url = url_or_id
else:
if category:
url = f"{BASE_URL}/{category}/{url_or_id}"
else:
url = f"{BASE_URL}/movie/{url_or_id}"
resp = self._get(url)
if not resp:
return {"success": False}
soup = BeautifulSoup(resp.text, "html.parser")
result = {
"success": True,
"url": url,
"title": "",
"thumbnail": "",
"info": {},
"episodes": [],
"video_links": [],
"play_url": ""
}
title_tag = soup.select_one("h1, h2.title, .content-title, title")
if title_tag:
title_text = title_tag.get_text(strip=True)
if " - " in title_text:
result["title"] = title_text.split(" - ")[0].strip()
else:
result["title"] = title_text
og_image = soup.select_one('meta[property="og:image"]')
if og_image:
result["thumbnail"] = og_image.get("content", "")
seen_episode_ids = set()
episode_links = soup.select(f"a[href*='/{category or 'drama'}/']")
for link in episode_links:
href = link.get("href", "")
if not href:
continue
full_url = urljoin(BASE_URL, href)
if full_url == url:
continue
episode_id_match = re.search(r'/(\d+)/(\d+)', href)
if not episode_id_match:
continue
episode_id = episode_id_match.group(2)
if episode_id in seen_episode_ids:
continue
seen_episode_ids.add(episode_id)
link_text = link.get_text(strip=True)
episode_num = re.search(r'(\d+화|\d+회|EP?\d+|제?\d+부?|\d+)', link_text, re.IGNORECASE)
if episode_num:
episode_title = f"{episode_num.group(1)}"
else:
episode_title = f"Episode {len(result['episodes']) + 1}"
result["episodes"].append({
"number": episode_title,
"title": link_text or episode_title,
"url": full_url,
"type": "webview"
})
result["video_links"].append({
"type": "play_page",
"url": full_url,
"title": link_text or episode_title
})
if result["episodes"]:
result["play_url"] = result["episodes"][0]["url"]
print(f" 제목: {result['title']}")
print(f" 썸네일: {result['thumbnail'][:60]}...")
print(f" 에피소드 수: {len(result['episodes'])}")
print(f" 비디오 링크 수: {len(result['video_links'])}")
for ep in result["episodes"][:5]:
print(f" - [{ep['number']}] {ep['title'][:40]}")
for vl in result["video_links"][:3]:
print(f" - [{vl['type']}] {vl['url'][:80]}...")
return result
def search(self, keyword: str, page: int = 1) -> Dict:
print(f"\n{'=' * 60}")
print(f"4. 검색: '{keyword}' (page={page})")
print("=" * 60)
encoded = quote(keyword)
url = f"{BASE_URL}/search?stx={encoded}" if page == 1 else f"{BASE_URL}/search?stx={encoded}&page={page}"
resp = self._get(url)
if not resp:
return {"success": False, "keyword": keyword, "results": []}
soup = BeautifulSoup(resp.text, "html.parser")
results = []
seen = set()
nav_patterns = ['/login', '/logout', '/register', '/mypage', '/bbs/', '/menu', '/faq', '/privacy']
all_links = soup.select("a[href*='/movie/'], a[href*='/drama/'], a[href*='/ent/'], a[href*='/world/'], a[href*='/animation/'], a[href*='/kor_movie/']")
for link in all_links:
href = link.get("href", "")
if not href:
continue
full_url = urljoin(BASE_URL, href)
if full_url in seen:
continue
if any(nav in full_url for nav in nav_patterns):
continue
seen.add(full_url)
id_match = re.search(r'/(\d+)(?:/|$|\?)', href)
content_id = id_match.group(1) if id_match else ""
category = self._get_category_from_url(href)
img_tag = link.select_one("img")
img_url = ""
if img_tag:
img_url = img_tag.get("src") or img_tag.get("data-src", "")
title = link.get_text(strip=True)
if not title:
title_tag = link.select_one(".title, .movie-title")
title = title_tag.get_text(strip=True) if title_tag else ""
if title:
results.append({
"id": content_id,
"title": title,
"url": full_url,
"thumbnail": img_url,
"category": category
})
print(f" 검색 결과: {len(results)}")
for r in results[:10]:
print(f" - [{r['category']}] {r['title'][:50]}")
return {
"success": True,
"keyword": keyword,
"results": results,
"page": page
}
def get_popular(self) -> Dict:
print(f"\n{'=' * 60}")
print(f"5. 인기 영상 크롤링")
print("=" * 60)
url = f"{BASE_URL}/popular"
resp = self._get(url)
if not resp:
return {"success": False, "items": []}
soup = BeautifulSoup(resp.text, "html.parser")
items = []
seen = set()
nav_patterns = ['/login', '/logout', '/register', '/mypage', '/bbs/', '/menu', '/faq', '/privacy']
all_links = soup.select("a[href*='/movie/'], a[href*='/drama/'], a[href*='/ent/'], a[href*='/world/'], a[href*='/animation/'], a[href*='/kor_movie/']")
for link in all_links:
href = link.get("href", "")
if not href:
continue
full_url = urljoin(BASE_URL, href)
if full_url in seen:
continue
if any(nav in full_url for nav in nav_patterns):
continue
seen.add(full_url)
id_match = re.search(r'/(\d+)(?:/|$|\?)', href)
content_id = id_match.group(1) if id_match else ""
category = self._get_category_from_url(href)
img_tag = link.select_one("img")
img_url = ""
if img_tag:
img_url = img_tag.get("src") or img_tag.get("data-src", "")
title = link.get_text(strip=True)
if not title:
title_tag = link.select_one(".title, .movie-title")
title = title_tag.get_text(strip=True) if title_tag else ""
if title:
items.append({
"id": content_id,
"title": title,
"url": full_url,
"thumbnail": img_url,
"category": category
})
print(f" 인기 항목 수: {len(items)}")
for item in items[:10]:
print(f" - [{item['category']}] {item['title'][:50]}")
return {
"success": True,
"items": items
}
def _get_category_from_url(self, url: str) -> str:
"""URL에서 카테고리 추출"""
for cat_key in CATEGORIES:
if f"/{cat_key}/" in url:
return cat_key
return "unknown"
def _extract_id_from_url(self, url: str) -> str:
"""URL에서 콘텐츠 ID 추출"""
# 패턴: /category/12345 또는 /category/12345/67890
match = re.search(r'/(\d+)(?:/|$|\?)', url)
if match:
return match.group(1)
return ""
def _extract_episode_number(self, text: str) -> str:
"""텍스트에서 회차 번호 추출"""
match = re.search(r'(\d+화|\d+회|제?\d+부?|EP?\d+)', text, re.IGNORECASE)
if match:
return match.group(1)
return text
def _get_pagination(self, soup: BeautifulSoup) -> Dict:
"""페이지네이션 정보 추출"""
pagination = {"current": 1, "max_page": 1}
page_links = soup.select("a[href*='/page/']")
for link in page_links:
href = link.get("href", "")
match = re.search(r'/page/(\d+)', href)
if match:
page_num = int(match.group(1))
if page_num > pagination["max_page"]:
pagination["max_page"] = page_num
return pagination
def test_all_categories():
"""모든 카테고리 테스트"""
scraper = TvmonScraper()
print("\n🔍 tvmon.site 카테고리별 크롤링 테스트\n")
results = {}
# 홈페이지
try:
results["homepage"] = scraper.get_homepage()
except Exception as e:
print(f" ❌ 홈페이지 실패: {e}")
results["homepage"] = {"success": False}
# 각 카테고리 테스트
for cat_key in ["movie", "kor_movie", "drama", "ent", "sisa", "world", "ott_ent", "ani_movie", "animation"]:
try:
results[cat_key] = scraper.get_category(cat_key, page=1)
except Exception as e:
print(f"{cat_key} 실패: {e}")
results[cat_key] = {"success": False}
# 검색 테스트
try:
results["search"] = scraper.search("사냥개들")
except Exception as e:
print(f" ❌ 검색 실패: {e}")
results["search"] = {"success": False}
# 인기 영상
try:
results["popular"] = scraper.get_popular()
except Exception as e:
print(f" ❌ 인기 영상 실패: {e}")
results["popular"] = {"success": False}
# 결과 요약
print(f"\n{'=' * 60}")
print("📊 테스트 결과 요약")
print("=" * 60)
for name, result in results.items():
status = "✅ PASS" if result.get("success") else "❌ FAIL"
print(f" {status} - {name}")
return results
def test_detail_page():
"""상세 페이지 테스트"""
scraper = TvmonScraper()
print("\n🔍 상세 페이지 크롤링 테스트\n")
# 테스트할 URL들 (사용자가 제공한 URL 기반)
test_urls = [
"https://tvmon.site/drama/3781", # 사냥개들 시즌 2
"https://tvmon.site/kor_movie/30314", # 휴민트
"https://tvmon.site/world/19479", # 월린기기
]
for url in test_urls:
try:
result = scraper.get_detail(url)
print(f"\n{'=' * 40}")
except Exception as e:
print(f" ❌ 상세 페이지 실패 ({url}): {e}")
if __name__ == "__main__":
# 전체 테스트 실행
test_all_categories()
print("\n" + "=" * 60)
# 상세 페이지 테스트
test_detail_page()