From 372e60c4e576b19cae15eb94692796372d7c06d1 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Wed, 8 Apr 2026 13:49:24 +0800 Subject: [PATCH] fix: restore Android PiP/fullscreen and remove stale media caching Fixes #142 --- android-tv/app/src/main/AndroidManifest.xml | 3 +- .../main/java/com/kvideo/tv/MainActivity.kt | 157 ++++++++++++++++-- .../app/src/main/res/layout/activity_main.xml | 7 + components/ServiceWorkerRegister.tsx | 28 +++- .../hooks/desktop/useFullscreenControls.ts | 49 +++++- public/sw.js | 100 +---------- 6 files changed, 227 insertions(+), 117 deletions(-) diff --git a/android-tv/app/src/main/AndroidManifest.xml b/android-tv/app/src/main/AndroidManifest.xml index c9bd84a..ac4a130 100644 --- a/android-tv/app/src/main/AndroidManifest.xml +++ b/android-tv/app/src/main/AndroidManifest.xml @@ -26,9 +26,10 @@ diff --git a/android-tv/app/src/main/java/com/kvideo/tv/MainActivity.kt b/android-tv/app/src/main/java/com/kvideo/tv/MainActivity.kt index a2c8658..fd1f106 100644 --- a/android-tv/app/src/main/java/com/kvideo/tv/MainActivity.kt +++ b/android-tv/app/src/main/java/com/kvideo/tv/MainActivity.kt @@ -2,18 +2,27 @@ package com.kvideo.tv import android.annotation.SuppressLint import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Configuration import android.net.Uri +import android.os.Build import android.os.Bundle +import android.util.Log +import android.util.Rational import android.view.KeyEvent import android.view.View +import android.view.ViewGroup import android.view.WindowManager import android.view.inputmethod.EditorInfo +import android.webkit.JavascriptInterface import android.webkit.WebChromeClient +import android.webkit.WebChromeClient.CustomViewCallback import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Button import android.widget.EditText +import android.widget.FrameLayout import android.widget.TextView import androidx.activity.ComponentActivity @@ -22,36 +31,33 @@ class MainActivity : ComponentActivity() { companion object { private const val PREFS_NAME = "kvideo_tv_settings" private const val PREF_SERVER_URL = "server_url" + private const val TAG = "KVideoMainActivity" } private lateinit var webView: WebView private lateinit var setupContainer: View + private lateinit var fullscreenContainer: FrameLayout private lateinit var urlInput: EditText private lateinit var statusText: TextView private lateinit var openButton: Button private lateinit var saveButton: Button private lateinit var prefs: android.content.SharedPreferences + private var customView: View? = null + private var customViewCallback: CustomViewCallback? = null + private var wasSetupVisibleBeforeFullscreen = false @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - // Fullscreen immersive mode window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - @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_FULLSCREEN - or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_LAYOUT_STABLE - ) + applyImmersiveMode() setContentView(R.layout.activity_main) webView = findViewById(R.id.webview) setupContainer = findViewById(R.id.setup_container) + fullscreenContainer = findViewById(R.id.fullscreen_container) urlInput = findViewById(R.id.url_input) statusText = findViewById(R.id.status_text) openButton = findViewById(R.id.open_button) @@ -94,7 +100,41 @@ class MainActivity : ComponentActivity() { } webViewClient = WebViewClient() - webChromeClient = WebChromeClient() + webChromeClient = object : WebChromeClient() { + override fun onShowCustomView(view: View?, callback: CustomViewCallback?) { + if (view == null || callback == null) { + callback?.onCustomViewHidden() + return + } + + if (customView != null) { + callback.onCustomViewHidden() + return + } + + wasSetupVisibleBeforeFullscreen = isSetupVisible() + customView = view + customViewCallback = callback + + fullscreenContainer.removeAllViews() + fullscreenContainer.addView( + view, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + fullscreenContainer.visibility = View.VISIBLE + webView.visibility = View.GONE + setupContainer.visibility = View.GONE + applyImmersiveMode() + } + + override fun onHideCustomView() { + exitCustomFullscreen() + } + } + addJavascriptInterface(AndroidPlayerBridge(), "KVideoAndroid") } val configuredUrl = getConfiguredUrl() @@ -107,6 +147,11 @@ class MainActivity : ComponentActivity() { } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (!isSetupVisible() && keyCode == KeyEvent.KEYCODE_BACK && customView != null) { + exitCustomFullscreen() + return true + } + if (!isSetupVisible() && (keyCode == KeyEvent.KEYCODE_MENU || keyCode == KeyEvent.KEYCODE_SETTINGS)) { showSetup(getString(R.string.status_settings_hint)) return true @@ -130,6 +175,11 @@ class MainActivity : ComponentActivity() { @Deprecated("Use OnBackPressedDispatcher") override fun onBackPressed() { + if (customView != null) { + exitCustomFullscreen() + return + } + if (isSetupVisible()) { @Suppress("DEPRECATION") super.onBackPressed() @@ -143,7 +193,30 @@ class MainActivity : ComponentActivity() { } } + override fun onResume() { + super.onResume() + applyImmersiveMode() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + applyImmersiveMode() + } + } + + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (!isInPictureInPictureMode) { + applyImmersiveMode() + } + } + override fun onDestroy() { + exitCustomFullscreen() webView.destroy() super.onDestroy() } @@ -229,4 +302,66 @@ class MainActivity : ComponentActivity() { val scheme = uri.scheme?.lowercase() return (scheme == "http" || scheme == "https") && !uri.host.isNullOrBlank() } + + private fun applyImmersiveMode() { + @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_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + ) + } + + private fun exitCustomFullscreen() { + val currentCustomView = customView ?: return + fullscreenContainer.removeView(currentCustomView) + fullscreenContainer.visibility = View.GONE + customView = null + webView.visibility = View.VISIBLE + if (wasSetupVisibleBeforeFullscreen) { + setupContainer.visibility = View.VISIBLE + } + customViewCallback?.onCustomViewHidden() + customViewCallback = null + wasSetupVisibleBeforeFullscreen = false + applyImmersiveMode() + } + + private fun isPictureInPictureSupported(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return false + } + + return packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + } + + private inner class AndroidPlayerBridge { + @JavascriptInterface + fun isPictureInPictureSupported(): Boolean = isPictureInPictureSupported() + + @JavascriptInterface + fun enterPictureInPicture(width: Int, height: Int): Boolean { + if (!isPictureInPictureSupported()) { + return false + } + + runOnUiThread { + try { + exitCustomFullscreen() + val builder = android.app.PictureInPictureParams.Builder() + if (width > 0 && height > 0) { + builder.setAspectRatio(Rational(width, height)) + } + enterPictureInPictureMode(builder.build()) + } catch (error: IllegalStateException) { + Log.w(TAG, "Failed to enter Picture-in-Picture mode", error) + } + } + + return true + } + } } diff --git a/android-tv/app/src/main/res/layout/activity_main.xml b/android-tv/app/src/main/res/layout/activity_main.xml index ffbc956..602e4c1 100644 --- a/android-tv/app/src/main/res/layout/activity_main.xml +++ b/android-tv/app/src/main/res/layout/activity_main.xml @@ -112,4 +112,11 @@ + + diff --git a/components/ServiceWorkerRegister.tsx b/components/ServiceWorkerRegister.tsx index 82be763..4537f12 100644 --- a/components/ServiceWorkerRegister.tsx +++ b/components/ServiceWorkerRegister.tsx @@ -4,17 +4,27 @@ import { useEffect } from 'react'; export function ServiceWorkerRegister() { useEffect(() => { - if ('serviceWorker' in navigator) { - window.addEventListener('load', () => { - navigator.serviceWorker.register('/sw.js').then( - (registration) => { - // Registration successful - }) - .catch((err) => { - // Registration failed + if (!('serviceWorker' in navigator)) return; + + const registerServiceWorker = () => { + navigator.serviceWorker.register('/sw.js') + .then((registration) => { + registration.update().catch(() => { + // Ignore update check errors. }); - }); + }) + .catch(() => { + // Ignore registration failures. + }); + }; + + if (document.readyState === 'complete') { + registerServiceWorker(); + return; } + + window.addEventListener('load', registerServiceWorker, { once: true }); + return () => window.removeEventListener('load', registerServiceWorker); }, []); return null; diff --git a/components/player/hooks/desktop/useFullscreenControls.ts b/components/player/hooks/desktop/useFullscreenControls.ts index 7749803..2ca92b1 100644 --- a/components/player/hooks/desktop/useFullscreenControls.ts +++ b/components/player/hooks/desktop/useFullscreenControls.ts @@ -1,6 +1,11 @@ import { useCallback, useEffect, useMemo } from 'react'; import type { FullscreenMode } from '../useDesktopPlayerState'; +interface AndroidPiPBridge { + isPictureInPictureSupported?: () => boolean; + enterPictureInPicture?: (width: number, height: number) => boolean; +} + interface UseFullscreenControlsProps { containerRef: React.RefObject; videoRef: React.RefObject; @@ -53,19 +58,48 @@ export function useFullscreenControls({ (document as any).msFullscreenElement ), []); + const getAndroidPiPBridge = useCallback((): AndroidPiPBridge | null => { + if (typeof window === 'undefined') return null; + + const bridge = (window as Window & { KVideoAndroid?: AndroidPiPBridge }).KVideoAndroid; + if (!bridge) return null; + + return bridge; + }, []); + + const requestAndroidPictureInPicture = useCallback(() => { + const bridge = getAndroidPiPBridge(); + const video = videoRef.current; + if (!bridge || !video || typeof bridge.enterPictureInPicture !== 'function') { + return false; + } + + const width = video.videoWidth || containerRef.current?.clientWidth || 16; + const height = video.videoHeight || containerRef.current?.clientHeight || 9; + + try { + return bridge.enterPictureInPicture(width, height) !== false; + } catch (error) { + console.error('Android Picture-in-Picture bridge failed:', error); + return false; + } + }, [containerRef, getAndroidPiPBridge, videoRef]); + useEffect(() => { if (typeof document !== 'undefined') { - const hasNativePiP = 'pictureInPictureEnabled' in document; + const hasNativePiP = Boolean((document as any).pictureInPictureEnabled); const hasWebkitPiP = videoRef.current && ( 'webkitSupportsPresentationMode' in (videoRef.current as any) || 'webkitPresentationMode' in (videoRef.current as any) ); - setIsPiPSupported(hasNativePiP || !!hasWebkitPiP); + const androidBridge = getAndroidPiPBridge(); + const hasAndroidPiPBridge = Boolean(androidBridge?.isPictureInPictureSupported?.()); + setIsPiPSupported(hasNativePiP || !!hasWebkitPiP || hasAndroidPiPBridge); } if (typeof window !== 'undefined') { setIsAirPlaySupported('WebKitPlaybackTargetAvailabilityEvent' in window); } - }, [setIsPiPSupported, setIsAirPlaySupported, videoRef]); + }, [getAndroidPiPBridge, setIsPiPSupported, setIsAirPlaySupported, videoRef]); const exitNativeFullscreen = useCallback(async () => { try { @@ -259,15 +293,20 @@ export function useFullscreenControls({ await document.exitPictureInPicture(); } else if (video.webkitPresentationMode === 'picture-in-picture') { video.webkitSetPresentationMode('inline'); - } else if (video.requestPictureInPicture) { + } else if (video.requestPictureInPicture && (document as any).pictureInPictureEnabled) { await video.requestPictureInPicture(); + } else if (requestAndroidPictureInPicture()) { + return; } else if (video.webkitSupportsPresentationMode && video.webkitSupportsPresentationMode('picture-in-picture')) { video.webkitSetPresentationMode('picture-in-picture'); } } catch (error) { + if (requestAndroidPictureInPicture()) { + return; + } console.error('Failed to toggle Picture-in-Picture:', error); } - }, [videoRef, isPiPSupported]); + }, [isPiPSupported, requestAndroidPictureInPicture, videoRef]); const showAirPlayMenu = useCallback(() => { if (!videoRef.current || !isAirPlaySupported) return; diff --git a/public/sw.js b/public/sw.js index 43a0c43..2277237 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,99 +1,17 @@ -const CACHE_NAME = 'video-cache-v2'; +const LEGACY_CACHE_PREFIXES = ['video-cache-']; -self.addEventListener('install', (event) => { +self.addEventListener('install', () => { self.skipWaiting(); }); self.addEventListener('activate', (event) => { event.waitUntil( - caches.keys().then((cacheNames) => { - return Promise.all( - cacheNames.map((cacheName) => { - if (cacheName !== CACHE_NAME) { - return caches.delete(cacheName); - } - }) - ); - }).then(() => self.clients.claim()) + caches.keys() + .then((cacheNames) => Promise.all( + cacheNames + .filter((cacheName) => LEGACY_CACHE_PREFIXES.some((prefix) => cacheName.startsWith(prefix))) + .map((cacheName) => caches.delete(cacheName)) + )) + .then(() => self.clients.claim()) ); }); - -self.addEventListener('fetch', (event) => { - const url = new URL(event.request.url); - - // Skip proxy API routes - they handle their own caching and URL rewriting - if (url.pathname.startsWith('/api/proxy')) { - return; // Let the request pass through without Service Worker intervention - } - - // Intercept HLS manifest files (.m3u8) - if (url.pathname.endsWith('.m3u8')) { - event.respondWith( - caches.open(CACHE_NAME).then((cache) => { - return cache.match(event.request, { ignoreSearch: true }).then((cachedResponse) => { - // Always fetch fresh manifest but return cached while fetching - const fetchPromise = fetch(event.request).then((networkResponse) => { - // Check if network response is valid - if (!networkResponse || networkResponse.status !== 200) { - // Return the response as-is so client can see the error status - if (networkResponse) return networkResponse; - // If no response at all, throw to trigger catch block - throw new Error('Network response was not ok'); - } - cache.put(event.request, networkResponse.clone()); - return networkResponse; - }).catch((err) => { - console.error('[SW] Fetch failed for manifest:', err); - // If network fails, return cached response if available - if (cachedResponse) { - return cachedResponse; - } - // If no cache, return a proper error Response instead of throwing - // This prevents "Load failed" and lets the client handle it - return new Response('Network error', { - status: 503, - statusText: 'Service Worker: Network Unavailable' - }); - }); - - // Return cache immediately if available, otherwise wait for network - return cachedResponse || fetchPromise; - }); - }) - ); - } - - // Intercept video segment files (.ts) - if (url.pathname.endsWith('.ts')) { - event.respondWith( - caches.open(CACHE_NAME).then((cache) => { - return cache.match(event.request, { ignoreSearch: true }).then((cachedResponse) => { - // Cache hit - return immediately for instant playback - if (cachedResponse) { - return cachedResponse; - } - - // Cache miss - fetch from network - return fetch(event.request).then((response) => { - // Only cache valid responses - if (response && response.status === 200) { - cache.put(event.request, response.clone()); - return response; - } - // If response is not valid (e.g. 403, 404), return it as is - // so the browser/player can handle the error status - return response; - }).catch((error) => { - console.error('[SW] Failed to fetch segment:', error); - // Return a proper error Response instead of throwing - // This prevents "Load failed" and lets the client handle it - return new Response('Network error', { - status: 503, - statusText: 'Service Worker: Network Unavailable' - }); - }); - }); - }) - ); - } -});