mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-27 05:02:08 +00:00
Nettoyage massif côté frontend, grosse passe perf, app mobile v2.5.3
(écran qui ne s'éteint plus + fix build), extracteur Uqload HLS sur
extensions/userscript, redirection réseau-bloqué via passerelle
explicative et cast cross-receiver/cross-browser.
Nettoyage Firebase & code mort
- Drop firebase + @firebase/firestore (package.json + vite.config
optimizeDeps + alias)
- Suppression src/config/firebase.ts (jamais utilisé)
- Suppression services morts : adminService, commentService, logService,
notificationService + composants liés (AdminLogin, CommentSection,
CommentItem, ReactionBar) + types/Comment.ts
- Suppression scripts non-utilisés : bundle-report.mjs, publish-app.mjs
- Net : -3 449 lignes
Perf carrousels & TMDB images
- useTmdbImages : 1 seul fetch TMDB pour logo + poster (au lieu de 2),
prefetch idle au mount du carousel + dedup via inflight map (0 doublon
entre prefetch et hooks), include_image_language=fr,en,null pour
récupérer les versions FR-prioritisées
- EmblaCarousel : pré-décode des posters off-DOM (img.decode) en idle
pour éliminer le coût de décode pendant le scroll horizontal, priority
cap statique (eager + fetchpriority=high pour les N premières cards
selon viewport), suppression du tracking visibleSlides (state churn)
- content-visibility:auto sur slides hors viewport (skip layout/paint
natif), exclu des slides Top 10 pour ne pas clipper le digit débordant
- will-change:transform pré-promotion compositing layer (PC only via
hover:hover)
- Embla duration 25 → 15 (snap plus rapide, moins de frames composées)
- Boutons nav carousel : suppression backdrop-blur (cher), bg opaque
- LazyImage : width/height attrs pour éviter CLS + alléger placeholder
Top progress bar (style YouTube)
- TopProgressBar : asymptote vers 85% pendant chunk loads, fill 100%
smooth + fade out à la fin, 120ms grace period pour suppress flash
sur chunks cachés
- lazyWithRetry tracke les loads actifs + dispatch chunk:load:start /
chunk:load:end events sur window
- Routes loaders supportent { silent: true } pour skip le tracking sur
les prefetch (PrefetchLink hover + IDLE_PREFETCH au mount)
SW redirect → passerelle movix.health explicative
- buildMirrorUrl() factorise la construction d'URL miroir avec contexte
- handleNavigation passe ?from=&reason=unreachable&error=&via= à la
gateway pour qu'elle affiche une popup explicative au user au lieu
d'un redirect silencieux
- MOVIX_FORCE_REDIRECT message handler passe reason=api-errors
- blockDetection.ts transmet la dernière erreur axios au SW
- index.html : cache-bust du Cast SDK URL (cb=Date.now()) pour éviter
qu'un SDK stale ne reste coincé en cache navigateur/SW
Cast (Chromecast + AirPlay + Remote Playback API)
- loadMediaOnCastWithFallback : retry sur 3 MIME HLS différents
(application/vnd.apple.mpegurl, x-mpegurl, x-mpegURL) — certains
receivers rejettent le premier choix selon firmware
- Subtitles externes attachés au cast (CastSubtitleTrack) via
chrome.cast.media.Track + TextTrackStyle par défaut (50% black
background, edge outline) lisible sur la majorité des TV
- isRemotePlaybackSupported : fallback W3C Remote Playback API pour
Firefox / Edge avec cast extension bloquée / Brave avec shields →
device discovery + picker via video.remote.prompt()
- initializeAirPlay supporte désormais les deux paths (WebKit AirPlay
pour Safari + Remote Playback pour les autres)
- Détection castSdkBlocked : si le SDK Cast ne charge pas en 5s
(adblock, FAI, shield), affiche un message d'erreur explicite au
lieu du générique "aucun device trouvé"
- 7 nouvelles strings i18n FR + EN pour les états cast :
castUnavailable, castUnavailableNoDevices,
castUnavailableUnsupportedBrowser, castUnavailableSdkBlocked,
castUnavailableHelpChromecast, castUnavailableHelpAirPlay,
castUnavailableSeeHelp
App mobile Android v2.5.3 (versionCode 12)
- AndroidManifest : android:keepScreenOn="true" sur MainActivity →
l'écran ne s'éteint plus pendant le visionnage (avant le user devait
toucher l'écran toutes les 30s pour pas que le téléphone se lock en
plein film)
- build.gradle : exclude androidx.legacy:legacy-support-core-utils
(tiré transitivement par mediarouter → palette) qui embarquait ses
propres androidx.autofill.R$attr → "duplicate class" au mergeDexRelease
contre le module androidx.autofill:autofill:1.1.0 d'appcompat
- gradle.properties : android.nonTransitiveRClass=true pour empêcher
chaque lib de regénérer ses R classes transitivement (même cause
racine du duplicate class)
- version.json bumped 2.5.2 → 2.5.3, nouvel APK (-17KB), release notes
FR : "Correction extraction du lecteur Uqload" + "L'écran ne s'éteint
plus lors de l'utilisation de l'app"
- apkUrl repointé sur le repo Movix-STMG (au lieu de movixcorp)
Userscript + Extensions Chrome/Firefox — Uqload HLS
- extractUqload : préfère désormais le master.m3u8 multi-bitrate (qualité
adaptative) au v.mp4 single-quality. Regex tente d'abord
/master\.m3u8/, sinon n'importe quel .m3u8, et fallback sur l'ancien
pattern v.mp4 si rien trouvé. Les 3 cibles (Chrome MV3, Firefox MV2,
Tampermonkey) sont synchronisées sur la même logique.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
452 lines
16 KiB
JavaScript
452 lines
16 KiB
JavaScript
// ============================================================================
|
|
// Fallback domain — constantes injectées au build par vite.config.ts
|
|
// ============================================================================
|
|
const DEFAULT_MIRRORS = __MOVIX_DEFAULT_MIRRORS__;
|
|
const CONFIG_URL = __MOVIX_CONFIG_URL__;
|
|
const NAV_TIMEOUT_MS = 3000;
|
|
const CONFIG_TIMEOUT_MS = 3000;
|
|
// Ping de confirmation sur un asset statique de l'origine. Volontairement
|
|
// généreux : sur mobile, sortir de veille peut prendre 3-5s (DNS + TLS +
|
|
// radio cellulaire qui se réveille). Si même ce ping fail, l'origine est
|
|
// vraiment injoignable.
|
|
const REACHABILITY_TIMEOUT_MS = 4000;
|
|
const REACHABILITY_PROBE_PATH = '/movix.png';
|
|
const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
|
|
|
// ============================================================================
|
|
// Image cache — TMDB images (posters, backdrops, logos)
|
|
// ============================================================================
|
|
//
|
|
// Stratégie : cache-first sur image.tmdb.org. Premier hit = network + cache,
|
|
// hits suivants = direct depuis CacheStorage (instantané, no network).
|
|
//
|
|
// Bonus : queue de concurrence côté SW, cap à 6 simultanés. Sans throttle,
|
|
// le navigateur peut déclencher 30+ fetchs parallèles au mount du home.
|
|
//
|
|
// Bump IMAGE_CACHE_NAME pour invalider toutes les images cachées d'un coup
|
|
// (ex. quand on change la taille standard w500→w342 — sinon on continue à
|
|
// servir les vieilles URLs pendant des semaines). v2 = passage à w342 posters
|
|
// + w300 logos.
|
|
const IMAGE_CACHE_NAME = 'movix-tmdb-images-v2';
|
|
const TMDB_IMAGE_HOST = 'image.tmdb.org';
|
|
const MAX_CONCURRENT_IMAGE_FETCHES = 6;
|
|
let activeImageFetches = 0;
|
|
const imageFetchQueue = [];
|
|
|
|
function acquireImageFetchSlot() {
|
|
return new Promise((resolve) => {
|
|
if (activeImageFetches < MAX_CONCURRENT_IMAGE_FETCHES) {
|
|
activeImageFetches++;
|
|
resolve();
|
|
} else {
|
|
imageFetchQueue.push(resolve);
|
|
}
|
|
});
|
|
}
|
|
|
|
function releaseImageFetchSlot() {
|
|
const next = imageFetchQueue.shift();
|
|
if (next) {
|
|
next();
|
|
} else {
|
|
activeImageFetches = Math.max(0, activeImageFetches - 1);
|
|
}
|
|
}
|
|
|
|
async function handleTmdbImage(req) {
|
|
const cache = await caches.open(IMAGE_CACHE_NAME);
|
|
const cached = await cache.match(req);
|
|
if (cached) return cached;
|
|
|
|
await acquireImageFetchSlot();
|
|
try {
|
|
const res = await fetch(req);
|
|
if (res && (res.ok || res.type === 'opaque')) {
|
|
// .clone() avant .put() : la response ne peut être consommée qu'une fois.
|
|
// .catch silently : QuotaExceededError quand storage full → on sert la
|
|
// réponse non-cachée à l'utilisateur, qui marche quand même.
|
|
cache.put(req, res.clone()).catch(() => {});
|
|
}
|
|
return res;
|
|
} finally {
|
|
releaseImageFetchSlot();
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helpers fallback domain
|
|
// ============================================================================
|
|
|
|
// Dev/LAN guard : on skip toute logique de redirect miroir quand le SW tourne
|
|
// sur localhost ou une IP privée. Sinon un backend absent en dev ou un HMR qui
|
|
// bouge déclenche des fetch failures et balance le dev sur le miroir prod.
|
|
function isLocalHost(hostname) {
|
|
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true;
|
|
if (hostname.endsWith('.localhost')) return true;
|
|
if (/^10\./.test(hostname)) return true;
|
|
if (/^192\.168\./.test(hostname)) return true;
|
|
if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return true;
|
|
return false;
|
|
}
|
|
|
|
function parseConfig(text) {
|
|
// Deux formats supportés :
|
|
// - JSON : {"mirrors":["movix.health",...]} (dpaste.org, gist raw, etc.)
|
|
// - HTML : page rendue rentry.co/<slug> — on extrait les hostnames des <a href>
|
|
// à l'intérieur du <article>. Rentry.co exige un access code pour /raw
|
|
// depuis un durcissement anti-abuse ; on parse le HTML rendu à la place.
|
|
let hostnames = [];
|
|
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
if (parsed && Array.isArray(parsed.mirrors)) {
|
|
hostnames = parsed.mirrors
|
|
.map((m) => (typeof m === 'string' ? m.trim().toLowerCase() : ''));
|
|
}
|
|
} catch {
|
|
const articleMatch = text.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i);
|
|
const scope = articleMatch ? articleMatch[1] : text;
|
|
const hrefRe = /href=["']https?:\/\/([^/"'\s?#]+)/gi;
|
|
const seen = new Set();
|
|
let match;
|
|
while ((match = hrefRe.exec(scope)) !== null) {
|
|
const host = match[1].trim().toLowerCase();
|
|
if (!seen.has(host)) {
|
|
seen.add(host);
|
|
hostnames.push(host);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filtre : format hostname valide + exclusion de rentry.co (lien canonique,
|
|
// CDN-cgi, footer, etc. qui peuvent se retrouver dans le scope si <article>
|
|
// n'est pas trouvé).
|
|
const mirrors = hostnames
|
|
.filter((h) => h.length > 0 && HOSTNAME_RE.test(h))
|
|
.filter((h) => h !== 'rentry.co' && !h.endsWith('.rentry.co'));
|
|
if (mirrors.length === 0) return null;
|
|
return { mirrors };
|
|
}
|
|
|
|
async function loadMirrors() {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), CONFIG_TIMEOUT_MS);
|
|
const res = await fetch(CONFIG_URL, { cache: 'no-store', signal: controller.signal });
|
|
clearTimeout(timer);
|
|
if (res.ok) {
|
|
const text = await res.text();
|
|
const config = parseConfig(text);
|
|
if (config) return config.mirrors;
|
|
}
|
|
} catch {}
|
|
return Array.isArray(DEFAULT_MIRRORS) ? DEFAULT_MIRRORS.slice() : [];
|
|
}
|
|
|
|
function pickNextMirror(mirrors, currentHost) {
|
|
const candidates = mirrors.filter((h) => h !== currentHost);
|
|
return candidates[0] || null;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function buildMirrorUrl(targetHost, { from, reason, error, via } = {}) {
|
|
const url = new URL(`https://${targetHost}/`);
|
|
if (from) url.searchParams.set('from', from);
|
|
if (reason) url.searchParams.set('reason', reason);
|
|
if (error) url.searchParams.set('error', String(error).slice(0, 100));
|
|
if (via) url.searchParams.set('via', via);
|
|
return url.href;
|
|
}
|
|
|
|
function renderRedirectPage(url) {
|
|
const safe = escapeHtml(url);
|
|
const html = `<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta http-equiv="refresh" content="0; url=${safe}">
|
|
<title>Movix — Redirection</title>
|
|
<link rel="canonical" href="${safe}">
|
|
<style>
|
|
html, body { margin: 0; padding: 0; height: 100%; background: #000; color: #fff;
|
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
display: grid; place-items: center; }
|
|
.wrap { text-align: center; padding: 1.5rem; max-width: 420px; }
|
|
.logo { font-size: 2rem; font-weight: 900; color: #dc2626; letter-spacing: 0.1em; margin-bottom: 1.5rem; }
|
|
.spinner { width: 40px; height: 40px; margin: 0 auto 1.5rem;
|
|
border: 3px solid rgba(255,255,255,.1); border-top-color: #dc2626;
|
|
border-radius: 50%; animation: spin 0.8s linear infinite; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
p { margin: 0.5rem 0; color: #aaa; font-size: 0.9rem; line-height: 1.5; }
|
|
a { color: #dc2626; text-decoration: none; font-weight: 600; }
|
|
a:hover { text-decoration: underline; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="logo">MOVIX</div>
|
|
<div class="spinner"></div>
|
|
<p>Redirection vers notre nouveau domaine…</p>
|
|
<p><a href="${safe}">Cliquer ici si rien ne se passe</a></p>
|
|
</div>
|
|
<script>
|
|
setTimeout(function () { window.location.replace(${JSON.stringify(url)}); }, 100);
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
return new Response(html, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Cache-Control': 'no-store',
|
|
},
|
|
});
|
|
}
|
|
|
|
function render503Page() {
|
|
const html = `<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Movix — Indisponible</title>
|
|
<style>
|
|
html, body { margin: 0; padding: 0; height: 100%; background: #000; color: #fff;
|
|
font-family: system-ui, -apple-system, sans-serif; display: grid; place-items: center; }
|
|
.wrap { text-align: center; padding: 1.5rem; max-width: 420px; }
|
|
.logo { font-size: 2rem; font-weight: 900; color: #dc2626; letter-spacing: 0.1em; margin-bottom: 1rem; }
|
|
h1 { font-size: 1.2rem; margin: 0 0 1rem; }
|
|
p { margin: 0.5rem 0; color: #aaa; font-size: 0.9rem; line-height: 1.5; }
|
|
a { display: inline-block; margin-top: 1rem; background: #229ED9; color: #fff;
|
|
padding: 0.7rem 1.2rem; border-radius: 0.5rem; text-decoration: none; font-weight: 600; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="logo">MOVIX</div>
|
|
<h1>Site temporairement indisponible</h1>
|
|
<p>Tous nos domaines connus sont inaccessibles depuis votre connexion.</p>
|
|
<p>Rejoins notre canal Telegram pour recevoir l'adresse du nouveau domaine.</p>
|
|
<a href="https://t.me/movix_site">Ouvrir Telegram</a>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
return new Response(html, {
|
|
status: 503,
|
|
headers: {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Cache-Control': 'no-store',
|
|
},
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Lifecycle
|
|
// ============================================================================
|
|
|
|
self.addEventListener('install', () => {
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
(async () => {
|
|
const keys = await caches.keys();
|
|
// Préserve le cache d'images courant ; supprime tout le reste (anciennes
|
|
// versions de cache, caches légacy d'avant cette logique). Quand on bumpe
|
|
// IMAGE_CACHE_NAME (ex. v1 → v2), l'ancienne version sera supprimée ici
|
|
// automatiquement.
|
|
await Promise.all(
|
|
keys
|
|
.filter((k) => k !== IMAGE_CACHE_NAME)
|
|
.map((k) => caches.delete(k))
|
|
);
|
|
await self.clients.claim();
|
|
})()
|
|
);
|
|
});
|
|
|
|
// ============================================================================
|
|
// Push notifications (préservé tel quel)
|
|
// ============================================================================
|
|
|
|
self.addEventListener('push', (event) => {
|
|
if (!event.data) return;
|
|
const data = event.data.json();
|
|
const baseUrl = self.location.origin;
|
|
event.waitUntil(
|
|
self.registration.showNotification(data.title || 'Movix', {
|
|
body: data.body || '',
|
|
icon: data.icon ? new URL(data.icon, baseUrl).href : `${baseUrl}/movix.png`,
|
|
badge: `${baseUrl}/movix.png`,
|
|
image: data.image || undefined,
|
|
data: data.data || {},
|
|
})
|
|
);
|
|
});
|
|
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
const { contentType, contentId } = event.notification.data || {};
|
|
let url = '/';
|
|
if (contentType && contentId) {
|
|
url = contentType === 'movie' ? `/movie/${contentId}` : `/tv/${contentId}`;
|
|
}
|
|
event.waitUntil(
|
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
|
for (const client of windowClients) {
|
|
if (client.url.includes(self.location.origin)) {
|
|
client.focus();
|
|
client.navigate(url);
|
|
return;
|
|
}
|
|
}
|
|
return clients.openWindow(url);
|
|
})
|
|
);
|
|
});
|
|
|
|
// ============================================================================
|
|
// Fetch — intercepte les navigations top-level pour fallback domain
|
|
// ============================================================================
|
|
|
|
// Ping de confirmation : l'origine répond-elle réellement ? Utilisé pour
|
|
// distinguer un VRAI blocage FAI (toute l'origine bloquée) d'un échec
|
|
// transient (mobile qui sort de veille, throttling de tab background, blip
|
|
// réseau). On vise un asset statique stable, cache: 'no-store' pour forcer
|
|
// un round-trip frais.
|
|
async function probeOriginOnce() {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), REACHABILITY_TIMEOUT_MS);
|
|
try {
|
|
const url = new URL(REACHABILITY_PROBE_PATH, self.location.origin).href;
|
|
const res = await fetch(`${url}?_swprobe=${Date.now()}`, {
|
|
method: 'HEAD',
|
|
cache: 'no-store',
|
|
signal: controller.signal,
|
|
credentials: 'omit',
|
|
redirect: 'manual',
|
|
});
|
|
clearTimeout(timer);
|
|
// 2xx, 3xx, 4xx = origine répond (même un 404 confirme que le serveur
|
|
// est joignable). Seul un échec réseau ou un 5xx massif = injoignable.
|
|
return res.status < 500;
|
|
} catch {
|
|
clearTimeout(timer);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Deux tentatives avant de conclure à un blocage. Un seul HEAD raté n'est
|
|
// pas un signal suffisant : un blip réseau ponctuel (perte de paquet, switch
|
|
// de cell tower, throttling court) peut le faire échouer. Si la 1ère échoue
|
|
// on attend 500ms et on retente — un VRAI blocage FAI est persistant et
|
|
// échouera les deux fois ; un blip transient laissera passer la 2ème.
|
|
async function isOriginReachable() {
|
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
return false;
|
|
}
|
|
const first = await probeOriginOnce();
|
|
if (first) return true;
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
return await probeOriginOnce();
|
|
}
|
|
|
|
async function handleNavigation(req) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), NAV_TIMEOUT_MS);
|
|
try {
|
|
const res = await fetch(req, { signal: controller.signal });
|
|
clearTimeout(timer);
|
|
return res;
|
|
} catch (err) {
|
|
clearTimeout(timer);
|
|
// Si l'utilisateur est offline, on laisse l'erreur réseau naturelle
|
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
throw err;
|
|
}
|
|
// Confirmation : l'origine est-elle vraiment injoignable ? Sinon (mobile
|
|
// qui se réveille, network blip, tab throttlé), on relaie l'erreur
|
|
// d'origine et on laisse le browser gérer (retry naturel, page d'erreur).
|
|
// On ne bascule au miroir QUE si même un HEAD simple échoue.
|
|
const reachable = await isOriginReachable();
|
|
if (reachable) {
|
|
throw err;
|
|
}
|
|
return await redirectToMirror({
|
|
from: self.location.hostname,
|
|
reason: 'unreachable',
|
|
error: `${err.name}: ${err.message}`,
|
|
via: 'sw-fetch',
|
|
});
|
|
}
|
|
}
|
|
|
|
async function redirectToMirror({ from, reason, error, via } = {}) {
|
|
const mirrors = await loadMirrors();
|
|
const target = pickNextMirror(mirrors, self.location.hostname);
|
|
if (!target) return render503Page();
|
|
const redirectUrl = buildMirrorUrl(target, { from, reason, error, via });
|
|
return renderRedirectPage(redirectUrl);
|
|
}
|
|
|
|
self.addEventListener('fetch', (event) => {
|
|
const req = event.request;
|
|
if (req.method !== 'GET') return;
|
|
|
|
// 1. TMDB image cache — intercepte tous les GET sur image.tmdb.org peu
|
|
// importe l'origine du SW. Pas de garde localhost ici : le cache est utile
|
|
// aussi en dev pour éviter de re-fetcher les mêmes posters à chaque reload.
|
|
let url;
|
|
try {
|
|
url = new URL(req.url);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (url.hostname === TMDB_IMAGE_HOST) {
|
|
event.respondWith(handleTmdbImage(req));
|
|
return;
|
|
}
|
|
|
|
// 2. Navigation fallback (logique existante — préservée)
|
|
if (req.mode !== 'navigate') return;
|
|
if (isLocalHost(self.location.hostname)) return;
|
|
event.respondWith(handleNavigation(req));
|
|
});
|
|
|
|
// ============================================================================
|
|
// Message — handle force-redirect trigger depuis la page (block detection)
|
|
// ============================================================================
|
|
|
|
self.addEventListener('message', async (event) => {
|
|
const data = event.data;
|
|
if (!data || data.type !== 'MOVIX_FORCE_REDIRECT') return;
|
|
if (isLocalHost(self.location.hostname)) return;
|
|
try {
|
|
// Garde-fou : la page peut compter ses erreurs de manière trop
|
|
// optimiste (burst d'API calls qui fail au réveil mobile). On confirme
|
|
// avant de rediriger — sinon on enverrait l'utilisateur sur un miroir
|
|
// alors que l'origine répond très bien.
|
|
const reachable = await isOriginReachable();
|
|
if (reachable) return;
|
|
const mirrors = await loadMirrors();
|
|
const target = pickNextMirror(mirrors, self.location.hostname);
|
|
if (!target) return;
|
|
const url = buildMirrorUrl(target, {
|
|
from: self.location.hostname,
|
|
reason: 'api-errors',
|
|
error: data.error || 'API error threshold reached',
|
|
via: 'sw-message',
|
|
});
|
|
event.source?.postMessage({ type: 'MOVIX_REDIRECT_TO', url });
|
|
} catch {}
|
|
});
|