commit 387517fd47793398ac40a095cc73eb21a8622c99 Author: tvmon-dev Date: Wed Apr 15 15:00:19 2026 +0900 Initial commit: tvmon v1.0.0 - Android TV app with pagination fix and cast display diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a33fa4 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/tvmon-app/app/build.gradle b/tvmon-app/app/build.gradle new file mode 100644 index 0000000..c47f99a --- /dev/null +++ b/tvmon-app/app/build.gradle @@ -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' +} diff --git a/tvmon-app/app/proguard-rules.pro b/tvmon-app/app/proguard-rules.pro new file mode 100644 index 0000000..87bb22b --- /dev/null +++ b/tvmon-app/app/proguard-rules.pro @@ -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 { + (...); +} +-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.** { *; } diff --git a/tvmon-app/app/src/main/AndroidManifest.xml b/tvmon-app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..eca769d --- /dev/null +++ b/tvmon-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/TvmonApplication.kt b/tvmon-app/app/src/main/java/com/example/tvmon/TvmonApplication.kt new file mode 100644 index 0000000..4632c1d --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/TvmonApplication.kt @@ -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) + } + } +} diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/TvmonGlideModule.kt b/tvmon-app/app/src/main/java/com/example/tvmon/TvmonGlideModule.kt new file mode 100644 index 0000000..a7eca1b --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/TvmonGlideModule.kt @@ -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)) + } +} \ No newline at end of file diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/local/AppDatabase.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/local/AppDatabase.kt new file mode 100644 index 0000000..c61cf45 --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/local/AppDatabase.kt @@ -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 +} diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/BookmarkDao.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/BookmarkDao.kt new file mode 100644 index 0000000..51ce185 --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/BookmarkDao.kt @@ -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> + + @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) + } + } +} diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/SearchHistoryDao.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/SearchHistoryDao.kt new file mode 100644 index 0000000..f8ebd2c --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/SearchHistoryDao.kt @@ -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> + + @Query("SELECT keyword FROM search_history ORDER BY timestamp DESC LIMIT :limit") + fun getRecentKeywordsFlow(limit: Int = 10): Flow> + + @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())) + } +} diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/WatchHistoryDao.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/WatchHistoryDao.kt new file mode 100644 index 0000000..e3a5f70 --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/WatchHistoryDao.kt @@ -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> + + @Query("SELECT * FROM watch_history ORDER BY timestamp DESC LIMIT :limit") + fun getRecentFlow(limit: Int = 10): Flow> + + @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): List + + @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) + } + } +} diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/local/entity/Entities.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/local/entity/Entities.kt new file mode 100644 index 0000000..1232ddf --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/local/entity/Entities.kt @@ -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() +) diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/model/Models.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/model/Models.kt new file mode 100644 index 0000000..20e64ef --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/model/Models.kt @@ -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 = emptyList(), + val episodes: List, + val videoLinks: List, + 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, + 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, + val page: Int +) diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/repository/BookmarkRepository.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/repository/BookmarkRepository.kt new file mode 100644 index 0000000..d178b9d --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/repository/BookmarkRepository.kt @@ -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> { + 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() + } +} diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/repository/WatchHistoryRepository.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/repository/WatchHistoryRepository.kt new file mode 100644 index 0000000..f6b2b82 --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/repository/WatchHistoryRepository.kt @@ -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> { + return dao.getRecentFlow(limit) + } + + fun getAllHistory(): Flow> { + 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() + } +} diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt new file mode 100644 index 0000000..d02b39b --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt @@ -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 = withContext(Dispatchers.IO) { + val html = get("$BASE_URL/") ?: return@withContext mapOf("success" to false) + + val doc = Jsoup.parse(html) + val result = mutableMapOf( + "success" to true, + "popular" to mutableListOf>(), + "latest" to mutableMapOf>>() + ) + + val popularLinks = doc.select("a[href*='/drama/'], a[href*='/movie/'], a[href*='/kor_movie/'], a[href*='/world/'], a[href*='/animation/']") + val popularList = mutableListOf>() + 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>>() + listOf("영화", "드라마", "예능", "해외드라마", "애니메이션").forEach { section -> + latest[section] = emptyList() + } + + val movieItems = doc.select("a[href*='/movie/']") + val movieList = mutableListOf>() + 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>() + 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() + val seen = mutableSetOf() + + 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() + + 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() + val videoLinks = mutableListOf() + val seenEpisodeIds = mutableSetOf() + + 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() + val seen = mutableSetOf() + + 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 = withContext(Dispatchers.IO) { + val html = get("$BASE_URL/popular") ?: return@withContext emptyList() + + val doc = Jsoup.parse(html) + val items = mutableListOf() + val seen = mutableSetOf() + + 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" + } +} diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/di/AppModule.kt b/tvmon-app/app/src/main/java/com/example/tvmon/di/AppModule.kt new file mode 100644 index 0000000..f73e6f8 --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/di/AppModule.kt @@ -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().watchHistoryDao() } + single { get().bookmarkDao() } + single { get().searchHistoryDao() } +} + +val repositoryModule = module { + single { WatchHistoryRepository(get()) } + single { BookmarkRepository(get()) } +} + +val scraperModule = module { + single { TvmonScraper() } +} + +val appModules = listOf( + databaseModule, + repositoryModule, + scraperModule +) diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/detail/DetailsActivity.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/detail/DetailsActivity.kt new file mode 100644 index 0000000..ef24379 --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/detail/DetailsActivity.kt @@ -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() + } + } +} diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/detail/DetailsFragment.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/detail/DetailsFragment.kt new file mode 100644 index 0000000..e45e799 --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/detail/DetailsFragment.kt @@ -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() { + override fun onResourceReady( + resource: android.graphics.drawable.Drawable, + transition: Transition? + ) { + 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() { + override fun onResourceReady( + resource: android.graphics.drawable.Drawable, + transition: Transition? + ) { + detailsOverview.imageDrawable = resource + } + override fun onLoadCleared(placeholder: android.graphics.drawable.Drawable?) {} + }) + } + + rowsAdapter.notifyArrayItemRangeChanged(0, 1) + } + } + + private fun addEpisodesRow(episodes: List) { + 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) { + 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) { + 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) { + 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 + } + } +} diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainActivity.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainActivity.kt new file mode 100644 index 0000000..4fc3fb1 --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainActivity.kt @@ -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) + } +} diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt new file mode 100644 index 0000000..9551900 --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt @@ -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() + private val categoryLoading = mutableMapOf() + private val categoryMaxPage = mutableMapOf() + private val categoryItems = mutableMapOf>() + private val categoryRowAdapters = mutableMapOf() + 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) + } + } + } +} diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt new file mode 100644 index 0000000..26d0883 --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt @@ -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(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 = """ + + """.trimIndent() + + return if (html.contains("", true)) { + html.replace("", styleInjection + "", ignoreCase = true) + } else if (html.contains("