VirtualCam
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
venv
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
aiohttp
|
||||||
|
aiortc
|
||||||
|
av
|
||||||
|
numpy
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
WebRTC -> v4l2loopback bridge with stable output resolution (server-side reformat).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 server.py --cert cert.pem --key key.pem --video-device /dev/video2 --host 0.0.0.0 --port 8443 --width 640 --height 480
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- If you omit --width/--height the server will use the first incoming frame's size.
|
||||||
|
- Requires the same dependencies as before: aiohttp, aiortc, av, numpy, ffmpeg installed system-wide.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
from aiohttp import web
|
||||||
|
from aiortc import RTCPeerConnection, RTCSessionDescription
|
||||||
|
|
||||||
|
ROOT = os.path.join(os.path.dirname(__file__), "static")
|
||||||
|
pcs = set()
|
||||||
|
|
||||||
|
async def index(request):
|
||||||
|
return web.FileResponse(os.path.join(ROOT, "index.html"))
|
||||||
|
|
||||||
|
async def offer(request):
|
||||||
|
params = await request.json()
|
||||||
|
offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"])
|
||||||
|
|
||||||
|
pc = RTCPeerConnection()
|
||||||
|
pcs.add(pc)
|
||||||
|
print("Created peer connection", pc)
|
||||||
|
|
||||||
|
# ffmpeg process holder per-track
|
||||||
|
ffmpeg_proc = {"proc": None}
|
||||||
|
|
||||||
|
@pc.on("iceconnectionstatechange")
|
||||||
|
def on_ice():
|
||||||
|
print("ICE:", pc.iceConnectionState)
|
||||||
|
if pc.iceConnectionState == "failed":
|
||||||
|
asyncio.ensure_future(pc.close())
|
||||||
|
|
||||||
|
@pc.on("track")
|
||||||
|
def on_track(track):
|
||||||
|
print("Track received:", track.kind)
|
||||||
|
|
||||||
|
if track.kind != "video":
|
||||||
|
return
|
||||||
|
|
||||||
|
async def run_video():
|
||||||
|
proc = None
|
||||||
|
target_w = request.app.get("force_width")
|
||||||
|
target_h = request.app.get("force_height")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# get first frame (blocks until available)
|
||||||
|
first_frame = await track.recv()
|
||||||
|
# if client didn't force res, use first frame's dim
|
||||||
|
if not target_w or not target_h:
|
||||||
|
target_w = first_frame.width
|
||||||
|
target_h = first_frame.height
|
||||||
|
print(f"No forced size given; using first-frame size {target_w}x{target_h}")
|
||||||
|
else:
|
||||||
|
print(f"Using forced output size {target_w}x{target_h}")
|
||||||
|
|
||||||
|
# start ffmpeg once using target size
|
||||||
|
device = request.app["video_device"]
|
||||||
|
fps = request.app.get("force_fps", 30)
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel", "warning",
|
||||||
|
"-f", "rawvideo",
|
||||||
|
"-pix_fmt", "rgb24",
|
||||||
|
"-s", f"{target_w}x{target_h}",
|
||||||
|
"-r", str(fps),
|
||||||
|
"-i", "-",
|
||||||
|
"-f", "v4l2",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
device
|
||||||
|
]
|
||||||
|
print("Starting ffmpeg:", " ".join(cmd))
|
||||||
|
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, bufsize=0)
|
||||||
|
ffmpeg_proc["proc"] = proc
|
||||||
|
|
||||||
|
# reformat first frame to desired layout
|
||||||
|
try:
|
||||||
|
f_reformat = first_frame.reformat(width=target_w, height=target_h, format="rgb24")
|
||||||
|
except Exception:
|
||||||
|
# fallback: to_ndarray + numpy resize could go here, but reformat is preferred
|
||||||
|
f_reformat = first_frame
|
||||||
|
|
||||||
|
arr = f_reformat.to_ndarray(format="rgb24")
|
||||||
|
try:
|
||||||
|
proc.stdin.write(arr.tobytes())
|
||||||
|
proc.stdin.flush()
|
||||||
|
except BrokenPipeError:
|
||||||
|
print("BrokenPipeError writing first frame to ffmpeg")
|
||||||
|
except Exception as e:
|
||||||
|
print("Error writing first frame to ffmpeg:", e)
|
||||||
|
|
||||||
|
# loop and process subsequent frames: always reformat to target size/pixfmt
|
||||||
|
while True:
|
||||||
|
frame = await track.recv()
|
||||||
|
# reformat to target size/pixfmt; this creates a new VideoFrame
|
||||||
|
try:
|
||||||
|
out_frame = frame.reformat(width=target_w, height=target_h, format="rgb24")
|
||||||
|
except Exception:
|
||||||
|
# if reformat fails for any reason, skip the frame but continue
|
||||||
|
print("Warning: frame.reformat failed; skipping frame")
|
||||||
|
continue
|
||||||
|
|
||||||
|
arr = out_frame.to_ndarray(format="rgb24")
|
||||||
|
try:
|
||||||
|
proc.stdin.write(arr.tobytes())
|
||||||
|
except BrokenPipeError:
|
||||||
|
print("BrokenPipeError while writing frame to ffmpeg; terminating loop")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print("Error while writing frame to ffmpeg:", e)
|
||||||
|
break
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print("Video worker error:", e)
|
||||||
|
finally:
|
||||||
|
print("Video worker ending")
|
||||||
|
p = ffmpeg_proc.get("proc")
|
||||||
|
if p:
|
||||||
|
try:
|
||||||
|
p.stdin.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
p.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
p.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ffmpeg_proc["proc"] = None
|
||||||
|
|
||||||
|
# schedule the video worker
|
||||||
|
asyncio.ensure_future(run_video())
|
||||||
|
|
||||||
|
@track.on("ended")
|
||||||
|
async def on_ended():
|
||||||
|
print("Track ended")
|
||||||
|
|
||||||
|
await pc.setRemoteDescription(offer)
|
||||||
|
answer = await pc.createAnswer()
|
||||||
|
await pc.setLocalDescription(answer)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"sdp": pc.localDescription.sdp, "type": pc.localDescription.type}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_shutdown(app):
|
||||||
|
coros = [pc.close() for pc in pcs]
|
||||||
|
await asyncio.gather(*coros)
|
||||||
|
pcs.clear()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--host", default="0.0.0.0")
|
||||||
|
parser.add_argument("--port", type=int, default=8443)
|
||||||
|
parser.add_argument("--cert", required=True, help="TLS cert (PEM)")
|
||||||
|
parser.add_argument("--key", required=True, help="TLS key (PEM)")
|
||||||
|
parser.add_argument("--video-device", default="/dev/video2", help="v4l2loopback device")
|
||||||
|
parser.add_argument("--width", type=int, dest="width", help="Force output width (optional)")
|
||||||
|
parser.add_argument("--height", type=int, dest="height", help="Force output height (optional)")
|
||||||
|
parser.add_argument("--fps", type=int, dest="fps", default=30, help="Output framerate (default 30)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not os.path.exists(args.video_device):
|
||||||
|
print(f"Warning: video device {args.video_device} does not exist yet. Make sure v4l2loopback is loaded.")
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app["video_device"] = args.video_device
|
||||||
|
app["force_width"] = args.width
|
||||||
|
app["force_height"] = args.height
|
||||||
|
app["force_fps"] = args.fps
|
||||||
|
|
||||||
|
app.router.add_get("/", index)
|
||||||
|
app.router.add_post("/offer", offer)
|
||||||
|
app.router.add_static("/static/", ROOT, show_index=False)
|
||||||
|
|
||||||
|
async def manifest_handler(request):
|
||||||
|
return web.FileResponse(os.path.join(ROOT, "manifest.json"))
|
||||||
|
async def sw_handler(request):
|
||||||
|
return web.FileResponse(os.path.join(ROOT, "sw.js"))
|
||||||
|
app.router.add_get("/manifest.json", manifest_handler)
|
||||||
|
app.router.add_get("/sw.js", sw_handler)
|
||||||
|
|
||||||
|
app.on_shutdown.append(on_shutdown)
|
||||||
|
|
||||||
|
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||||
|
ssl_context.load_cert_chain(args.cert, args.key)
|
||||||
|
|
||||||
|
web.run_app(app, host=args.host, port=args.port, ssl_context=ssl_context)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Phone as Webcam (PWA)</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<style>
|
||||||
|
html,body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #000; /* black background per request */
|
||||||
|
color: #fff;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.1rem; margin: 0 0 0.5rem 0; color: #fff; }
|
||||||
|
video { width: 100%; max-width: 640px; background: #000; display: block; border-radius: 6px; }
|
||||||
|
.controls { margin-top: 0.5rem; display:flex; gap:0.5rem; flex-wrap:wrap; }
|
||||||
|
button {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
button:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
#status { margin-top: 0.6rem; color: #bbb; font-size: 0.92rem; }
|
||||||
|
/* overlay used to "turn screen off" -- pure black */
|
||||||
|
#offOverlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: #000;
|
||||||
|
z-index: 9999;
|
||||||
|
display: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
/* when overlay is active, hide UI but keep it accessible via tap */
|
||||||
|
.ui-hidden .container { visibility: hidden; } /* keep layout but visually hidden */
|
||||||
|
.ui-hidden #offOverlay { display: block; }
|
||||||
|
/* small helper so when UI hidden, status bar is still black on some devices */
|
||||||
|
meta[name=theme-color] { }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="offOverlay" role="button" aria-label="Tap to restore"></div>
|
||||||
|
|
||||||
|
<div class="container" id="app">
|
||||||
|
<h1>Use this device as a webcam</h1>
|
||||||
|
|
||||||
|
<video id="local" autoplay playsinline muted></video>
|
||||||
|
|
||||||
|
<div class="controls" id="controls">
|
||||||
|
<button id="start">Start streaming</button>
|
||||||
|
<button id="stop" disabled>Stop</button>
|
||||||
|
<button id="switchCam" disabled>Switch camera</button>
|
||||||
|
<button id="turnOff">Turn screen off</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="status">Not connected</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Basic PWA service worker registration
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(e => {
|
||||||
|
console.warn('Service worker registration failed:', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const startButton = document.getElementById('start');
|
||||||
|
const stopButton = document.getElementById('stop');
|
||||||
|
const switchButton = document.getElementById('switchCam');
|
||||||
|
const turnOffButton = document.getElementById('turnOff');
|
||||||
|
const localVideo = document.getElementById('local');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const offOverlay = document.getElementById('offOverlay');
|
||||||
|
const appEl = document.getElementById('app');
|
||||||
|
let pc = null;
|
||||||
|
let localStream = null;
|
||||||
|
let videoSender = null;
|
||||||
|
let currentFacing = 'user'; // 'user' or 'environment'
|
||||||
|
|
||||||
|
async function getCameraStream(facing) {
|
||||||
|
const constraints = {
|
||||||
|
video: {
|
||||||
|
facingMode: { ideal: facing }, // 'user' or 'environment'
|
||||||
|
width: { ideal: 1920 },
|
||||||
|
height: { ideal: 1080 },
|
||||||
|
},
|
||||||
|
audio: false
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
return await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
} catch (err) {
|
||||||
|
// fallback: try without facingMode constraints
|
||||||
|
console.warn('getUserMedia with facingMode failed, trying generic video:', err);
|
||||||
|
return await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startButton.onclick = async () => {
|
||||||
|
startButton.disabled = true;
|
||||||
|
status.textContent = 'Getting camera...';
|
||||||
|
try {
|
||||||
|
localStream = await getCameraStream(currentFacing);
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = 'getUserMedia error: ' + e;
|
||||||
|
startButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localVideo.srcObject = localStream;
|
||||||
|
|
||||||
|
pc = new RTCPeerConnection();
|
||||||
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
console.log('ICE', pc.iceConnectionState);
|
||||||
|
if (pc.iceConnectionState === 'failed') pc.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
// add tracks and keep video sender reference so we can replaceTrack later
|
||||||
|
localStream.getTracks().forEach(track => {
|
||||||
|
const sender = pc.addTrack(track, localStream);
|
||||||
|
if (track.kind === 'video') videoSender = sender;
|
||||||
|
});
|
||||||
|
|
||||||
|
status.textContent = 'Creating offer...';
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
|
||||||
|
status.textContent = 'Sending offer to server...';
|
||||||
|
const resp = await fetch('/offer', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(pc.localDescription),
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
const answer = await resp.json();
|
||||||
|
await pc.setRemoteDescription(answer);
|
||||||
|
|
||||||
|
status.textContent = 'Streaming to server';
|
||||||
|
stopButton.disabled = false;
|
||||||
|
switchButton.disabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
stopButton.onclick = () => {
|
||||||
|
if (localStream) {
|
||||||
|
localStream.getTracks().forEach(t => t.stop());
|
||||||
|
localStream = null;
|
||||||
|
}
|
||||||
|
if (pc) {
|
||||||
|
try {
|
||||||
|
pc.getSenders().forEach(s => { if (s.track) s.track.stop(); });
|
||||||
|
pc.close();
|
||||||
|
} catch (e) { console.warn(e); }
|
||||||
|
pc = null;
|
||||||
|
}
|
||||||
|
localVideo.srcObject = null;
|
||||||
|
status.textContent = 'Stopped';
|
||||||
|
startButton.disabled = false;
|
||||||
|
stopButton.disabled = true;
|
||||||
|
switchButton.disabled = true;
|
||||||
|
videoSender = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Switch camera: obtains new track and replaces the outgoing sender's track
|
||||||
|
switchButton.onclick = async () => {
|
||||||
|
if (!pc || !videoSender) {
|
||||||
|
status.textContent = 'Not streaming';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = 'Switching camera...';
|
||||||
|
// toggle facing
|
||||||
|
currentFacing = currentFacing === 'user' ? 'environment' : 'user';
|
||||||
|
try {
|
||||||
|
// get new stream first
|
||||||
|
const newStream = await getCameraStream(currentFacing);
|
||||||
|
const newVideoTrack = newStream.getVideoTracks()[0];
|
||||||
|
|
||||||
|
// replace the outgoing track first (minimize race)
|
||||||
|
await videoSender.replaceTrack(newVideoTrack);
|
||||||
|
|
||||||
|
// stop old local preview tracks after replacement
|
||||||
|
if (localStream) localStream.getTracks().forEach(t => t.stop());
|
||||||
|
|
||||||
|
// update preview and localStream reference
|
||||||
|
localStream = newStream;
|
||||||
|
localVideo.srcObject = newStream;
|
||||||
|
|
||||||
|
status.textContent = 'Switched camera';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Switch camera error', e);
|
||||||
|
status.textContent = 'Switch camera failed: ' + e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Turn screen off: hide UI and show black overlay; tapping overlay restores UI
|
||||||
|
turnOffButton.onclick = () => {
|
||||||
|
document.body.classList.add('ui-hidden');
|
||||||
|
// attempt to dim screen / request fullscreen if user desires later (not automatic)
|
||||||
|
// overlay is visible and will handle taps
|
||||||
|
};
|
||||||
|
|
||||||
|
// restore UI on any tap on overlay (touchstart/click)
|
||||||
|
function restoreUI() {
|
||||||
|
document.body.classList.remove('ui-hidden');
|
||||||
|
}
|
||||||
|
offOverlay.addEventListener('click', restoreUI, { passive: true });
|
||||||
|
offOverlay.addEventListener('touchstart', (e) => { e.preventDefault(); restoreUI(); }, { passive: false });
|
||||||
|
|
||||||
|
// Optional: make short tap also act as toggling overlay
|
||||||
|
// Keep a small safety: long press won't wake up
|
||||||
|
// No-op for now.
|
||||||
|
|
||||||
|
// Show install hint if available (small UX improvement)
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
// Prevent automatic prompt; we could show a custom UI later
|
||||||
|
e.preventDefault();
|
||||||
|
console.log('beforeinstallprompt fired');
|
||||||
|
// If desired, you could keep 'e' and call e.prompt() when the user chooses to install
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Phone as Webcam",
|
||||||
|
"short_name": "WebCamPhone",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#000000",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"description": "Use your phone as a webcam for Linux via WebRTC -> v4l2loopback",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Minimal service worker: cache the app shell so site is quickly available and installable.
|
||||||
|
const CACHE_NAME = 'phone-webcam-v1';
|
||||||
|
const FILES_TO_CACHE = [
|
||||||
|
'/',
|
||||||
|
'/manifest.json',
|
||||||
|
'/static/index.html',
|
||||||
|
'/static/index.html', // duplicated intentionally harmless
|
||||||
|
'/static/icon-192.png'
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (evt) => {
|
||||||
|
evt.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then(cache => cache.addAll(FILES_TO_CACHE))
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (evt) => {
|
||||||
|
evt.waitUntil(
|
||||||
|
caches.keys().then(keys => Promise.all(
|
||||||
|
keys.map(k => { if (k !== CACHE_NAME) return caches.delete(k); })
|
||||||
|
))
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (evt) => {
|
||||||
|
// network-first for /offer (dynamic), cache-first for app shell assets
|
||||||
|
const url = new URL(evt.request.url);
|
||||||
|
if (url.pathname === '/offer') {
|
||||||
|
// passthrough to network
|
||||||
|
evt.respondWith(fetch(evt.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
evt.respondWith(
|
||||||
|
caches.match(evt.request).then(cached => cached || fetch(evt.request).then(resp => {
|
||||||
|
// optionally cache dynamic responses here
|
||||||
|
return resp;
|
||||||
|
}).catch(() => {
|
||||||
|
// fallback could be a blank response for image or offline page
|
||||||
|
return caches.match('/');
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user