v1.0.1 업데이트
- 정렬 순서 wr_datetime DESC로 변경 (최신순 정렬) - 런처 아이콘 개선 (Netflix 스타일: 빨간 배경 + 흰 아이콘) - 웹뷰 동영상 재생 최적화 (_LAYER_TYPE_NONE, setInterval 제거) - 설정 버튼 카테고리 하단 배치 - 메모리 누수 수정 (WebView cleanup)
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.example.tvmon"
|
||||
minSdk 28
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
versionCode 2
|
||||
versionName "1.0.1"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -55,19 +55,35 @@
|
||||
android:theme="@style/Theme.Tvmon.Search" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.search.SearchActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/search_title"
|
||||
android:screenOrientation="landscape"
|
||||
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>
|
||||
android:name=".ui.search.SearchActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/search_title"
|
||||
android:screenOrientation="landscape"
|
||||
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>
|
||||
<activity
|
||||
android:name=".ui.settings.SettingsActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="landscape"
|
||||
android:theme="@style/Theme.Tvmon" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -5,11 +5,11 @@ import com.example.tvmon.data.local.entity.CategoryContent
|
||||
|
||||
@Dao
|
||||
interface CategoryContentDao {
|
||||
@Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey ORDER BY cachedAt ASC")
|
||||
suspend fun getByCategory(categoryKey: String): List<CategoryContent>
|
||||
@Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey")
|
||||
suspend fun getByCategory(categoryKey: String): List<CategoryContent>
|
||||
|
||||
@Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey AND pageNumber <= :page ORDER BY cachedAt ASC")
|
||||
suspend fun getByCategoryUntilPage(categoryKey: String, page: Int): List<CategoryContent>
|
||||
@Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey AND pageNumber <= :page")
|
||||
suspend fun getByCategoryUntilPage(categoryKey: String, page: Int): List<CategoryContent>
|
||||
|
||||
@Query("SELECT MAX(pageNumber) FROM category_content WHERE categoryKey = :categoryKey")
|
||||
suspend fun getMaxPage(categoryKey: String): Int?
|
||||
|
||||
@@ -142,17 +142,19 @@ private fun extractSeriesUrl(url: String): String {
|
||||
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)
|
||||
)
|
||||
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(
|
||||
// Sort by wr_datetime (latest first) - same as website default sorting
|
||||
val sortParam = "sst=wr_datetime&sod=desc"
|
||||
val url = if (page == 1) "$BASE_URL${catInfo.path}?$sortParam" else "$BASE_URL${catInfo.path}?$sortParam&page=$page"
|
||||
val html = get(url) ?: return@withContext CategoryResult(
|
||||
success = false,
|
||||
category = catInfo.name,
|
||||
items = emptyList(),
|
||||
|
||||
@@ -15,21 +15,24 @@ 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 com.example.tvmon.ui.settings.SettingsActivity
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class MainFragment : BrowseSupportFragment(), OnItemViewClickedListener, OnItemViewSelectedListener {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TVMON_MAIN"
|
||||
}
|
||||
companion object {
|
||||
private const val TAG = "TVMON_MAIN"
|
||||
}
|
||||
|
||||
private val categoryCacheRepository: CategoryCacheRepository by inject()
|
||||
private val watchHistoryRepository: WatchHistoryRepository by inject()
|
||||
private val rowsAdapter = ArrayObjectAdapter(ListRowPresenter())
|
||||
private val categoryRowAdapters = mutableMapOf<String, ArrayObjectAdapter>()
|
||||
private val loadingStates = mutableMapOf<String, Boolean>()
|
||||
private var isDataLoaded = false
|
||||
data class SettingsItem(val title: String = "설정")
|
||||
|
||||
private val categoryCacheRepository: CategoryCacheRepository by inject()
|
||||
private val watchHistoryRepository: WatchHistoryRepository by inject()
|
||||
private val rowsAdapter = ArrayObjectAdapter(ListRowPresenter())
|
||||
private val categoryRowAdapters = mutableMapOf<String, ArrayObjectAdapter>()
|
||||
private val loadingStates = mutableMapOf<String, Boolean>()
|
||||
private var isDataLoaded = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -99,13 +102,26 @@ private fun setupUI() {
|
||||
if (success) successCount++ else failCount++
|
||||
}
|
||||
|
||||
Log.w(TAG, "=== loadCategories COMPLETE: success=$successCount, fail=$failCount ===")
|
||||
isDataLoaded = true
|
||||
Log.w(TAG, "=== loadCategories COMPLETE: success=$successCount, fail=$failCount ===")
|
||||
isDataLoaded = true
|
||||
|
||||
if (rowsAdapter.size() == 0) {
|
||||
Toast.makeText(requireContext(), "카테고리를 불러오지 못했습니다", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
if (rowsAdapter.size() == 0) {
|
||||
Toast.makeText(requireContext(), "카테고리를 불러오지 못했습니다", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
activity?.runOnUiThread {
|
||||
addSettingsRow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun addSettingsRow() {
|
||||
val settingsAdapter = ArrayObjectAdapter(ContentCardPresenter())
|
||||
settingsAdapter.add(SettingsItem())
|
||||
val settingsHeader = HeaderItem("설정")
|
||||
val settingsRow = ListRow(settingsHeader, settingsAdapter)
|
||||
rowsAdapter.add(settingsRow)
|
||||
Log.w(TAG, "ADDED SETTINGS ROW")
|
||||
}
|
||||
|
||||
private suspend fun loadCategoryWithCache(category: com.example.tvmon.data.model.Category): Boolean {
|
||||
return try {
|
||||
@@ -139,26 +155,30 @@ private fun setupUI() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClicked(
|
||||
itemViewHolder: Presenter.ViewHolder?,
|
||||
item: Any?,
|
||||
rowViewHolder: RowPresenter.ViewHolder?,
|
||||
row: Row?
|
||||
) {
|
||||
Log.w(TAG, "=== onItemClicked: item=$item ===")
|
||||
when (item) {
|
||||
is Content -> {
|
||||
Log.w(TAG, "Content clicked: ${item.title}, url=${item.url}")
|
||||
val intent = Intent(requireContext(), DetailsActivity::class.java).apply {
|
||||
putExtra(DetailsActivity.EXTRA_CONTENT, item)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown item type: ${item?.javaClass?.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onItemClicked(
|
||||
itemViewHolder: Presenter.ViewHolder?,
|
||||
item: Any?,
|
||||
rowViewHolder: RowPresenter.ViewHolder?,
|
||||
row: Row?
|
||||
) {
|
||||
Log.w(TAG, "=== onItemClicked: item=$item ===")
|
||||
when (item) {
|
||||
is Content -> {
|
||||
Log.w(TAG, "Content clicked: ${item.title}, url=${item.url}")
|
||||
val intent = Intent(requireContext(), DetailsActivity::class.java).apply {
|
||||
putExtra(DetailsActivity.EXTRA_CONTENT, item)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
is SettingsItem -> {
|
||||
Log.w(TAG, "Settings clicked")
|
||||
startActivity(Intent(requireContext(), SettingsActivity::class.java))
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown item type: ${item?.javaClass?.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemSelected(
|
||||
itemViewHolder: Presenter.ViewHolder?,
|
||||
|
||||
@@ -153,152 +153,158 @@ class PlaybackActivity : AppCompatActivity() {
|
||||
webView.loadUrl(url)
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun setupWebView() {
|
||||
val webSettings: WebSettings = webView.settings
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun setupWebView() {
|
||||
val webSettings: WebSettings = webView.settings
|
||||
|
||||
webSettings.javaScriptEnabled = true
|
||||
webSettings.domStorageEnabled = true
|
||||
webSettings.databaseEnabled = 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_DEFAULT
|
||||
webSettings.allowUniversalAccessFromFileURLs = true
|
||||
webSettings.allowFileAccessFromFileURLs = true
|
||||
webSettings.javaScriptEnabled = true
|
||||
webSettings.domStorageEnabled = true
|
||||
webSettings.databaseEnabled = 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_DEFAULT
|
||||
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")
|
||||
// Video playback optimizations
|
||||
webSettings.mediaPlaybackRequiresUserGesture = false
|
||||
webSettings.blockNetworkImage = false
|
||||
|
||||
val cookieManager = CookieManager.getInstance()
|
||||
cookieManager.setAcceptCookie(true)
|
||||
cookieManager.setAcceptThirdPartyCookies(webView, true)
|
||||
webView.addJavascriptInterface(object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun hideLoadingOverlay() {
|
||||
runOnUiThread {
|
||||
loadingOverlay.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
@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")
|
||||
|
||||
if (message.contains("player") || message.contains("video") || message.contains(".m3u8") || message.contains(".mp4")) {
|
||||
android.util.Log.i("WebViewConsole", "Player info: $message")
|
||||
}
|
||||
return true
|
||||
}
|
||||
val cookieManager = CookieManager.getInstance()
|
||||
cookieManager.setAcceptCookie(true)
|
||||
cookieManager.setAcceptThirdPartyCookies(webView, true)
|
||||
|
||||
override fun onPermissionRequest(request: android.webkit.PermissionRequest?) {
|
||||
android.util.Log.d("PlaybackActivity", "Permission request: ${request?.resources?.joinToString()}")
|
||||
request?.grant(request.resources)
|
||||
}
|
||||
}
|
||||
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")
|
||||
|
||||
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)
|
||||
}
|
||||
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 onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
runOnUiThread {
|
||||
loadingOverlay.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
override fun onPermissionRequest(request: android.webkit.PermissionRequest?) {
|
||||
android.util.Log.d("PlaybackActivity", "Permission request: ${request?.resources?.joinToString()}")
|
||||
request?.grant(request.resources)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
android.util.Log.i("PlaybackActivity", "Page finished: $url")
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
|
||||
val url = request?.url?.toString() ?: ""
|
||||
|
||||
handler.postDelayed({
|
||||
injectFullscreenScript()
|
||||
injectEnhancedAutoPlayScript()
|
||||
}, 200)
|
||||
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/")
|
||||
|
||||
handler.postDelayed({
|
||||
runOnUiThread {
|
||||
loadingOverlay.visibility = View.GONE
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
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()
|
||||
|
||||
webView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
}
|
||||
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 onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
runOnUiThread {
|
||||
loadingOverlay.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
android.util.Log.i("PlaybackActivity", "Page finished: $url")
|
||||
|
||||
// Reduced delays and calls to minimize memory pressure
|
||||
handler.postDelayed({
|
||||
injectFullscreenScript()
|
||||
injectEnhancedAutoPlayScript()
|
||||
}, 300)
|
||||
|
||||
handler.postDelayed({
|
||||
runOnUiThread {
|
||||
loadingOverlay.visibility = View.GONE
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
// Use hardware layer only when needed, not always
|
||||
webView.setLayerType(View.LAYER_TYPE_NONE, null)
|
||||
}
|
||||
|
||||
private fun injectFullscreenStyles(html: String): String {
|
||||
val styleInjection = """
|
||||
@@ -449,12 +455,10 @@ class PlaybackActivity : AppCompatActivity() {
|
||||
});
|
||||
};
|
||||
|
||||
makePlayerFullscreen();
|
||||
setTimeout(makePlayerFullscreen, 500);
|
||||
setTimeout(makePlayerFullscreen, 1500);
|
||||
setTimeout(makePlayerFullscreen, 3000);
|
||||
setInterval(makePlayerFullscreen, 5000);
|
||||
})();
|
||||
makePlayerFullscreen();
|
||||
setTimeout(makePlayerFullscreen, 500);
|
||||
setTimeout(makePlayerFullscreen, 2000);
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null
|
||||
)
|
||||
|
||||
@@ -7,6 +7,8 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.leanback.widget.ImageCardView
|
||||
import androidx.leanback.widget.Presenter
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
|
||||
import com.example.tvmon.R
|
||||
import com.example.tvmon.data.model.Content
|
||||
import com.example.tvmon.data.model.Category
|
||||
@@ -68,8 +70,10 @@ class ContentCardPresenter : Presenter() {
|
||||
if (content.thumbnail.isNotBlank()) {
|
||||
Glide.with(cardView.context)
|
||||
.load(content.thumbnail)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.error(R.drawable.default_background)
|
||||
.placeholder(R.drawable.default_background)
|
||||
.transition(DrawableTransitionOptions.withCrossFade(200))
|
||||
.centerCrop()
|
||||
.into(cardView.mainImageView)
|
||||
} else {
|
||||
@@ -143,8 +147,10 @@ class SearchCardPresenter : Presenter() {
|
||||
if (content.thumbnail.isNotBlank()) {
|
||||
Glide.with(cardView.context)
|
||||
.load(content.thumbnail)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.error(R.drawable.default_background)
|
||||
.placeholder(R.drawable.default_background)
|
||||
.transition(DrawableTransitionOptions.withCrossFade(200))
|
||||
.centerCrop()
|
||||
.into(cardView.mainImageView)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.example.tvmon.ui.settings
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.example.tvmon.R
|
||||
import com.example.tvmon.util.ApkDownloader
|
||||
import com.example.tvmon.util.UpdateChecker
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import com.example.tvmon.data.repository.CategoryCacheRepository
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
private val categoryCacheRepository: CategoryCacheRepository by inject()
|
||||
|
||||
private lateinit var tvAppVersion: TextView
|
||||
private lateinit var tvLatestVersion: TextView
|
||||
private lateinit var btnClearCache: Button
|
||||
private lateinit var btnCheckUpdate: Button
|
||||
private lateinit var btnBack: Button
|
||||
|
||||
private var latestVersionInfo: com.example.tvmon.util.VersionInfo? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN,
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||
)
|
||||
|
||||
setContentView(R.layout.activity_settings)
|
||||
|
||||
initViews()
|
||||
loadSettings()
|
||||
setupListeners()
|
||||
}
|
||||
|
||||
private fun initViews() {
|
||||
tvAppVersion = findViewById(R.id.tv_app_version)
|
||||
tvLatestVersion = findViewById(R.id.tv_latest_version)
|
||||
btnClearCache = findViewById(R.id.btn_clear_cache)
|
||||
btnCheckUpdate = findViewById(R.id.btn_check_update)
|
||||
btnBack = findViewById(R.id.btn_back)
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val versionName = UpdateChecker.getCurrentVersionName(this)
|
||||
val versionCode = UpdateChecker.getCurrentVersionCode(this)
|
||||
tvAppVersion.text = "v$versionName (build $versionCode)"
|
||||
|
||||
loadLatestVersion()
|
||||
}
|
||||
|
||||
private fun loadLatestVersion() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
latestVersionInfo = UpdateChecker.fetchLatestVersion()
|
||||
|
||||
if (latestVersionInfo != null) {
|
||||
val currentVersionCode = UpdateChecker.getCurrentVersionCode(this@SettingsActivity)
|
||||
if (latestVersionInfo!!.versionCode > currentVersionCode) {
|
||||
tvLatestVersion.text = "최신 버전: v${latestVersionInfo!!.versionName} (업데이트 가능)"
|
||||
tvLatestVersion.setTextColor(getColor(R.color.accent))
|
||||
} else {
|
||||
tvLatestVersion.text = "최신 버전: v${latestVersionInfo!!.versionName} (최신)"
|
||||
tvLatestVersion.setTextColor(getColor(R.color.netflix_light_gray))
|
||||
}
|
||||
} else {
|
||||
tvLatestVersion.text = "최신 버전: 확인 실패"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
tvLatestVersion.text = "최신 버전: 확인 실패"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
btnClearCache.setOnClickListener {
|
||||
showClearCacheDialog()
|
||||
}
|
||||
|
||||
btnCheckUpdate.setOnClickListener {
|
||||
checkForUpdate()
|
||||
}
|
||||
|
||||
btnBack.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showClearCacheDialog() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("캐시 삭제")
|
||||
.setMessage("모든 캐시 데이터를 삭제하시겠습니까?\n\n삭제 항목:\n- 영화/에피소드 정보\n- 이미지 캐시")
|
||||
.setPositiveButton("삭제") { _, _ ->
|
||||
clearAllCache()
|
||||
}
|
||||
.setNegativeButton("취소", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun clearAllCache() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
categoryCacheRepository.clearCache()
|
||||
|
||||
com.bumptech.glide.Glide.get(this@SettingsActivity).clearMemory()
|
||||
com.bumptech.glide.Glide.get(this@SettingsActivity).clearDiskCache()
|
||||
|
||||
Toast.makeText(this@SettingsActivity, "캐시가 삭제되었습니다", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this@SettingsActivity, "캐시 삭제 실패: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkForUpdate() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val latestVersion = UpdateChecker.fetchLatestVersion()
|
||||
|
||||
if (latestVersion != null) {
|
||||
if (UpdateChecker.needsUpdate(this@SettingsActivity, latestVersion)) {
|
||||
showUpdateDialog(latestVersion)
|
||||
} else {
|
||||
Toast.makeText(this@SettingsActivity, "최신 버전입니다 (v${latestVersion.versionName})", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(this@SettingsActivity, "업데이트 정보를 가져올 수 없습니다", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this@SettingsActivity, "업데이트 확인 실패: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUpdateDialog(versionInfo: com.example.tvmon.util.VersionInfo) {
|
||||
val message = buildString {
|
||||
append("현재 버전: ${UpdateChecker.getCurrentVersionName(this@SettingsActivity)}\n")
|
||||
append("최신 버전: ${versionInfo.versionName}\n\n")
|
||||
append(versionInfo.updateMessage)
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("업데이트 알림")
|
||||
.setMessage(message)
|
||||
.setPositiveButton("업데이트") { _, _ ->
|
||||
Toast.makeText(this, "업데이트 다운로드 시작...", Toast.LENGTH_SHORT).show()
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val apkFile = ApkDownloader.downloadApkDirect(this@SettingsActivity, versionInfo.apkUrl)
|
||||
if (apkFile != null) {
|
||||
ApkDownloader.installApk(this@SettingsActivity, apkFile)
|
||||
finish()
|
||||
} else {
|
||||
Toast.makeText(this@SettingsActivity, "다운로드 실패", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this@SettingsActivity, "다운로드 오류: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton("나중에", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.example.tvmon.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object ApkDownloader {
|
||||
private const val TAG = "ApkDownloader"
|
||||
private const val APK_FILE_NAME = "tvmon_update.apk"
|
||||
private const val CONNECT_TIMEOUT = 30L
|
||||
private const val READ_TIMEOUT = 60L
|
||||
|
||||
private val okHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
|
||||
.readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
suspend fun downloadApkDirect(context: Context, apkUrl: String): File? = withContext(Dispatchers.IO) {
|
||||
var response: Response? = null
|
||||
var outputStream: FileOutputStream? = null
|
||||
try {
|
||||
android.util.Log.i(TAG, "Starting download from: $apkUrl")
|
||||
|
||||
val apkFile = getApkFile(context)
|
||||
android.util.Log.i(TAG, "APK file path: ${apkFile.absolutePath}")
|
||||
|
||||
if (apkFile.exists()) {
|
||||
android.util.Log.i(TAG, "Deleting existing APK file")
|
||||
apkFile.delete()
|
||||
}
|
||||
|
||||
apkFile.parentFile?.mkdirs()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(apkUrl)
|
||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android TV) AppleWebKit/537.36")
|
||||
.build()
|
||||
|
||||
android.util.Log.i(TAG, "Executing request...")
|
||||
|
||||
response = okHttpClient.newCall(request).execute()
|
||||
android.util.Log.i(TAG, "Response received: ${response.code} ${response.message}")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
android.util.Log.e(TAG, "Download failed: ${response.code} ${response.message}")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val responseBody: ResponseBody = response.body ?: return@withContext null
|
||||
val contentLength = responseBody.contentLength()
|
||||
|
||||
android.util.Log.i(TAG, "Starting file download...")
|
||||
outputStream = FileOutputStream(apkFile)
|
||||
val inputStream = responseBody.byteStream()
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
var totalBytesRead = 0L
|
||||
|
||||
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
totalBytesRead += bytesRead
|
||||
|
||||
if (contentLength > 0) {
|
||||
val progress = (totalBytesRead * 100 / contentLength).toInt()
|
||||
if (progress % 10 == 0) {
|
||||
android.util.Log.d(TAG, "Download progress: $progress%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputStream.flush()
|
||||
android.util.Log.i(TAG, "APK download completed: ${apkFile.absolutePath}, size: ${apkFile.length()}")
|
||||
|
||||
return@withContext apkFile
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Download failed: ${e.message}", e)
|
||||
return@withContext null
|
||||
} finally {
|
||||
outputStream?.close()
|
||||
response?.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun installApk(context: Context, apkFile: File) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
apkFile
|
||||
)
|
||||
} else {
|
||||
Uri.fromFile(apkFile)
|
||||
}
|
||||
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Failed to install APK: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getApkFile(context: Context): File {
|
||||
return File(context.filesDir, APK_FILE_NAME)
|
||||
}
|
||||
|
||||
fun isApkDownloaded(context: Context): Boolean {
|
||||
return getApkFile(context).exists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.example.tvmon.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
||||
data class VersionInfo(
|
||||
val versionCode: Int,
|
||||
val versionName: String,
|
||||
val apkUrl: String,
|
||||
val updateMessage: String,
|
||||
val forceUpdate: Boolean
|
||||
)
|
||||
|
||||
object UpdateChecker {
|
||||
private const val TAG = "UpdateChecker"
|
||||
|
||||
private const val VERSION_JSON_URL = "https://tvmon.site/version.json"
|
||||
private const val APK_BASE_URL = "https://tvmon.site/releases"
|
||||
|
||||
fun getCurrentVersionCode(context: Context): Int {
|
||||
return try {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionCode
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentVersionName(context: Context): String {
|
||||
return try {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0"
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
"1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchLatestVersion(): VersionInfo? = withContext(Dispatchers.IO) {
|
||||
fetchLatestVersionDirect()
|
||||
}
|
||||
|
||||
private fun fetchLatestVersionDirect(): VersionInfo? {
|
||||
try {
|
||||
android.util.Log.d(TAG, "Fetching version.json from: $VERSION_JSON_URL")
|
||||
|
||||
val url = java.net.URL(VERSION_JSON_URL)
|
||||
val conn = url.openConnection() as java.net.HttpURLConnection
|
||||
conn.requestMethod = "GET"
|
||||
conn.connectTimeout = 15000
|
||||
conn.readTimeout = 15000
|
||||
|
||||
val responseCode = conn.responseCode
|
||||
android.util.Log.d(TAG, "Response Code: $responseCode")
|
||||
|
||||
if (responseCode != 200) {
|
||||
android.util.Log.e(TAG, "HTTP Error: $responseCode")
|
||||
return null
|
||||
}
|
||||
|
||||
val jsonStr = conn.inputStream.bufferedReader().readText()
|
||||
android.util.Log.d(TAG, "Response: $jsonStr")
|
||||
|
||||
val json = JSONObject(jsonStr)
|
||||
val versionCode = json.getInt("versionCode")
|
||||
val versionName = json.getString("versionName")
|
||||
val updateMessage = json.optString("updateMessage", "새로운 버전이 있습니다.")
|
||||
val forceUpdate = json.optBoolean("forceUpdate", false)
|
||||
|
||||
val apkUrl = if (json.has("apkUrl") && !json.isNull("apkUrl")) {
|
||||
json.getString("apkUrl")
|
||||
} else {
|
||||
"$APK_BASE_URL/v$versionName/app-release.apk"
|
||||
}
|
||||
|
||||
android.util.Log.d(TAG, "Version: $versionName, apkUrl: $apkUrl")
|
||||
|
||||
return VersionInfo(
|
||||
versionCode = versionCode,
|
||||
versionName = versionName,
|
||||
apkUrl = apkUrl,
|
||||
updateMessage = updateMessage,
|
||||
forceUpdate = forceUpdate
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Failed to fetch version: ${e.message}", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun needsUpdate(context: Context, latestVersion: VersionInfo): Boolean {
|
||||
val currentVersionCode = getCurrentVersionCode(context)
|
||||
android.util.Log.i(TAG, "Current: $currentVersionCode, Latest: ${latestVersion.versionCode}")
|
||||
return latestVersion.versionCode > currentVersionCode
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,66 @@
|
||||
<?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>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="320dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="180">
|
||||
|
||||
<!-- Background with Netflix dark gray -->
|
||||
<path
|
||||
android:fillColor="#141414"
|
||||
android:pathData="M0,0h320v180h-320z"/>
|
||||
|
||||
<!-- TV Screen - Netflix Red -->
|
||||
<path
|
||||
android:fillColor="#E50914"
|
||||
android:pathData="M110,50h100v70h-100z"/>
|
||||
|
||||
<!-- TV Stand -->
|
||||
<path
|
||||
android:fillColor="#E50914"
|
||||
android:pathData="M140,125h40v8h-40z"/>
|
||||
<path
|
||||
android:fillColor="#E50914"
|
||||
android:pathData="M125,133h70v4h-70z"/>
|
||||
|
||||
<!-- Play Button - White -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M145,70l30,20l-30,20z"/>
|
||||
|
||||
<!-- tvmon Text - White -->
|
||||
<!-- Letter 't' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M100,148h6v-3h-6v-4h10v18h-4v-11h-6z"/>
|
||||
<!-- Letter 'v' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M115,141h4l5,15l5,-15h4l-7,18h-4z"/>
|
||||
<!-- Letter 'm' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M140,159v-18h5v3c1.5,-2 3,-3 5,-3c2,0 3.5,1 4.5,2.5c1.5,-2 3.5,-2.5 5.5,-2.5c4,0 6.5,2.5 6.5,6.5v11.5h-5v-11c0,-2 -1.5,-3.5 -3.5,-3.5s-3.5,1.5 -3.5,3.5v11h-5v-11c0,-2 -1.5,-3.5 -3.5,-3.5s-3.5,1.5 -3.5,3.5v11z"/>
|
||||
<!-- Letter 'o' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M175,148c0,-5.5 4.5,-10 10,-10s10,4.5 10,10s-4.5,10 -10,10s-10,-4.5 -10,-10zm4,0c0,3.5 2.5,6 6,6s6,-2.5 6,-6s-2.5,-6 -6,-6s-6,2.5 -6,6z"/>
|
||||
<!-- Letter 'n' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M205,159v-18h5v3c2,-2 4,-3 6,-3c5,0 8,3 8,8v10h-5v-10c0,-2.5 -2,-4.5 -4.5,-4.5s-4.5,2 -4.5,4.5v10z"/>
|
||||
|
||||
<!-- Right side - decorative lines -->
|
||||
<path
|
||||
android:fillColor="#E50914"
|
||||
android:fillAlpha="0.3"
|
||||
android:pathData="M250,40h40v4h-40z"/>
|
||||
<path
|
||||
android:fillColor="#E50914"
|
||||
android:fillAlpha="0.3"
|
||||
android:pathData="M250,55h35v4h-35z"/>
|
||||
<path
|
||||
android:fillColor="#E50914"
|
||||
android:fillAlpha="0.3"
|
||||
android:pathData="M250,70h30v4h-30z"/>
|
||||
</vector>
|
||||
@@ -1,49 +1,66 @@
|
||||
<?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>
|
||||
android:width="320dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="180">
|
||||
|
||||
<!-- Background with Netflix dark gray -->
|
||||
<path
|
||||
android:fillColor="#141414"
|
||||
android:pathData="M0,0h320v180h-320z"/>
|
||||
|
||||
<!-- TV Screen - Netflix Red -->
|
||||
<path
|
||||
android:fillColor="#E50914"
|
||||
android:pathData="M110,50h100v70h-100z"/>
|
||||
|
||||
<!-- TV Stand -->
|
||||
<path
|
||||
android:fillColor="#E50914"
|
||||
android:pathData="M140,125h40v8h-40z"/>
|
||||
<path
|
||||
android:fillColor="#E50914"
|
||||
android:pathData="M125,133h70v4h-70z"/>
|
||||
|
||||
<!-- Play Button - White -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M145,70l30,20l-30,20z"/>
|
||||
|
||||
<!-- tvmon Text - White -->
|
||||
<!-- Letter 't' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M100,148h6v-3h-6v-4h10v18h-4v-11h-6z"/>
|
||||
<!-- Letter 'v' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M115,141h4l5,15l5,-15h4l-7,18h-4z"/>
|
||||
<!-- Letter 'm' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M140,159v-18h5v3c1.5,-2 3,-3 5,-3c2,0 3.5,1 4.5,2.5c1.5,-2 3.5,-2.5 5.5,-2.5c4,0 6.5,2.5 6.5,6.5v11.5h-5v-11c0,-2 -1.5,-3.5 -3.5,-3.5s-3.5,1.5 -3.5,3.5v11h-5v-11c0,-2 -1.5,-3.5 -3.5,-3.5s-3.5,1.5 -3.5,3.5v11z"/>
|
||||
<!-- Letter 'o' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M175,148c0,-5.5 4.5,-10 10,-10s10,4.5 10,10s-4.5,10 -10,10s-10,-4.5 -10,-10zm4,0c0,3.5 2.5,6 6,6s6,-2.5 6,-6s-2.5,-6 -6,-6s-6,2.5 -6,6z"/>
|
||||
<!-- Letter 'n' -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M205,159v-18h5v3c2,-2 4,-3 6,-3c5,0 8,3 8,8v10h-5v-10c0,-2.5 -2,-4.5 -4.5,-4.5s-4.5,2 -4.5,4.5v10z"/>
|
||||
|
||||
<!-- Right side - decorative lines -->
|
||||
<path
|
||||
android:fillColor="#E50914"
|
||||
android:fillAlpha="0.3"
|
||||
android:pathData="M250,40h40v4h-40z"/>
|
||||
<path
|
||||
android:fillColor="#E50914"
|
||||
android:fillAlpha="0.3"
|
||||
android:pathData="M250,55h35v4h-35z"/>
|
||||
<path
|
||||
android:fillColor="#E50914"
|
||||
android:fillAlpha="0.3"
|
||||
android:pathData="M250,70h30v4h-30z"/>
|
||||
</vector>
|
||||
@@ -1,52 +1,47 @@
|
||||
<?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"/>
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- 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>
|
||||
<!-- TV Screen with white color -->
|
||||
<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 - using Netflix red -->
|
||||
<path
|
||||
android:fillColor="#E50914"
|
||||
android:pathData="M50,44l12,8l-12,8z"/>
|
||||
|
||||
<!-- tvmon Text - white -->
|
||||
<!-- 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>
|
||||
146
tvmon-app/app/src/main/res/layout/activity_settings.xml
Normal file
@@ -0,0 +1,146 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/netflix_black"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="48dp">
|
||||
|
||||
<!-- Header -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="설정"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="32sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="32dp" />
|
||||
|
||||
<!-- Settings Items -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- App Version -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="200dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="앱 버전"
|
||||
android:textColor="@color/netflix_light_gray"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_app_version"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="-"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_latest_version"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="최신 버전: 확인 중..."
|
||||
android:textColor="@color/netflix_light_gray"
|
||||
android:textSize="14sp"
|
||||
android:layout_marginTop="4dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Cache Delete Buttons -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="200dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="캐시 관리"
|
||||
android:textColor="@color/netflix_light_gray"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_clear_cache"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="전체 삭제"
|
||||
android:textSize="14sp"
|
||||
android:backgroundTint="@color/netflix_medium_gray"
|
||||
android:textColor="@android:color/white"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Update Check Button -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="200dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="업데이트"
|
||||
android:textColor="@color/netflix_light_gray"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_check_update"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="업데이트 확인"
|
||||
android:textSize="16sp"
|
||||
android:backgroundTint="@color/accent"
|
||||
android:textColor="@android:color/white"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingVertical="12dp" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Bottom Spacer -->
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<!-- Back Button -->
|
||||
<Button
|
||||
android:id="@+id/btn_back"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="뒤로 가기"
|
||||
android:textSize="16sp"
|
||||
android:backgroundTint="@color/netflix_medium_gray"
|
||||
android:textColor="@android:color/white"
|
||||
android:paddingHorizontal="32dp"
|
||||
android:paddingVertical="16dp"
|
||||
android:layout_gravity="start" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
BIN
tvmon-app/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 757 B |
BIN
tvmon-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 757 B |
BIN
tvmon-app/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 587 B |
BIN
tvmon-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 587 B |
BIN
tvmon-app/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 938 B |
BIN
tvmon-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 938 B |
BIN
tvmon-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
tvmon-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
tvmon-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
tvmon-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1F1F1F</color>
|
||||
<color name="ic_launcher_background">#E50914</color>
|
||||
</resources>
|
||||
|
||||
12
tvmon-app/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<files-path
|
||||
name="internal_files"
|
||||
path="." />
|
||||
<cache-path
|
||||
name="cache"
|
||||
path="." />
|
||||
<external-files-path
|
||||
name="external_files"
|
||||
path="." />
|
||||
</paths>
|
||||
6
tvmon-app/version.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"versionCode": 2,
|
||||
"versionName": "1.0.1",
|
||||
"apkUrl": "https://git.webpluss.net/sanjeok77/tvmon_release/releases/download/v1.0.1/app-release.apk",
|
||||
"updateMessage": "v1.0.1 업데이트\n\n- 정렬 순서 wr_datetime DESC로 변경\n- 런처 아이콘 개선 (Netflix 스타일)\n- 웹뷰 동영상 재생 최적화\n- 설정 버튼 카테고리 하단 배치"
|
||||
}
|
||||