From f45ca65951b9def2856bb3e7db00ef5b59f97711 Mon Sep 17 00:00:00 2001 From: tvmon-dev Date: Thu, 16 Apr 2026 20:28:59 +0900 Subject: [PATCH] =?UTF-8?q?v1.0.4=20-=20PlaybackActivity=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tvmon-app/app/build.gradle | 4 +- .../tvmon/ui/playback/PlaybackActivity.kt | 343 ++++++------------ 2 files changed, 117 insertions(+), 230 deletions(-) diff --git a/tvmon-app/app/build.gradle b/tvmon-app/app/build.gradle index 2b95794..8b47d46 100644 --- a/tvmon-app/app/build.gradle +++ b/tvmon-app/app/build.gradle @@ -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" } diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt index ff6f363..044819b 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt @@ -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(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() } -} \ No newline at end of file +}