v1.0.4 - PlaybackActivity 업데이트

This commit is contained in:
tvmon-dev
2026-04-16 20:28:59 +09:00
parent 616891ab5e
commit f45ca65951
2 changed files with 117 additions and 230 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId "com.example.tvmon"
minSdk 28
targetSdk 34
versionCode 4
versionName "1.0.3"
versionCode 5
versionName "1.0.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -20,6 +20,7 @@ import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity
import com.example.tvmon.R
import java.io.ByteArrayInputStream
import java.net.HttpURLConnection
class PlaybackActivity : AppCompatActivity() {
@@ -31,9 +32,13 @@ class PlaybackActivity : AppCompatActivity() {
private lateinit var webView: WebView
private lateinit var loadingOverlay: View
private var isVideoPlaying = false
private val handler = Handler(Looper.getMainLooper())
// [FIX 1] loadingRunnable을 명시적으로 관리하여 중복 postDelayed 방지
private val hideLoadingRunnable = Runnable {
loadingOverlay.visibility = View.GONE
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -67,45 +72,9 @@ class PlaybackActivity : AppCompatActivity() {
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)
// [FIX 2] 불필요한 WebView 제거/재생성 패턴 제거.
// XML에 정의된 WebView를 그대로 사용하고, 서브클래스 기능은 setOnKeyListener로 처리.
webView = findViewById(R.id.webview)
webView.setOnKeyListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN) {
@@ -115,31 +84,12 @@ class PlaybackActivity : AppCompatActivity() {
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_DPAD_LEFT -> { seekVideo(-10000); true }
KeyEvent.KEYCODE_DPAD_RIGHT -> { seekVideo(10000); true }
KeyEvent.KEYCODE_DPAD_UP -> { adjustVolume(0.1f); true }
KeyEvent.KEYCODE_DPAD_DOWN -> { adjustVolume(-0.1f); true }
KeyEvent.KEYCODE_BACK -> {
if (webView.canGoBack()) {
webView.goBack()
}
true
if (webView.canGoBack()) { webView.goBack(); true } else false
}
else -> false
}
@@ -153,125 +103,88 @@ 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
webSettings.blockNetworkImage = false
// Video playback optimizations
webSettings.mediaPlaybackRequiresUserGesture = false
webSettings.blockNetworkImage = false
webView.addJavascriptInterface(object {
@android.webkit.JavascriptInterface
fun hideLoadingOverlay() {
runOnUiThread {
loadingOverlay.visibility = View.GONE
}
}
}, "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 onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
super.onPageStarted(view, url, favicon)
handler.removeCallbacksAndMessages(null)
runOnUiThread {
loadingOverlay.visibility = View.VISIBLE
}
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
android.util.Log.i("PlaybackActivity", "Page finished: $url")
handler.postDelayed({
injectFullscreenScript()
}, 300)
handler.postDelayed({
webView.addJavascriptInterface(object {
@android.webkit.JavascriptInterface
fun hideLoadingOverlay() {
runOnUiThread {
loadingOverlay.visibility = View.GONE
}
}, 1500)
}
}
}
}, "Android")
// Use hardware layer only when needed, not always
webView.setLayerType(View.LAYER_TYPE_NONE, null)
}
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
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?) {
request?.grant(request.resources)
}
}
webView.webViewClient = object : WebViewClient() {
// [FIX 3] shouldInterceptRequest 메모리 누수 수정.
// 기존 코드는 요청마다 HttpURLConnection을 열어 전체 응답을 String으로 로드하고
// disconnect()를 호출하지 않아 연결과 메모리가 누적되었음.
// CSS/JS 인터셉트는 실익이 없으므로 제거하고 null 반환(WebView 기본 처리)으로 대체.
// HTML 스타일 주입은 onPageFinished의 JS 인젝션으로 충분히 커버됨.
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
return null
}
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
super.onPageStarted(view, url, favicon)
// [FIX 4] 페이지 시작 시 이전 hideLoading 콜백만 제거 (다른 콜백은 유지)
handler.removeCallbacks(hideLoadingRunnable)
loadingOverlay.visibility = View.VISIBLE
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
android.util.Log.i("PlaybackActivity", "Page finished: $url")
// [FIX 5] injectFullscreenScript는 한 번만, hideLoading은 명시적 Runnable로 관리
handler.postDelayed({ injectFullscreenScript() }, 300)
handler.removeCallbacks(hideLoadingRunnable)
handler.postDelayed(hideLoadingRunnable, 1500)
}
}
webView.setLayerType(View.LAYER_TYPE_NONE, null)
}
private fun injectFullscreenStyles(html: String): String {
val styleInjection = """
@@ -422,8 +335,8 @@ webView.setLayerType(View.LAYER_TYPE_NONE, null)
});
};
makePlayerFullscreen();
})();
makePlayerFullscreen();
})();
""".trimIndent(),
null
)
@@ -433,13 +346,12 @@ webView.setLayerType(View.LAYER_TYPE_NONE, null)
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")
simulateCenterClick()
toggleVideoPlayback()
return true
}
if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER)
&& action == KeyEvent.ACTION_DOWN) {
simulateCenterClick()
toggleVideoPlayback()
return true
}
if (keyCode == KeyEvent.KEYCODE_BACK && action == KeyEvent.ACTION_DOWN) {
if (webView.canGoBack()) {
@@ -453,16 +365,6 @@ if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTE
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(
"""
@@ -485,15 +387,9 @@ if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTE
(function() {
var video = document.querySelector('video');
if (video && video.duration > 0) {
if (video.paused) {
video.play();
return 'played';
} else {
video.pause();
return 'paused';
}
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 {
@@ -501,12 +397,10 @@ if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTE
iframes[i].contentWindow.postMessage('toggle', '*');
} catch(e) {}
}
var focused = document.activeElement;
if (focused && focused !== document.body) {
try { focused.click(); } catch(e) {}
}
return 'sent';
})();
""".trimIndent()
@@ -525,14 +419,12 @@ if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTE
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(),
@@ -541,28 +433,18 @@ if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTE
}
private fun simulateCenterClick() {
runOnUiThread {
val centerX = webView.width / 2f
val centerY = webView.height / 2f
val downTime = SystemClock.uptimeMillis()
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
)
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)
webView.dispatchTouchEvent(downEvent)
webView.dispatchTouchEvent(upEvent)
downEvent.recycle()
upEvent.recycle()
android.util.Log.d("PlaybackActivity", "Center click simulated at ($centerX, $centerY)")
}
downEvent.recycle()
upEvent.recycle()
}
@Deprecated("Deprecated in Java")
@@ -590,10 +472,15 @@ if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTE
}
override fun onDestroy() {
// [FIX 6] handler 콜백 전부 제거 후 WebView 정리. loadingOverlay 참조 해제.
handler.removeCallbacksAndMessages(null)
if (::webView.isInitialized) {
webView.stopLoading()
webView.clearHistory()
webView.webViewClient = WebViewClient() // 기존 client 참조 해제
webView.webChromeClient = null
webView.destroy()
}
super.onDestroy()
}
}
}