Initial commit: tvmon v1.0.0 - Android TV app with pagination fix and cast display
This commit is contained in:
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" />
|
||||
Reference in New Issue
Block a user