fix(desktop): enable click-to-play YouTube embeds + CISA feed fixes (#476)

* fix(tech): use rss() for CISA feed, drop build from pre-push hook

- CISA Advisories used dead rss.worldmonitor.app domain (404), switch to rss() helper
- Remove Vite build from pre-push hook (tsc already catches errors)

* fix(desktop): enable click-to-play for YouTube embeds in WKWebView

WKWebView blocks programmatic autoplay in cross-origin iframes regardless
of allow attributes, Permissions-Policy, mute-first retries, or secure
context. Documented all 10 approaches tested in docs/internal/.

Changes:
- Switch sidecar embed origin from 127.0.0.1 to localhost (secure context)
- Add MutationObserver + retry chain as best-effort autoplay attempts
- Use postMessage('*') to fix tauri://localhost cross-origin messaging
- Make sidecar play overlay non-interactive (pointer-events:none)
- Fix .webcam-iframe pointer-events:none blocking clicks in grid view
- Add expand button to grid cells for switching to single view on desktop
- Add http://localhost:* to CSP frame-src in index.html and tauri.conf.json
This commit is contained in:
Elie Habib
2026-02-27 22:02:06 +04:00
committed by GitHub
parent e71e7945b6
commit 4a0f0f02bd
7 changed files with 184 additions and 19 deletions

View File

@@ -0,0 +1,128 @@
# YouTube Autoplay on Tauri Desktop (WKWebView) — Cannot Work
**Status**: Confirmed impossible. All JS/CSS approaches exhausted.
**Date**: February 2026
**Affects**: Live News panel, Live Webcams panel (macOS desktop app)
**Root cause**: WKWebView blocks programmatic media playback in cross-origin iframes without a direct user gesture.
---
## Architecture
```
tauri://localhost (main webview)
└── http://localhost:PORT (sidecar youtube-embed handler)
└── https://www.youtube.com/embed/VIDEO_ID (YouTube IFrame Player)
```
Three nested origins. The YouTube player sits inside a sidecar-served HTML page, which sits inside the Tauri WKWebView. The user gesture context is lost crossing each origin boundary.
---
## What Was Tested (All Failed)
### 1. `allow="autoplay"` iframe attribute
**What**: Set `allow="autoplay; encrypted-media"` on the sidecar's `<iframe>` embedding YouTube.
**Why it fails**: WKWebView does not honor the `allow` attribute for media autoplay policy. This attribute works in Chromium-based browsers but is ignored by WebKit's media policy engine.
### 2. MutationObserver to patch iframe attributes
**What**: Used a `MutationObserver` in the sidecar embed HTML to watch for YT.Player's dynamically-created `<iframe>` and force `allow="autoplay"` onto it at creation time.
**Result**: The attribute was successfully added (confirmed via console logs), but WKWebView's autoplay policy operates at a layer below HTML attributes — it checks the media element's playback context, not iframe permissions.
### 3. Mute-first retry chain
**What**: After `onReady`, immediately call `player.mute()` + `player.playVideo()`, then retry at 500ms and 1500ms intervals.
**Why it fails**: In Chromium, muted autoplay is always allowed. In WKWebView, even muted playback requires a user gesture in cross-origin iframe contexts. `playVideo()` silently does nothing — no error thrown, no state change.
### 4. `Permissions-Policy: autoplay=*` response header
**What**: Added `Permissions-Policy: autoplay=*, encrypted-media=*` to the sidecar's HTTP response for the embed page.
**Why it fails**: `Permissions-Policy` is a Chromium feature. WebKit/WKWebView does not implement this header for media autoplay decisions.
### 5. Secure context via `http://localhost` (vs `http://127.0.0.1`)
**What**: Per W3C spec, `http://localhost` is a secure context while `http://127.0.0.1` is not. Changed all sidecar URLs from `127.0.0.1` to `localhost` hoping secure context would unlock autoplay.
**Result**: No effect. WKWebView's autoplay restriction is orthogonal to secure context status. The policy is about user gesture propagation, not HTTPS/secure origin.
### 6. YouTube playerVars configuration
**What**: Set all relevant playerVars: `autoplay: 1`, `mute: 1`, `playsinline: 1`, `enablejsapi: 1`, plus `origin` and `widget_referrer` matching the sidecar origin.
**Result**: YouTube's player respects these in Chromium. In WKWebView, the underlying `<video>` element's `play()` call is what gets blocked — playerVars just configure what YouTube *attempts* to do, not what WKWebView *allows*.
### 7. `player.playVideo()` from various timing contexts
**What**: Called `playVideo()` from:
- `onReady` callback (immediate)
- `setTimeout` at 500ms, 1500ms, 2000ms
- After `player.mute()`
**Why it fails**: None of these run in a user gesture context. JavaScript's event loop loses gesture propagation after any async boundary (`setTimeout`, `Promise.then`, `await`). The `onReady` callback itself is fired asynchronously by YouTube's API.
### 8. Iframe reload fallback
**What**: After 2 seconds, if autoplay hasn't started (no `YT.PlayerState.PLAYING` or `BUFFERING` event), destroy and recreate the iframe.
**Result**: The fresh iframe loads but still cannot autoplay — same policy applies. Reloading doesn't create a user gesture.
### 9. wry/WKWebView configuration check
**What**: Verified that wry 0.54.2 (Tauri's WebKit wrapper) sets `mediaTypesRequiringUserActionForPlayback = []` (equivalent to `.none`), meaning autoplay should be allowed at the WKWebView level.
**Result**: This setting works for **same-origin** content. It does NOT override the cross-origin iframe media policy. The YouTube embed is cross-origin (`youtube.com` vs `localhost`), so the WKWebView-level permission is insufficient.
### 10. Play overlay with click handler
**What**: Added a full-screen overlay div inside the sidecar embed. On click, called `player.playVideo()` + `player.unMute()`.
**Result**: This DOES work — the click is a genuine user gesture. However, it requires the user to click inside the sidecar iframe first, and the gesture doesn't propagate to the cross-origin YouTube iframe underneath. The overlay approach only works if the overlay itself triggers `playVideo()` via the JS API (which does work from a user gesture).
---
## Why It's a Platform Limitation
WKWebView's media autoplay policy:
1. **Same-origin content**: Respects `mediaTypesRequiringUserActionForPlayback` setting. If set to `.none`, autoplay works.
2. **Cross-origin iframes**: Requires a **user gesture that originates within the iframe's own browsing context**. A gesture in the parent frame does NOT propagate. This is a WebKit security decision, not a bug.
3. **Gesture propagation**: JavaScript loses user gesture context after any async operation (`await`, `setTimeout`, `Promise`). Even `onReady` is async.
There is an open Tauri issue (#13200) requesting exposure of `mediaTypesRequiringUserActionForPlayback` per-webview, but even with it exposed, it wouldn't help for cross-origin iframes.
The only way autoplay would work is if YouTube's embed was served from the **same origin** as the WKWebView — which would mean serving it from `tauri://localhost`, which is impossible since YouTube's player must load from `youtube.com`.
---
## What Works Instead
### Click-to-play (current solution)
- Grid view iframes have `pointer-events: auto` — user clicks YouTube's native play button directly
- Sidecar overlay starts hidden and non-interactive (`pointer-events: none`)
- Each grid cell has an expand button (arrow icon) to switch to single/fullscreen view
- YouTube's own play button UI handles the user gesture natively
### Why Vercel-hosted embeds worked before
The previous architecture served embeds from `https://worldmonitor.app/api/youtube/embed`. The parent was also `https://worldmonitor.app`. **Same origin** = WKWebView's autoplay policy respected the `.none` setting. When we moved embeds to the local sidecar (`http://localhost:PORT`), the parent became `tauri://localhost` — now cross-origin, breaking autoplay.
**Trade-off**: Reverting to Vercel-hosted embeds would restore autoplay but adds a cloud dependency for the desktop app (defeats offline/local-first goals) and adds latency.
---
## Files Changed
| File | Change |
|------|--------|
| `src-tauri/sidecar/local-api-server.mjs` | MutationObserver, retry chain, overlay fixes, `postMessage('*')`, `Permissions-Policy` header |
| `src/styles/main.css` | `.webcam-iframe { pointer-events: auto }` (was `none`), expand button styles |
| `src/components/LiveWebcamsPanel.ts` | Expand button in grid cells, `localhost` embed URLs |
| `src/components/LiveNewsPanel.ts` | `localhost` embed URLs, `embedOrigin` getter |
| `index.html` | CSP `frame-src` added `http://localhost:*` |
| `src-tauri/tauri.conf.json` | CSP `frame-src` added `http://localhost:*` |
---
## Future Options (Not Pursued)
1. **Tauri custom protocol for YouTube proxy**: Serve YouTube embed HTML from `tauri://` scheme to make it same-origin. Would require proxying YouTube's iframe API JS — legally and technically complex.
2. **Native AVPlayer**: Use a native macOS media player instead of WKWebView for video. Would lose YouTube's player UI/controls.
3. **Electron migration**: Electron uses Chromium where muted autoplay always works. Not viable — Tauri chosen deliberately.
4. **Revert to Vercel embeds on desktop**: Adds cloud dependency but restores autoplay. Could be a user-toggleable setting.

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:* https://worldmonitor.app https://tech.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:* http://localhost:* https://worldmonitor.app https://tech.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<!-- Primary Meta Tags -->

View File

@@ -934,9 +934,9 @@ async function dispatch(requestUrl, req, routes, context) {
const autoplay = requestUrl.searchParams.get('autoplay') === '0' ? '0' : '1';
const mute = requestUrl.searchParams.get('mute') === '0' ? '0' : '1';
const vq = ['small','medium','large','hd720','hd1080'].includes(requestUrl.searchParams.get('vq') || '') ? requestUrl.searchParams.get('vq') : '';
const origin = `http://127.0.0.1:${context.port}`;
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>html,body{margin:0;padding:0;width:100%;height:100%;background:#000;overflow:hidden}#player{width:100%;height:100%}#play-overlay{position:absolute;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;cursor:pointer;background:rgba(0,0,0,0.4)}#play-overlay svg{width:72px;height:72px;opacity:0.9;filter:drop-shadow(0 2px 8px rgba(0,0,0,0.5))}#play-overlay.hidden{display:none}</style></head><body><div id="player"></div><div id="play-overlay"><svg viewBox="0 0 68 48"><path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="red"/><path d="M45 24L27 14v20" fill="#fff"/></svg></div><script>var tag=document.createElement('script');tag.src='https://www.youtube.com/iframe_api';document.head.appendChild(tag);var player,overlay=document.getElementById('play-overlay'),started=false,parentOrigin='${origin}';function hideOverlay(){overlay.classList.add('hidden')}function onYouTubeIframeAPIReady(){player=new YT.Player('player',{videoId:'${videoId}',host:'https://www.youtube.com',playerVars:{autoplay:${autoplay},mute:${mute},playsinline:1,rel:0,controls:1,modestbranding:1,enablejsapi:1,origin:'${origin}',widget_referrer:'${origin}'},events:{onReady:function(){window.parent.postMessage({type:'yt-ready'},parentOrigin);${vq ? `if(player.setPlaybackQuality)player.setPlaybackQuality('${vq}');` : ''}if(${autoplay}===1){player.playVideo()}},onError:function(e){window.parent.postMessage({type:'yt-error',code:e.data},parentOrigin)},onStateChange:function(e){window.parent.postMessage({type:'yt-state',state:e.data},parentOrigin);if(e.data===1||e.data===3){hideOverlay();started=true}}}})}overlay.addEventListener('click',function(){if(player&&player.playVideo){player.playVideo();player.unMute();hideOverlay()}});setTimeout(function(){if(!started)overlay.classList.remove('hidden')},3000);window.addEventListener('message',function(e){if(e.origin!==parentOrigin)return;if(!player||!player.getPlayerState)return;var m=e.data;if(!m||!m.type)return;switch(m.type){case'play':player.playVideo();break;case'pause':player.pauseVideo();break;case'mute':player.mute();break;case'unmute':player.unMute();break;case'loadVideo':if(m.videoId)player.loadVideoById(m.videoId);break;case'setQuality':if(m.quality&&player.setPlaybackQuality)player.setPlaybackQuality(m.quality);break}})<\/script></body></html>`;
return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...makeCorsHeaders(req) } });
const origin = `http://localhost:${context.port}`;
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="strict-origin-when-cross-origin"><style>html,body{margin:0;padding:0;width:100%;height:100%;background:#000;overflow:hidden}#player{width:100%;height:100%}#play-overlay{position:absolute;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;pointer-events:none;background:rgba(0,0,0,0.15)}#play-overlay svg{width:72px;height:72px;opacity:0.9;filter:drop-shadow(0 2px 8px rgba(0,0,0,0.5))}#play-overlay.hidden{display:none}</style></head><body><div id="player"></div><div id="play-overlay" class="hidden"><svg viewBox="0 0 68 48"><path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="red"/><path d="M45 24L27 14v20" fill="#fff"/></svg></div><script>var tag=document.createElement('script');tag.src='https://www.youtube.com/iframe_api';document.head.appendChild(tag);var player,overlay=document.getElementById('play-overlay'),started=false,muteSyncId,retryTimers=[];var obs=new MutationObserver(function(muts){for(var i=0;i<muts.length;i++){var nodes=muts[i].addedNodes;for(var j=0;j<nodes.length;j++){if(nodes[j].tagName==='IFRAME'){var a=nodes[j].getAttribute('allow')||'';if(a.indexOf('autoplay')===-1){nodes[j].setAttribute('allow','autoplay; encrypted-media; picture-in-picture '+a);console.log('[yt-embed] patched iframe allow=autoplay')}obs.disconnect();return}}}});obs.observe(document.getElementById('player'),{childList:true,subtree:true});function hideOverlay(){overlay.classList.add('hidden')}function readMuted(){if(!player)return null;if(typeof player.isMuted==='function')return player.isMuted();if(typeof player.getVolume==='function')return player.getVolume()===0;return null}function stopMuteSync(){if(muteSyncId){clearInterval(muteSyncId);muteSyncId=null}}function startMuteSync(){if(muteSyncId)return;var last=readMuted();if(last!==null)window.parent.postMessage({type:'yt-mute-state',muted:last},'*');muteSyncId=setInterval(function(){var m=readMuted();if(m!==null&&m!==last){last=m;window.parent.postMessage({type:'yt-mute-state',muted:m},'*')}},500)}function tryAutoplay(){if(!player||!player.playVideo)return;try{player.mute();player.playVideo();console.log('[yt-embed] tryAutoplay: mute+play')}catch(e){}}function onYouTubeIframeAPIReady(){player=new YT.Player('player',{videoId:'${videoId}',host:'https://www.youtube.com',playerVars:{autoplay:${autoplay},mute:${mute},playsinline:1,rel:0,controls:1,modestbranding:1,enablejsapi:1,origin:'${origin}',widget_referrer:'${origin}'},events:{onReady:function(){console.log('[yt-embed] onReady');window.parent.postMessage({type:'yt-ready'},'*');${vq ? `if(player.setPlaybackQuality)player.setPlaybackQuality('${vq}');` : ''}if(${autoplay}===1){tryAutoplay();retryTimers.push(setTimeout(function(){if(!started)tryAutoplay()},500));retryTimers.push(setTimeout(function(){if(!started)tryAutoplay()},1500));retryTimers.push(setTimeout(function(){if(!started){console.log('[yt-embed] autoplay failed after retries');window.parent.postMessage({type:'yt-autoplay-failed'},'*')}},2500))}startMuteSync()},onError:function(e){console.log('[yt-embed] error code='+e.data);stopMuteSync();window.parent.postMessage({type:'yt-error',code:e.data},'*')},onStateChange:function(e){window.parent.postMessage({type:'yt-state',state:e.data},'*');if(e.data===1||e.data===3){hideOverlay();started=true;retryTimers.forEach(clearTimeout);retryTimers=[]}}}})}setTimeout(function(){if(!started)overlay.classList.remove('hidden')},4000);window.addEventListener('message',function(e){if(!player||!player.getPlayerState)return;var m=e.data;if(!m||!m.type)return;switch(m.type){case'play':player.playVideo();break;case'pause':player.pauseVideo();break;case'mute':player.mute();break;case'unmute':player.unMute();break;case'loadVideo':if(m.videoId)player.loadVideoById(m.videoId);break;case'setQuality':if(m.quality&&player.setPlaybackQuality)player.setPlaybackQuality(m.quality);break}});window.addEventListener('beforeunload',function(){stopMuteSync();obs.disconnect();retryTimers.forEach(clearTimeout)})<\/script></body></html>`;
return new Response(html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'permissions-policy': 'autoplay=*, encrypted-media=*', ...makeCorsHeaders(req) } });
}
// ── Global auth gate ────────────────────────────────────────────────────

View File

@@ -29,7 +29,7 @@
}
],
"security": {
"csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:* ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:* https://worldmonitor.app https://tech.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;"
"csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:* ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com https://us-assets.i.posthog.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:* http://localhost:* https://worldmonitor.app https://tech.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;"
}
},
"bundle": {

View File

@@ -1,6 +1,6 @@
import { Panel } from './Panel';
import { fetchLiveVideoInfo } from '@/services/live-news';
import { isDesktopRuntime, getRemoteApiBaseUrl, getApiBaseUrl } from '@/services/runtime';
import { isDesktopRuntime, getRemoteApiBaseUrl, getApiBaseUrl, getLocalApiPort } from '@/services/runtime';
import { t } from '../services/i18n';
import { loadFromStorage, saveToStorage } from '@/utils';
import { STORAGE_KEYS, SITE_VARIANT } from '@/config';
@@ -356,6 +356,7 @@ export class LiveNewsPanel extends Panel {
}
private get embedOrigin(): string {
if (isDesktopRuntime()) return `http://localhost:${getLocalApiPort()}`;
try { return new URL(getRemoteApiBaseUrl()).origin; } catch { return 'https://worldmonitor.app'; }
}
@@ -907,7 +908,7 @@ export class LiveNewsPanel extends Panel {
mute: this.isMuted ? '1' : '0',
});
if (quality !== 'auto') params.set('vq', quality);
const embedUrl = `${getApiBaseUrl()}/api/youtube-embed?${params.toString()}`;
const embedUrl = `http://localhost:${getLocalApiPort()}/api/youtube-embed?${params.toString()}`;
if (renderToken !== this.desktopEmbedRenderToken) {
return;

View File

@@ -1,5 +1,5 @@
import { Panel } from './Panel';
import { isDesktopRuntime, getApiBaseUrl } from '@/services/runtime';
import { isDesktopRuntime, getLocalApiPort } from '@/services/runtime';
import { escapeHtml } from '@/utils/sanitize';
import { t } from '../services/i18n';
import { trackWebcamSelected, trackWebcamRegionFiltered } from '@/services/analytics';
@@ -167,7 +167,7 @@ export class LiveWebcamsPanel extends Panel {
// The sidecar serves the embed from http://127.0.0.1:PORT which YouTube accepts.
const params = new URLSearchParams({ videoId, autoplay: '1', mute: '1' });
if (quality !== 'auto') params.set('vq', quality);
return `${getApiBaseUrl()}/api/youtube-embed?${params.toString()}`;
return `http://localhost:${getLocalApiPort()}/api/youtube-embed?${params.toString()}`;
}
const vq = quality !== 'auto' ? `&vq=${quality}` : '';
return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&controls=0&modestbranding=1&playsinline=1&rel=0${vq}`;
@@ -216,16 +216,33 @@ export class LiveWebcamsPanel extends Panel {
feeds.forEach((feed, i) => {
const cell = document.createElement('div');
cell.className = 'webcam-cell';
cell.addEventListener('click', () => {
trackWebcamSelected(feed.id, feed.city, 'grid');
this.activeFeed = feed;
this.setViewMode('single');
});
const label = document.createElement('div');
label.className = 'webcam-cell-label';
label.innerHTML = `<span class="webcam-live-dot"></span><span class="webcam-city">${escapeHtml(feed.city.toUpperCase())}</span>`;
if (desktop) {
// On desktop, clicks pass through label (pointer-events:none in CSS)
// to YouTube iframe so users click play directly. Add expand button.
const expandBtn = document.createElement('button');
expandBtn.className = 'webcam-expand-btn';
expandBtn.title = t('webcams.expand') || 'Expand';
expandBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
expandBtn.addEventListener('click', (e) => {
e.stopPropagation();
trackWebcamSelected(feed.id, feed.city, 'grid');
this.activeFeed = feed;
this.setViewMode('single');
});
label.appendChild(expandBtn);
} else {
cell.addEventListener('click', () => {
trackWebcamSelected(feed.id, feed.city, 'grid');
this.activeFeed = feed;
this.setViewMode('single');
});
}
cell.appendChild(label);
grid.appendChild(cell);

View File

@@ -2005,6 +2005,29 @@ canvas,
pointer-events: none;
}
.webcam-expand-btn {
pointer-events: auto;
margin-left: auto;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 3px;
color: #fff;
padding: 2px 4px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
line-height: 1;
}
.webcam-cell:hover .webcam-expand-btn {
opacity: 0.7;
}
.webcam-expand-btn:hover {
opacity: 1 !important;
background: rgba(255, 255, 255, 0.15);
}
.webcam-city {
font-family: var(--font-mono);
font-size: 10px;
@@ -2028,7 +2051,7 @@ canvas,
height: 100%;
border: 0;
display: block;
pointer-events: none;
pointer-events: auto;
}
.webcam-single {
@@ -2037,10 +2060,6 @@ canvas,
aspect-ratio: 16 / 9;
}
.webcam-single .webcam-iframe {
pointer-events: auto;
}
.webcam-switcher {
display: flex;
gap: 4px;