fix: restore Android PiP/fullscreen and remove stale media caching

Fixes #142
This commit is contained in:
kuekhaoyang
2026-04-08 13:49:24 +08:00
parent 4db08dbf72
commit 372e60c4e5
6 changed files with 227 additions and 117 deletions
+2 -1
View File
@@ -26,9 +26,10 @@
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize"
android:exported="true"
android:screenOrientation="landscape"
android:supportsPictureInPicture="true"
android:windowSoftInputMode="adjustResize">
<!-- Leanback launcher (Android TV home) -->
@@ -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
}
}
}
@@ -112,4 +112,11 @@
</LinearLayout>
</LinearLayout>
</ScrollView>
<FrameLayout
android:id="@+id/fullscreen_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000"
android:visibility="gone" />
</FrameLayout>
+18 -8
View File
@@ -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
if (!('serviceWorker' in navigator)) return;
const registerServiceWorker = () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
registration.update().catch(() => {
// Ignore update check errors.
});
})
.catch((err) => {
// Registration failed
});
.catch(() => {
// Ignore registration failures.
});
};
if (document.readyState === 'complete') {
registerServiceWorker();
return;
}
window.addEventListener('load', registerServiceWorker, { once: true });
return () => window.removeEventListener('load', registerServiceWorker);
}, []);
return null;
@@ -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<HTMLDivElement | null>;
videoRef: React.RefObject<HTMLVideoElement | null>;
@@ -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;
+9 -91
View File
@@ -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'
});
});
});
})
);
}
});