mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
fix: restore Android PiP/fullscreen and remove stale media caching
Fixes #142
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user