Initial commit: tvmon v1.0.0 - Android TV app with pagination fix and cast display
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
|
||||||
81
tvmon-app/app/build.gradle
Normal file
81
tvmon-app/app/build.gradle
Normal 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
37
tvmon-app/app/proguard-rules.pro
vendored
Normal 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.** { *; }
|
||||||
72
tvmon-app/app/src/main/AndroidManifest.xml
Normal file
72
tvmon-app/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
tvmon-app/app/src/main/proguard-rules.pro
vendored
Normal file
37
tvmon-app/app/src/main/proguard-rules.pro
vendored
Normal 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.** { *; }
|
||||||
6
tvmon-app/app/src/main/res/drawable/app_banner.xml
Normal file
6
tvmon-app/app/src/main/res/drawable/app_banner.xml
Normal 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>
|
||||||
49
tvmon-app/app/src/main/res/drawable/app_banner_vector.xml
Normal file
49
tvmon-app/app/src/main/res/drawable/app_banner_vector.xml
Normal 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>
|
||||||
5
tvmon-app/app/src/main/res/drawable/app_logo.xml
Normal file
5
tvmon-app/app/src/main/res/drawable/app_logo.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
5
tvmon-app/app/src/main/res/layout/activity_details.xml
Normal file
5
tvmon-app/app/src/main/res/layout/activity_details.xml
Normal 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" />
|
||||||
11
tvmon-app/app/src/main/res/layout/activity_main.xml
Normal file
11
tvmon-app/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||||
31
tvmon-app/app/src/main/res/layout/activity_playback.xml
Normal file
31
tvmon-app/app/src/main/res/layout/activity_playback.xml
Normal 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>
|
||||||
20
tvmon-app/app/src/main/res/layout/activity_search.xml
Normal file
20
tvmon-app/app/src/main/res/layout/activity_search.xml
Normal 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>
|
||||||
38
tvmon-app/app/src/main/res/layout/item_search_result.xml
Normal file
38
tvmon-app/app/src/main/res/layout/item_search_result.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
10
tvmon-app/app/src/main/res/values/colors.xml
Normal file
10
tvmon-app/app/src/main/res/values/colors.xml
Normal 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>
|
||||||
14
tvmon-app/app/src/main/res/values/dimens.xml
Normal file
14
tvmon-app/app/src/main/res/values/dimens.xml
Normal 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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#1F1F1F</color>
|
||||||
|
</resources>
|
||||||
11
tvmon-app/app/src/main/res/values/strings.xml
Normal file
11
tvmon-app/app/src/main/res/values/strings.xml
Normal 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>
|
||||||
22
tvmon-app/app/src/main/res/values/themes.xml
Normal file
22
tvmon-app/app/src/main/res/values/themes.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
5
tvmon-app/app/src/main/res/xml/searchable.xml
Normal file
5
tvmon-app/app/src/main/res/xml/searchable.xml
Normal 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
21
tvmon-app/build.gradle
Normal 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
|
||||||
|
}
|
||||||
4
tvmon-app/gradle.properties
Normal file
4
tvmon-app/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
BIN
tvmon-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
tvmon-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
tvmon-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
tvmon-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
14
tvmon-app/gradlew
vendored
Executable 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 "$@"
|
||||||
2
tvmon-app/settings.gradle
Normal file
2
tvmon-app/settings.gradle
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
rootProject.name = "Tvmon"
|
||||||
|
include(":app")
|
||||||
557
tvmon_scraper.py
Normal file
557
tvmon_scraper.py
Normal 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()
|
||||||
Reference in New Issue
Block a user