import type { RouteProp } from '@react-navigation/native';
import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useQueryClient } from '@tanstack/react-query';
import { useEvent } from 'expo';
import * as Haptics from 'expo-haptics';
import * as ScreenOrientation from 'expo-screen-orientation';
import { useVideoPlayer, VideoView, type VideoSource } from 'expo-video';
import React from 'react';
import {
  ActivityIndicator,
  Alert,
  Animated,
  LayoutChangeEvent,
  Pressable,
  StatusBar,
  StyleSheet,
  Text,
  View,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import type { StreamLink } from '../types';

interface RouteParams {
  url: string;
  sceneId: string;
  // 'tube:<sitetag>' źródła — do telemetrii odtwarzania (ranking źródeł). Opcjonalne;
  // brak → telemetria pomijana (np. canonical/paradisehill bez tube-origin).
  origin?: string;
  // Post/scene page URL dla tubów IP-bound resolwowanych phone-side (sxyprn/eporner/
  // fpoxxx). Gdy native player padnie na initial-load (token bound do innego IP /
  // wygasł), Player RE-RESOLVUJE świeżo z tej strony (nowy token, bieżący IP) zamiast
  // retry martwego URL-a. Zero VPS bandwidth. Ustawiane przez SceneDetail.openAsVideo.
  resolvePageUrl?: string;
  // 'scene' (default — back-compat z istniejącymi nav callami) lub 'movie'.
  // Player dispatcheruje upsertProgress vs upsertMovieProgress. Wcześniej
  // MovieDetail przekazywał movieId jako sceneId — backend /scenes/<movieId>/
  // progress zwracał 404 (silently caught). Po dodaniu /movies/{id}/progress
  // (2026-05-28) mamy proper routing.
  entityKind?: 'scene' | 'movie';
  // playback_source.id — gdy podane, ekran błędu (native 404/410 lub WebView
  // onHttpError) pokazuje „Mark broken” który oznacza źródło dead i wraca.
  playbackId?: string;
  durationSec?: number | null;
  refererHost?: string;
  title?: string;
  mode?: 'video' | 'webview';
  headers?: Record<string, string>;
  fallbackProxyUrl?: string;
  fallbackEmbedUrl?: string;
}

// Wspólny helper dla native + WebView ekranu błędu: oznacza playback source jako
// dead (scene lub movie) i wraca do detalu. Bug a78cc3b6: gdy hoster zwraca 404
// przy odtwarzaniu (sxyprn .vid CDN gone, fpo video usunięte), user nie miał jak
// tego zidentyfikować/zgłosić z Playera — tylko „Back”. Teraz: jeden tap → dead.
function useMarkSourceBroken(params: RouteParams) {
  const client = useClient();
  const queryClient = useQueryClient();
  const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Player'>>();
  const [busy, setBusy] = React.useState(false);
  const canMark = !!params.playbackId;
  const markBroken = React.useCallback(async () => {
    if (!params.playbackId) {
      nav.goBack();
      return;
    }
    setBusy(true);
    try {
      if (params.entityKind === 'movie') {
        await client.markMoviePlaybackDead(params.sceneId, params.playbackId);
        queryClient.invalidateQueries({ queryKey: ['movie', params.sceneId] });
      } else {
        await client.markPlaybackDead(params.sceneId, params.playbackId);
        queryClient.invalidateQueries({ queryKey: ['scene', params.sceneId] });
      }
      // refetchType:'none' — oznacz stale bez wymuszania refetcha całej infinite-listy
      // (po powrocie do Scenes powodowało przeładowanie wszystkich stron = obciążenie,
      // bug-report 5df48551). Lista odświeży się przy pull-to-refresh / zmianie filtra.
      queryClient.invalidateQueries({ queryKey: ['scenes'], refetchType: 'none' });
    } catch {
      // best-effort — i tak wracamy; źródło zostanie do następnej próby
    } finally {
      setBusy(false);
      nav.goBack();
    }
  }, [client, queryClient, nav, params.playbackId, params.entityKind, params.sceneId]);
  return { markBroken, canMark, busy };
}

// Czy komunikat błędu ExoPlayera wskazuje na trwale usunięte źródło (404/410)?
// 403 NIE liczy się (auth/IP-bound → fallback proxy/WebView może uratować).
function isGoneError(msg: string | undefined | null): boolean {
  if (!msg) return false;
  return /\b(404|410)\b|response code:\s*4(0[14])|not found|gone/i.test(msg);
}

const DEFAULT_UA =
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';

const VIDEO_EXT_RE = /\.(mp4|m3u8|mpd|webm|ts|mov)(?:[?/#]|$)/i;
const PLAYER_PAGE_RE = /(?:\/player\/|\/embed\/|\/e\/|\.php(?:[?/#]|$))/i;

// Ad-network domains — block at subframe load + match w INJECTED_JS adblock.
// Sync z app/extractors/tubes/_embed_iframe.py:AD_DOMAIN_RE.
const AD_HOSTS = [
  'hoirms.com', 'propellerads.com', 'popads.net', 'popcash.net', 'trafficstars.com',
  'exoclick.com', 'adsterra.com', 'happyleafmotion.com', 'adskeeper.com',
  'hilltopads.com', 'juicyads.com', 'trafficjunky.net', 'adblade.com',
  'mavrtracktor.com', 'adtng.com', 'bluetrafficstream.com', 'smartpop.io',
  'mypornclub.com', 'cdntrafficstars.com', 'trafficfactory.biz', 'popcrn.com',
  'popmyads.com', 'adcash.com', 'chaturbate.com', 'stripchat.com', 'streamate.com',
  'willingcease.com', 'doubleclick.net', 'googlesyndication.com',
  'google-analytics.com', 'googletagmanager.com',
  'traffichaus.com', 'plugrush.com', 'clickadu.com',
  'redirectvoluum.com', 'clickaine.com', 'hilltopads.net', 'popshq.com',
];

function detectMode(url: string): 'video' | 'webview' {
  if (VIDEO_EXT_RE.test(url)) return 'video';
  if (PLAYER_PAGE_RE.test(url)) return 'webview';
  if (/\/proxy\//.test(url)) return 'video';
  return 'video';
}

export function PlayerScreen() {
  const route = useRoute<RouteProp<RootStackParamList, 'Player'>>();
  const params = route.params as RouteParams;
  const mode = params.mode ?? detectMode(params.url);

  React.useEffect(() => {
    ScreenOrientation.unlockAsync().catch(() => {});
    return () => {
      ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
    };
  }, []);

  if (mode === 'webview') return <EmbedWebViewPlayer params={params} />;
  return <NativeVideoPlayer params={params} />;
}

function NativeVideoPlayer({ params }: { params: RouteParams }) {
  const client = useClient();
  const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Player'>>();
  const { url, sceneId, origin: playOrigin, resolvePageUrl, entityKind, durationSec, refererHost, title, fallbackEmbedUrl, headers: paramHeaders, fallbackProxyUrl } = params;
  const { markBroken, canMark, busy: markBusy } = useMarkSourceBroken(params);
  // 'movie' → /movies/{id}/progress, 'scene' (default) → /scenes/{id}/progress.
  const upsertProgress = React.useCallback(
    (body: { position_sec: number; duration_sec?: number; finished?: boolean }) =>
      entityKind === 'movie'
        ? client.upsertMovieProgress(sceneId, body)
        : client.upsertProgress(sceneId, body),
    [client, sceneId, entityKind],
  );

  const source: VideoSource = React.useMemo(() => {
    // Backend dostarczone headers (Referer + UA z extractor) mają precedencję —
    // CDN binduje URL do konkretnego Referera embed page'a (np. watchporn.to dla
    // 0dayxx scen). Bez tego CDN zwraca 410.
    if (paramHeaders) {
      return { uri: url, headers: paramHeaders };
    }
    // Legacy fallback: stary path z refererHost (proxy URL nie wymaga headers).
    const hdr: Record<string, string> = { 'User-Agent': DEFAULT_UA };
    if (refererHost) {
      hdr['Referer'] = refererHost.startsWith('http') ? refererHost : `https://${refererHost}/`;
    }
    return { uri: url, headers: hdr };
  }, [url, refererHost, paramHeaders]);

  const player = useVideoPlayer(source, (p) => {
    p.loop = false;
    // Start wyciszony — dźwięk dopiero po tapnięciu przycisku (UX request 2026-06-07).
    // Dodatkowo: muted autoplay nie wymaga audio-focus, więc nie ucisza muzyki usera
    // przy podglądzie i nie zaskakuje głośnym startem.
    p.muted = true;
    p.play();
  });
  const [muted, setMuted] = React.useState(true);
  const toggleMute = React.useCallback(() => {
    const next = !player.muted;
    player.muted = next;
    setMuted(next);
    setControlsVisible(true);
  }, [player]);

  const statusEvent = useEvent(player, 'statusChange', { status: player.status });
  const status = statusEvent?.status ?? player.status;
  const playerError = statusEvent?.error;
  const playingEvent = useEvent(player, 'playingChange', { isPlaying: player.playing });
  const isPlaying = playingEvent?.isPlaying ?? player.playing;

  // Auto-fallback na WebView gdy native ExoPlayer dostanie błąd, a backend dostarczył
  // embed URL. Najczęstsza przyczyna: IP-bound CDN URL (luluvids/iceyfile/tnmr) —
  // backend extracted z VPS IP, mobile dostaje 403. WebView fetch'uje URL we własnym
  // kontekście strony (cookies, Chrome JA3, IP w session) → działa.
  // Fallback chain (na error):
  //   1. direct CDN URL z headers — preferred (0 VPS bandwidth)
  //   2. → proxy URL (VPS re-fetchuje, streamuje do mobile) — gdy CDN IP-bound
  //   3. → WebView z embed URL — gdy ExoPlayer w ogóle nie radzi sobie z formatem
  // Każdy step ma osobną ref żeby nie loopować.
  const didFallbackProxyRef = React.useRef(false);
  const didFallbackWebViewRef = React.useRef(false);
  // Re-resolve dla IP-bound tubów (sxyprn/eporner/fpoxxx): token jest bound do IP
  // które pobrało stronę; jeśli IP się zmieniło (CGNAT/przełączenie sieci) albo token
  // wygasł, native player pada na initial-load. Zamiast retry martwego URL-a pobieramy
  // stronę ŚWIEŻO (bieżący IP) i podmieniamy źródło. Flaga `reResolveDone` gate'uje
  // łańcuch fallback (proxy/WebView) póki re-resolve nie skończy — i jest no-op dla
  // tubów BEZ resolvePageUrl (czyli zero wpływu na resztę).
  const didReResolveRef = React.useRef(false);
  const [reResolveDone, setReResolveDone] = React.useState(false);
  // Seek/decode recovery (bug f6c86847: doply/playmogo „invalid NAL length” przy
  // przewijaniu). Stream jest poprawny — faststart MP4, CDN wspiera Range 206
  // (zweryfikowane 2026-06-01 cross-IP) — więc to wewnętrzny błąd seeka ExoPlayera,
  // nie HTTP. `replace(source)` otwiera świeże połączenie + re-parsuje moov; resume
  // na ostatniej dobrej pozycji ratuje playback. Twardy limit 2 prób / mount żeby
  // NIE wpaść w auto-loop. Tylko gdy player JUŻ grał (post-load, czyli seek-error,
  // nie initial-load) i błąd nie jest 404/410.
  const loadedOnceRef = React.useRef(false);
  const lastGoodPositionRef = React.useRef(0);
  const seekRecoveryRef = React.useRef(0);
  React.useEffect(() => {
    if (status === 'readyToPlay') loadedOnceRef.current = true;
  }, [status]);

  // Re-resolve IP-bound tubów (sxyprn/eporner/fpoxxx) na initial-load error: pobierz
  // stronę ŚWIEŻO z urządzenia (token bound do bieżącego IP) i podmień źródło. Tylko
  // 1× / mount. No-op gdy brak resolvePageUrl. Po zakończeniu (sukces lub nie)
  // ustawia reResolveDone → odblokowuje łańcuch fallback gdy nie pomogło.
  React.useEffect(() => {
    if (status !== 'error' || loadedOnceRef.current) return;
    if (didReResolveRef.current || !resolvePageUrl || !playOrigin) return;
    if (isGoneError(playerError?.message)) return; // skasowany post → niech łańcuch oznaczy dead
    didReResolveRef.current = true;
    let cancelled = false;
    (async () => {
      try {
        let links: StreamLink[] = [];
        if (playOrigin === 'tube:sxyprncom') {
          links = await (await import('../lib/sxyprnResolver')).resolveSxyprnPage(resolvePageUrl);
        } else if (playOrigin === 'tube:epornercom') {
          links = await (await import('../lib/epornerResolver')).resolveEpornerPage(resolvePageUrl);
        } else if (playOrigin === 'tube:fpoxxx') {
          links = await (await import('../lib/fpoxxxResolver')).resolveFpoxxxPage(resolvePageUrl);
        }
        const fresh = links?.[0];
        const freshUrl = fresh?.direct_url || fresh?.stream_url;
        if (!cancelled && freshUrl && freshUrl !== url) {
          player.replace(fresh?.headers ? { uri: freshUrl, headers: fresh.headers } : freshUrl);
          player.play();
          return; // sukces → status zmieni się z 'error', łańcuch fallback nie ruszy
        }
      } catch {
        // ignore → łańcuch fallback przejmie
      } finally {
        if (!cancelled) setReResolveDone(true);
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [status, resolvePageUrl, playOrigin, playerError, player, url]);

  // Re-resolve (IP-bound tube) jest STOSOWALNY tylko na initial-load i tylko dla nie-gone
  // błędu (patrz warunki efektu wyżej). Gone / post-load error re-resolve się NIE uruchomi,
  // więc ani łańcuch fallback (gate niżej), ani spinner recoveryPending nie mogą na niego
  // czekać, inaczej deadlock: wieczny „Reconnecting", zero „Mark broken" (review 0/1/3).
  const reResolveApplicable =
    !!resolvePageUrl && !loadedOnceRef.current && !isGoneError(playerError?.message);

  React.useEffect(() => {
    if (status !== 'error') return;
    // Step 0: post-load decode/seek error → recover in-place (przed proxy/WebView,
    // które są dla INITIAL-load errorów IP-bound CDN).
    if (
      loadedOnceRef.current &&
      seekRecoveryRef.current < 2 &&
      !isGoneError(playerError?.message)
    ) {
      seekRecoveryRef.current += 1;
      const resumeAt = lastGoodPositionRef.current;
      try {
        player.replace(source);
        setTimeout(() => {
          try {
            if (resumeAt > 0) player.currentTime = resumeAt;
            player.play();
          } catch {
            // disposed/za wcześnie — następny error tick spróbuje znów (do limitu)
          }
        }, 700);
        return; // replace zaplanowany → czekamy na reload
      } catch {
        // replace padł (player disposed) → wyczerp seek-recovery i PRZEPUŚĆ do łańcucha
        // fallback niżej (wcześniej `return` blokował to mimo komentarza → stuck spinner).
        seekRecoveryRef.current = 2;
      }
    }
    // Gate: dla IP-bound tubów czekaj na re-resolve TYLKO gdy jest stosowalny (initial-load,
    // nie-gone). Gone/post-load → re-resolve nie ruszy, więc nie blokuj łańcucha (deadlock).
    if (reResolveApplicable && !reResolveDone) return;
    // Step 1 → 2: direct fail (403/410/etc), spróbuj proxy URL.
    if (fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) {
      didFallbackProxyRef.current = true;
      nav.replace('Player', {
        url: fallbackProxyUrl,
        sceneId,
        origin: playOrigin,
        playbackId: params.playbackId,
        entityKind,
        durationSec,
        refererHost,
        title,
        // Proxy URL nie wymaga headers (proxy sam dodaje Referer przy upstream fetch).
        headers: undefined,
        fallbackProxyUrl: undefined,  // już użyty
        fallbackEmbedUrl,  // zostawiamy do step 3
      });
      return;
    }
    // Step 2 → 3: proxy też failed, spróbuj WebView z embed URL.
    if (fallbackEmbedUrl && !didFallbackWebViewRef.current) {
      didFallbackWebViewRef.current = true;
      nav.replace('Player', {
        url: fallbackEmbedUrl,
        sceneId,
        origin: playOrigin,
        playbackId: params.playbackId,
        entityKind,
        durationSec,
        refererHost,
        title,
        mode: 'webview',
      });
    }
  }, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title, player, source, playerError, resolvePageUrl, reResolveApplicable, reResolveDone, playOrigin]);

  // Telemetria odtwarzania (ranking źródeł). Tylko native-player path (WebView mode
  // ma osobny komponent, nie umiemy tam wykryć sukcesu → pomijamy, fair). Jeden ping
  // per mount. SUCCESS = pierwszy readyToPlay (z ttff). ERROR = terminal native error
  // bez pozostałych fallbacków i bez wcześniejszego zagrania (nie penalizujemy źródeł
  // które jeszcze się ratują proxy/WebView).
  const playTelemetryMountRef = React.useRef<number | null>(null);
  if (playTelemetryMountRef.current === null) playTelemetryMountRef.current = Date.now();
  const playTelemetrySentRef = React.useRef(false);
  React.useEffect(() => {
    if (playTelemetrySentRef.current || !playOrigin) return;
    if (status === 'readyToPlay') {
      playTelemetrySentRef.current = true;
      client.reportPlaybackEvent({
        origin: playOrigin,
        status: 'success',
        scene_id: sceneId,
        ttff_ms: Date.now() - (playTelemetryMountRef.current ?? Date.now()),
      });
    }
  }, [status, playOrigin, client, sceneId]);
  React.useEffect(() => {
    if (playTelemetrySentRef.current || !playOrigin) return;
    if (status !== 'error' || loadedOnceRef.current) return;
    const proxyPending = !!fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl;
    const webviewPending = !!fallbackEmbedUrl && !didFallbackWebViewRef.current;
    if (proxyPending || webviewPending) return; // jeszcze jest fallback do spróbowania
    playTelemetrySentRef.current = true;
    client.reportPlaybackEvent({
      origin: playOrigin,
      status: 'error',
      scene_id: sceneId,
      error_kind: isGoneError(playerError?.message) ? 'gone' : 'player_error',
    });
  }, [status, playOrigin, fallbackProxyUrl, fallbackEmbedUrl, url, playerError, client, sceneId]);

  const lastReportedRef = React.useRef(0);
  // Lokalny tick co 500ms — driver dla custom scrubber + time labels. expo-video
  // ma `timeUpdate` event ale firuje z mniejszą częstotliwością niż chcemy dla UI.
  const [position, setPosition] = React.useState(0);
  const [knownDuration, setKnownDuration] = React.useState<number>(durationSec ?? 0);
  React.useEffect(() => {
    const tick = setInterval(() => {
      try {
        const pos = player.currentTime || 0;
        setPosition(pos);
        // Ostatnia „dobra” pozycja dla seek-recovery (tylko gdy faktycznie gra).
        if (player.playing && pos > 0) lastGoodPositionRef.current = pos;
        const dur = player.duration || 0;
        if (dur > 0 && Math.abs(dur - knownDuration) > 0.5) setKnownDuration(dur);
        const posInt = Math.floor(pos);
        const durInt = Math.floor(dur || durationSec || 0) || null;
        if (posInt > 0 && Math.abs(posInt - lastReportedRef.current) >= 10) {
          lastReportedRef.current = posInt;
          upsertProgress({
              position_sec: posInt,
              duration_sec: durInt ?? undefined,
              // "Watched" = email-style "read": min. 30s aktywnej odtwarzania (bo 1 sec
              // to przypadkowy klik). Tile z `finished=true` jest dim'owany w listach
              // jako sygnał "to widziałem już". Nie chcemy threshold 95% — to nie Netflix,
              // user przegląda materiał, decyduje po pierwszych sekundach.
              finished: posInt >= 30,
            })
            .catch(() => {});
        }
      } catch {
        // disposed
      }
    }, 500);
    return () => {
      clearInterval(tick);
      try {
        const pos = Math.floor(player.currentTime || 0);
        const dur = Math.floor(player.duration || durationSec || 0) || null;
        if (pos > 0) {
          upsertProgress({
              position_sec: pos,
              duration_sec: dur ?? undefined,
              finished: pos >= 30,
            })
            .catch(() => {});
        }
      } catch {
        // best-effort
      }
    };
  }, [player, sceneId, durationSec, upsertProgress, knownDuration]);

  // ----- controls visibility (auto-hide po 3.5s bez interakcji) ----------------
  const [controlsVisible, setControlsVisible] = React.useState(true);
  const fade = React.useRef(new Animated.Value(1)).current;
  const hideTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
  const cancelHide = React.useCallback(() => {
    if (hideTimerRef.current) {
      clearTimeout(hideTimerRef.current);
      hideTimerRef.current = null;
    }
  }, []);
  const scheduleHide = React.useCallback(() => {
    cancelHide();
    hideTimerRef.current = setTimeout(() => setControlsVisible(false), 3500);
  }, [cancelHide]);
  React.useEffect(() => {
    Animated.timing(fade, {
      toValue: controlsVisible ? 1 : 0,
      duration: 180,
      useNativeDriver: true,
    }).start();
    if (controlsVisible) scheduleHide();
    else cancelHide();
  }, [controlsVisible, fade, scheduleHide, cancelHide]);
  React.useEffect(() => () => cancelHide(), [cancelHide]);

  // ----- gesture overlay state (popups for ±10s i 2x) -------------------------
  const [seekHint, setSeekHint] = React.useState<'left' | 'right' | null>(null);
  const [seekDelta, setSeekDelta] = React.useState(0);
  const seekHintTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
  const showSeekHint = React.useCallback((side: 'left' | 'right', delta: number) => {
    setSeekHint(side);
    setSeekDelta((d) => (Math.sign(d) === Math.sign(delta) ? d + delta : delta));
    if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current);
    seekHintTimerRef.current = setTimeout(() => {
      setSeekHint(null);
      setSeekDelta(0);
    }, 700);
  }, []);
  React.useEffect(() => () => {
    if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current);
  }, []);

  const [speedActive, setSpeedActive] = React.useState(false);

  // ----- pan-to-seek (horizontal swipe scrub) ---------------------------------
  // Activation: 20px threshold po X, więc nie konfliktuje z double-tap (dużo krótszy
  // ruch) ani long-press (statyczny). Mapping: pełna szerokość ekranu = całe wideo,
  // jak YouTube / IG stories. Seek na koniec gestu (nie podczas onUpdate) bo
  // expo-video nie radzi sobie z 60Hz seekingiem — buffer hits + flicker.
  const panStartTimeRef = React.useRef(0);
  const [panSeekTarget, setPanSeekTarget] = React.useState<number | null>(null);

  // Layout szerokości overlaya — żeby double-tap wiedział lewa vs prawa połowa.
  const layoutWidthRef = React.useRef(0);
  const onLayout = React.useCallback((e: LayoutChangeEvent) => {
    layoutWidthRef.current = e.nativeEvent.layout.width;
  }, []);

  // ----- gesture definitions --------------------------------------------------
  // Single-tap → toggle controls. Czeka aż double-tap fail (~250ms by RNGH default).
  // Bez tego pierwszy tap z double-tap-a togglowałby controls.
  const singleTap = React.useMemo(
    () =>
      Gesture.Tap()
        .numberOfTaps(1)
        .maxDuration(250)
        .onEnd((_e, success) => {
          if (!success) return;
          setControlsVisible((v) => !v);
        })
        .runOnJS(true),
    [],
  );

  const doubleTap = React.useMemo(
    () =>
      Gesture.Tap()
        .numberOfTaps(2)
        .maxDelay(280)
        .onEnd((e, success) => {
          if (!success) return;
          const w = layoutWidthRef.current;
          const isRight = w > 0 && e.x > w / 2;
          // +15s/-15s per bug-report 2026-05-16 (#cdff6341) — user preferuje +15
          // bo 10 czuje się jak za krótkie na pomijanie krótkich klipów ad/intro.
          const delta = isRight ? 15 : -15;
          try {
            player.seekBy(delta);
          } catch {
            // ignore
          }
          showSeekHint(isRight ? 'right' : 'left', delta);
          Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
          // NIE pokazujemy pełnych kontrolek przy double-tap seeku — seek ma własny
          // popup ±15s (showSeekHint). Wcześniej setControlsVisible(true) wyrzucał
          // duży przycisk pauzy na środek na 3.5s (bug-report dc4e91fb).
        })
        .runOnJS(true),
    [player, showSeekHint],
  );

  // Long-press → 2× speed dopóki trzymasz. minDuration 220ms żeby nie konfliktowało
  // z double-tap (drugi tap trwa krócej). onTouchesUp finalizuje gest.
  const longPress = React.useMemo(
    () =>
      Gesture.LongPress()
        .minDuration(220)
        .maxDistance(30)
        .onStart(() => {
          try {
            player.playbackRate = 2.0;
          } catch {
            // ignore
          }
          setSpeedActive(true);
          Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
        })
        // onEnd palpi gdy gesture state → END (palec puszczony NORMALNIE).
        // onFinalize to safety-net dla CANCELLED/FAILED — np. gdy systemowy
        // gesture (scroll, swipe back) przejmie palec. Bez tego playbackRate
        // zostawał na 2.0 i user musiał drugi raz tapnąć żeby wyłączyć
        // (zgłoszone 2026-05-10, bug-report #f53e50b9). Wcześniejszy kod
        // używał nieudokumentowanego `.onTouchesUp/Cancelled` które na LongPress
        // gesture nie wywoływały się w wszystkich scenariuszach.
        .onEnd(() => {
          try {
            player.playbackRate = 1.0;
          } catch {
            // ignore
          }
          setSpeedActive(false);
        })
        .onFinalize(() => {
          try {
            if (player.playbackRate !== 1.0) {
              player.playbackRate = 1.0;
            }
          } catch {
            // ignore
          }
          setSpeedActive(false);
        })
        .runOnJS(true),
    [player],
  );

  const dur = knownDuration || durationSec || 0;

  const panSeek = React.useMemo(
    () =>
      Gesture.Pan()
        // Aktywacja dopiero po 20px w bok — niżej myliłoby się z drgnięciem palca
        // przy tap. Pionowy ruch >40px abortuje (rezerwa na future zoom/brightness).
        .activeOffsetX([-20, 20])
        .failOffsetY([-40, 40])
        .onStart(() => {
          cancelHide();
          panStartTimeRef.current = player.currentTime || 0;
          // NIE pokazujemy pełnych kontrolek przy swipe-seeku, pan ma własny popup
          // ±czas (panSeekBubble). Wcześniej setControlsVisible(true) wyrzucał duży
          // przycisk pauzy na środek i zostawiał go ~3.5s po puszczeniu (report dccc05e4).
          Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
        })
        .onUpdate((e) => {
          const w = layoutWidthRef.current;
          if (w <= 0 || dur <= 0) return;
          const deltaSec = (e.translationX / w) * dur;
          const target = Math.max(0, Math.min(dur, panStartTimeRef.current + deltaSec));
          setPanSeekTarget(target);
        })
        .onEnd((e) => {
          const w = layoutWidthRef.current;
          if (w > 0 && dur > 0) {
            const deltaSec = (e.translationX / w) * dur;
            const target = Math.max(0, Math.min(dur, panStartTimeRef.current + deltaSec));
            try {
              player.currentTime = target;
            } catch {
              // ignore
            }
          }
          setPanSeekTarget(null);
          scheduleHide();
        })
        .onFinalize((_e, success) => {
          if (!success) setPanSeekTarget(null);
        })
        .runOnJS(true),
    [player, dur, cancelHide, scheduleHide],
  );

  // panSeek + longPress mają niezależne triggery (motion 20px vs hold 220ms),
  // więc nie powinny się blokować — Gesture.Race pozwala pierwszemu który ACTIVATE
  // wygrać natychmiast. Wcześniejszy Exclusive(panSeek, ...) wymagał żeby panSeek
  // FAIL zanim longPress wystartuje, ale Pan z activeOffsetX nie failuje dopóki
  // touch trwa — efekt: longPress odpalał się dopiero przy puszczeniu palca
  // (bug-report 68483c6d 2026-05-23 v0.1.9: "Jak przytrzymuje nic. Dopiero jak
  // się puści, X2 pojawia się i znika"). Naprawa z 0136b68 (reorder Exclusive)
  // adresowała tylko interakcję z doubleTap, nie z panSeek.
  // Tap pair pozostaje Exclusive — singleTap MUSI czekać aż doubleTap fail,
  // inaczej każdy double-tap byłby najpierw zinterpretowany jako single (toggle controls).
  const composedGesture = React.useMemo(
    () => Gesture.Race(panSeek, longPress, Gesture.Exclusive(doubleTap, singleTap)),
    [panSeek, longPress, doubleTap, singleTap],
  );

  // ----- scrubber pan ---------------------------------------------------------
  const [scrubX, setScrubX] = React.useState<number | null>(null);
  const scrubBarWidthRef = React.useRef(0);
  const scrubBarLayout = React.useCallback((e: LayoutChangeEvent) => {
    scrubBarWidthRef.current = e.nativeEvent.layout.width;
  }, []);
  const scrubGesture = React.useMemo(
    () =>
      Gesture.Pan()
        .onStart((e) => {
          cancelHide();
          setScrubX(Math.max(0, Math.min(scrubBarWidthRef.current, e.x)));
        })
        .onUpdate((e) => {
          setScrubX(Math.max(0, Math.min(scrubBarWidthRef.current, e.x)));
        })
        .onEnd((e) => {
          const w = scrubBarWidthRef.current;
          if (w > 0 && knownDuration > 0) {
            const ratio = Math.max(0, Math.min(1, e.x / w));
            try {
              player.currentTime = ratio * knownDuration;
            } catch {
              // ignore
            }
          }
          setScrubX(null);
          scheduleHide();
        })
        .runOnJS(true),
    [player, knownDuration, cancelHide, scheduleHide],
  );

  // ----- fullscreen toggle ----------------------------------------------------
  const [isLandscape, setIsLandscape] = React.useState(false);
  const toggleFullscreen = React.useCallback(async () => {
    if (isLandscape) {
      await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(
        () => {},
      );
      setIsLandscape(false);
    } else {
      await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE).catch(
        () => {},
      );
      setIsLandscape(true);
    }
    setControlsVisible(true);
  }, [isLandscape]);

  // ----- helpers --------------------------------------------------------------
  const togglePlay = React.useCallback(() => {
    if (player.playing) player.pause();
    else player.play();
    setControlsVisible(true);
  }, [player]);

  const progressRatio = dur > 0 ? Math.min(1, Math.max(0, position / dur)) : 0;
  const scrubbingRatio =
    scrubX !== null && scrubBarWidthRef.current > 0
      ? Math.max(0, Math.min(1, scrubX / scrubBarWidthRef.current))
      : null;
  // Priorytet: scrubber (palec na pasku) > pan-seek (swipe na video) > playback.
  const panSeekRatio = panSeekTarget !== null && dur > 0 ? panSeekTarget / dur : null;
  const displayRatio = scrubbingRatio ?? panSeekRatio ?? progressRatio;
  // Aktywne przewijanie (palec na pasku albo swipe), chowamy wtedy duży środkowy
  // przycisk play/pauza (report dccc05e4): przy seeku nie chcesz wielkiej pauzy na ekranie.
  const isSeeking = panSeekTarget !== null || scrubX !== null;
  const displayedTime =
    scrubbingRatio !== null
      ? scrubbingRatio * dur
      : panSeekTarget !== null
        ? panSeekTarget
        : position;

  // Czy jest jeszcze JAKAKOLWIEK ścieżka ratunku w toku? Gdy tak, NIE pokazuj
  // terminalnego błędu z „Mark broken", bo za chwilę zagra (report dafa8cdb: eporner/
  // sxyprn/fpoxxx native pada na starcie, re-resolve podmienia URL ~1-3s później i gra;
  // przez tę chwilę migało „Playback failed / Mark broken"). Lustro logiki telemetrii:
  // re-resolve IP-bound, in-place seek-recovery, proxy albo WebView jeszcze nie próbowane.
  const recoveryPending =
    status === 'error' &&
    ((reResolveApplicable && !reResolveDone) ||
      (loadedOnceRef.current && seekRecoveryRef.current < 2 && !isGoneError(playerError?.message)) ||
      (!!fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) ||
      (!!fallbackEmbedUrl && !didFallbackWebViewRef.current));

  return (
    <View style={styles.root} onLayout={onLayout}>
      {/* Hidden status bar — full-bleed video w landscape bez 24px paska systemu. */}
      <StatusBar hidden />

      <VideoView
        style={styles.video}
        player={player}
        allowsFullscreen={false}
        allowsPictureInPicture
        nativeControls={false}
        contentFit="contain"
      />

      {/* Gesture warstwa nad video ale POD controls overlay — controls.pointerEvents=
          'box-none' przepuszcza taps poza buttons na to. */}
      <GestureDetector gesture={composedGesture}>
        <Animated.View style={StyleSheet.absoluteFill} collapsable={false} />
      </GestureDetector>

      {/* Pan-seek preview bubble (swipe horizontal scrub) — pokazuje target time + delta. */}
      {panSeekTarget !== null && (
        <View pointerEvents="none" style={styles.panSeekBubble}>
          <Text style={styles.panSeekTime}>{formatTime(panSeekTarget)}</Text>
          <Text style={styles.panSeekDelta}>
            {(() => {
              const delta = Math.round(panSeekTarget - panStartTimeRef.current);
              return delta >= 0 ? `+${delta}s` : `${delta}s`;
            })()}
          </Text>
        </View>
      )}

      {/* Seek hint popup (−10s / +10s) */}
      {seekHint && (
        <View
          pointerEvents="none"
          style={[
            styles.seekBubble,
            seekHint === 'left' ? styles.seekBubbleLeft : styles.seekBubbleRight,
          ]}
        >
          <Text style={styles.seekBubbleText}>
            {seekHint === 'left' ? '« ' : ''}
            {seekDelta > 0 ? `+${seekDelta}` : seekDelta}s
            {seekHint === 'right' ? ' »' : ''}
          </Text>
        </View>
      )}

      {/* 2× speed pill */}
      {speedActive && (
        <View pointerEvents="none" style={styles.speedPill}>
          <Text style={styles.speedPillText}>▶▶ 2×</Text>
        </View>
      )}

      {/* Controls overlay — pointerEvents box-none żeby gesture overlay pod spodem
          dalej dostawał taps poza interactive elements (back, play, scrubber). */}
      <Animated.View
        pointerEvents={controlsVisible ? 'box-none' : 'none'}
        style={[StyleSheet.absoluteFill, { opacity: fade }]}
      >
        <View style={styles.controlsTop} pointerEvents="box-none">
          <Pressable onPress={() => nav.goBack()} hitSlop={16} style={styles.iconBtn}>
            <Text style={styles.iconText}>←</Text>
          </Pressable>
          {title ? (
            <Text style={styles.titleText} numberOfLines={1}>
              {title}
            </Text>
          ) : (
            <View style={{ flex: 1 }} />
          )}
          <Pressable onPress={toggleMute} hitSlop={16} style={styles.iconBtn}>
            <Text style={styles.iconText}>{muted ? '🔇' : '🔊'}</Text>
          </Pressable>
          <Pressable onPress={toggleFullscreen} hitSlop={16} style={styles.iconBtn}>
            <Text style={styles.iconText}>{isLandscape ? '⤬' : '⛶'}</Text>
          </Pressable>
        </View>

        {!isSeeking && (
          <View style={styles.controlsCenter} pointerEvents="box-none">
            <Pressable onPress={togglePlay} hitSlop={20} style={styles.playBtn}>
              <Text style={styles.playBtnText}>{isPlaying ? '❚❚' : '▶'}</Text>
            </Pressable>
          </View>
        )}

        <View style={styles.controlsBottom} pointerEvents="box-none">
          <Text style={styles.timeText}>{formatTime(displayedTime)}</Text>
          <GestureDetector gesture={scrubGesture}>
            <View style={styles.scrubTouchArea} onLayout={scrubBarLayout} collapsable={false}>
              <View style={styles.scrubTrack} />
              <View
                style={[
                  styles.scrubFill,
                  { width: `${displayRatio * 100}%` },
                ]}
              />
              <View
                style={[
                  styles.scrubThumb,
                  { left: `${displayRatio * 100}%` },
                ]}
              />
            </View>
          </GestureDetector>
          <Text style={styles.timeText}>{formatTime(dur)}</Text>
        </View>
      </Animated.View>

      {status === 'loading' && (
        <View style={styles.overlay} pointerEvents="none">
          <ActivityIndicator color={theme.fg} size="large" />
          <Text style={styles.overlayText}>{title ?? 'Loading…'}</Text>
        </View>
      )}
      {status === 'error' && !recoveryPending && (
        <View style={styles.overlay}>
          <Text style={styles.errorTitle}>
            {isGoneError(playerError?.message) ? 'Source no longer available' : 'Playback failed'}
          </Text>
          <Text style={styles.errorBody}>
            {isGoneError(playerError?.message)
              ? 'The host returned 404/410 — this video was removed.'
              : (playerError?.message ?? 'The stream did not start.')}
          </Text>
          <View style={styles.errorBtnRow}>
            {canMark && (
              <Pressable style={[styles.btn, styles.btnDanger]} onPress={markBroken} disabled={markBusy}>
                <Text style={styles.btnText}>{markBusy ? 'Marking…' : 'Mark broken'}</Text>
              </Pressable>
            )}
            <Pressable style={styles.btn} onPress={() => nav.goBack()}>
              <Text style={styles.btnText}>Back</Text>
            </Pressable>
          </View>
        </View>
      )}
      {recoveryPending && (
        <View style={styles.overlay} pointerEvents="none">
          <ActivityIndicator color={theme.fg} size="large" />
          <Text style={styles.overlayText}>
            {fallbackEmbedUrl && !didFallbackWebViewRef.current
              ? 'Native player failed, switching to embed…'
              : 'Reconnecting…'}
          </Text>
        </View>
      )}
    </View>
  );
}

function formatTime(sec: number): string {
  if (!isFinite(sec) || sec < 0) sec = 0;
  const total = Math.floor(sec);
  const h = Math.floor(total / 3600);
  const m = Math.floor((total % 3600) / 60);
  const s = total % 60;
  const mm = String(m).padStart(2, '0');
  const ss = String(s).padStart(2, '0');
  return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`;
}

// JS injected do embed page. Robi 3 rzeczy:
//
//  1. **Adblock**: zatrzymuje `window.open`, klikalne linki z `target=_blank`, dynamiczne
//     iframe ad-overlays. Hostery jak xtremestream/playmogo używają "click-to-play" tricku
//     gdzie pierwszy tap na video idzie do reklamy (popunder), nie do play.
//  2. **Auto-extract**: hookuje `XMLHttpRequest.open` + `fetch()` + sondowanie `<video>.src`
//     żeby wyciągnąć finalny m3u8/mp4 URL. Gdy znajdzie, posyła do RN przez postMessage —
//     RN pokazuje banner "Otwórz w native playerze" który robi nav.replace na ExoPlayer.
//  3. **Auto-tap**: po załadowaniu, klika programowo na <video> + .play() — pomija ekran
//     "tap to play" reklamowego overlaya.
//
// Kontekst: WebView (Chrome engine) jest jedyną drogą dla CF-WAF-blocked hosterów (perverzija
// → xtremestream.xyz, watchporn → playmogo). Apka porn-app robi dokładnie to samo — embed
// JS hostera zna m3u8 URL bo Chrome JA3 pasuje, a ExoPlayer w naszej apce nie.
const INJECTED_JS = `
(function() {
  if (window.__goonPatched) return;
  window.__goonPatched = true;

  // -- 0a. Mute autoplay until the FIRST user gesture (kills the brief unmuted-audio
  //        flash gdy WebView otwiera stronę żeby przechwycić stream, report dccc05e4).
  //        Override play() PRZED page JS: bez gestu → muted; po tapie usera → dźwięk
  //        dozwolony (zgodne z "dźwięk dopiero po geście usera"). Element-level mute
  //        niżej łapał za późno (autoplay z dźwiękiem zdążył ruszyć).
  try {
    var _goonOrigPlay = HTMLMediaElement.prototype.play;
    HTMLMediaElement.prototype.play = function() {
      if (!window.__goonUserGestured) { try { this.muted = true; } catch (e) {} }
      return _goonOrigPlay.apply(this, arguments);
    };
    var _goonAllowSound = function() { window.__goonUserGestured = true; };
    window.addEventListener('touchstart', _goonAllowSound, { once: true, capture: true });
    window.addEventListener('click', _goonAllowSound, { once: true, capture: true });
  } catch (e) {}

  // -- 0. Anti-adblock detection bypass --------------------------------------
  // Hostery sprawdzają czy ad-script się załadował (np. /js/dnsads.js ustawia
  // \`window.cRAds\`). Blokujemy te requesty na poziomie AD_HOSTS, więc flag
  // pozostaje undefined → pełnoekranowy "Disable AdBlock" overlay zakrywa player.
  // Bug-report \`02444895\` 2026-05-20 (Luluvid czarny ekran): hostery
  // sprawdzają flag w \`$(function(){})\` które odpala się po ad-script load.
  // Pre-ustawiamy flagi PRZED kodem strony żeby anti-adblock przeszedł.
  // Lista jest defensywna — większość overlapuje (dnsads.js ustawia różne nazwy
  // zależnie od skinu hostera). Nie szkodzi mieć wszystkie ustawione.
  try {
    window.cRAds = 1;
    window.adsbygoogle = window.adsbygoogle || [];
    window.canRunAds = true;
    window.cantRunAds = false;
    window.isAdBlockActive = false;
  } catch (e) {}

  // -- 1. Ad-network domain blocklist ----------------------------------------
  // Sync z app/extractors/tubes/_embed_iframe.py:AD_DOMAIN_RE. Match na hostname
  // — jakikolwiek URL którego host KOŃCZY się tym (uwzględnia subdomeny ib.hoirms.com).
  // Hostery aggregator tubes (porn4days, siska/playmogo) wstrzykują ad scripts +
  // popunder iframes z tych domen po CAPTCHA solve. Bez block widać pełnoekranowe
  // reklamy nakładane na player.
  const AD_HOSTS = [
    'hoirms.com','propellerads.com','popads.net','popcash.net','trafficstars.com',
    'exoclick.com','adsterra.com','happyleafmotion.com','adskeeper.com',
    'hilltopads.com','juicyads.com','trafficjunky.net','adblade.com',
    'mavrtracktor.com','adtng.com','bluetrafficstream.com','smartpop.io',
    'mypornclub.com','cdntrafficstars.com','trafficfactory.biz','popcrn.com',
    'popmyads.com','adcash.com','chaturbate.com','stripchat.com','streamate.com',
    'willingcease.com','doubleclick.net','googlesyndication.com',
    'google-analytics.com','googletagmanager.com',
    // Popunder networks często widywane na DoodStream-rebranding hostach
    'ib.hoirms.com','traffichaus.com','plugrush.com','clickadu.com',
    'redirectvoluum.com','clickaine.com','hilltopads.net','popshq.com',
    // VIDEO-ad CDN/VAST preroll (mp4) — KVS hosty (yespornvip/pornditt) wstrzykują
    // preroll który scrape inaczej łapie zamiast contentu. 2026-06-01.
    'trafostatic.com','bkcdn.net','gripi.online','dipi.online','tsyndicate.com',
    'marzaent.com','yomeno.xyz','cdn-fc.com','tapioni.com','fh-wgt.com',
    'xlivrdr.com','dynspt.com','svtrck.site','urlhaus.com',
  ];
  const isAdHost = function(url) {
    if (!url) return false;
    try {
      let host = url;
      if (url.indexOf('//') !== -1) host = url.split('//')[1].split('/')[0];
      host = host.toLowerCase();
      for (let i = 0; i < AD_HOSTS.length; i++) {
        const ad = AD_HOSTS[i];
        if (host === ad || host.endsWith('.' + ad)) return true;
      }
    } catch (e) {}
    return false;
  };

  // window.open zwraca fake window (no-op) bo hostery nie wykrywają fail.
  window.open = function() {
    return { closed: false, focus: function() {}, close: function() {} };
  };

  // Block <a target="_blank"> i clicki na overlaye reklamowe.
  document.addEventListener('click', function(e) {
    let el = e.target;
    while (el && el.nodeType === 1) {
      if (el.tagName === 'A') {
        const href = el.getAttribute('href') || '';
        const target = el.getAttribute('target') || '';
        if (target === '_blank' || (href && href.startsWith('http') && !href.includes(location.host))) {
          e.preventDefault();
          e.stopPropagation();
          e.stopImmediatePropagation();
          return false;
        }
      }
      el = el.parentNode;
    }
  }, true);

  // Block dynamic ad-script injection. Hostery (DoodStream variant z siska) ładują
  // adsnet przez \`document.createElement('script').src = ad-url\` + appendChild. Patch
  // tych metod żeby filter URL przed insertem do DOM.
  const origCreateElement = Document.prototype.createElement;
  Document.prototype.createElement = function(tagName) {
    const el = origCreateElement.apply(this, arguments);
    if (typeof tagName === 'string' && /^(script|iframe|img)$/i.test(tagName)) {
      // Hook src setter — gdy URL jest ad-host, set noop URL żeby ad nie ładował się.
      let cachedSrc = '';
      try {
        Object.defineProperty(el, 'src', {
          configurable: true,
          get: function() { return cachedSrc; },
          set: function(v) {
            if (isAdHost(v)) { cachedSrc = ''; return; }
            cachedSrc = v;
            el.setAttribute('src', v);
          },
        });
      } catch (e) {}
    }
    return el;
  };

  // Patch appendChild + insertBefore żeby blokować już-ustawione ad src (przypadki
  // gdy ad-network ustawi src przed appendChild). Dla iframe + script + img.
  const filterAdNode = function(node) {
    if (!node || node.nodeType !== 1) return node;
    const tag = (node.tagName || '').toUpperCase();
    if (!/^(SCRIPT|IFRAME|IMG)$/.test(tag)) return node;
    const src = node.getAttribute && (node.getAttribute('src') || '');
    if (src && isAdHost(src)) {
      // Zwracamy zaślepkę zamiast prawdziwego node — appendChild zakończy się
      // sukcesem ale fizycznie nic do DOM nie idzie. Hostery nie wykrywają fail.
      return document.createComment('blocked-ad');
    }
    return node;
  };
  const origAppendChild = Node.prototype.appendChild;
  Node.prototype.appendChild = function(node) {
    return origAppendChild.call(this, filterAdNode(node));
  };
  const origInsertBefore = Node.prototype.insertBefore;
  Node.prototype.insertBefore = function(node, ref) {
    return origInsertBefore.call(this, filterAdNode(node), ref);
  };

  // Block location.href / location.assign cross-origin nav (popunder via redirect).
  try {
    const origAssign = Location.prototype.assign;
    Location.prototype.assign = function(url) {
      if (isAdHost(url)) return;
      return origAssign.apply(this, arguments);
    };
    const origReplace = Location.prototype.replace;
    Location.prototype.replace = function(url) {
      if (isAdHost(url)) return;
      return origReplace.apply(this, arguments);
    };
  } catch (e) {}

  // -- 1.5. Cookie/consent auto-dismiss --------------------------------------
  // Tube'y typu hqporner mają cookie-consent gate ("Allow All / Allow Essential
  // Only") który blokuje kt_player JS — player nie inicjalizuje się dopóki user
  // nie kliknie. INJECTED_JS scrape \`<source>.src\` odpala się więc za wcześnie
  // (DOM nie ma jeszcze video). Auto-klikamy consent żeby odblokować player.
  //
  // Bezpieczeństwo: klikamy TYLKO element którego tekst pasuje do consent-frazy
  // ORAZ leży w kontenerze z markerem cookie/consent/gdpr (≤6 przodków). To
  // wyklucza przypadkowy klik w reklamę "Continue to site".
  const CONSENT_TEXT_RE = /^(allow all|accept all|accept|accept & continue|accept and continue|i accept|i agree|agree|agree & continue|got it|enable all|consent|continue|ok|akceptuj.*|zgadzam.*|zgoda|rozumiem|wyra(z|ż)am zgod)$/i;
  const CONSENT_CTX_RE = /(cookie|consent|gdpr|privacy|cmp|onetrust|didomi|cookiebar|cookie-?notice)/i;
  const dismissConsent = function() {
    const els = document.querySelectorAll('button, a, [role="button"], div[onclick], span[onclick], input[type="button"], input[type="submit"]');
    for (let i = 0; i < els.length; i++) {
      const el = els[i];
      const txt = ((el.textContent || el.value || '') + '').trim();
      if (!txt || txt.length > 32) continue;
      if (!CONSENT_TEXT_RE.test(txt)) continue;
      // Kontekst: element lub ≤6 przodków ma cookie/consent marker (class/id).
      let ctx = el, depth = 0, inCtx = false;
      while (ctx && depth < 7) {
        const cn = ctx.className;
        const sig = ((typeof cn === 'string' ? cn : (cn && cn.baseVal) || '') + ' ' + (ctx.id || '')).toLowerCase();
        if (CONSENT_CTX_RE.test(sig)) { inCtx = true; break; }
        ctx = ctx.parentElement; depth++;
      }
      if (!inCtx) continue;
      try {
        el.click();
        window.ReactNativeWebView.postMessage(JSON.stringify({type: 'consent_dismissed'}));
      } catch (e) {}
    }
  };

  // Niektóre hostery wstrzykują full-screen <iframe> jako ad — usuwamy periodically.
  // Plus iframe-ad już istniejące przed naszym patchowaniem (race condition).
  const removeAdIframes = function() {
    document.querySelectorAll('iframe').forEach(function(f) {
      const src = f.src || '';
      if (isAdHost(src)) { f.remove(); return; }
      const isPlayer = /\\/(?:e|embed|player|hls)\\//.test(src);
      const isCurrentOrigin = src.includes(location.host);
      if (!isPlayer && !isCurrentOrigin && src.startsWith('http')) {
        f.remove();
      }
    });
    // AdBlock-detection overlays. Defense-in-depth dla bug-report \`02444895\` —
    // gdyby ktoś wszedł na hostera który NIE używa \`window.cRAds\` flag, usuwamy
    // div po id/klasie. Luluvid (#adbd.overdiv), streamwish/doodstream warianty.
    const ADBLOCK_OVERLAY_RE = /(^|\\s)(adbd|adblock|adb-detect|adblocker-detect|overdiv)(\\s|$)/i;
    document.querySelectorAll('#adbd, .overdiv, [class*="adblock"], [id*="adblock"]').forEach(function(d) {
      const sig = (d.id || '') + ' ' + (typeof d.className === 'string' ? d.className : '');
      if (ADBLOCK_OVERLAY_RE.test(sig)) d.remove();
    });
    // Również full-screen overlay divs (ads często overlay na video element)
    document.querySelectorAll('div[style*="z-index"], div[style*="position: fixed"], div[style*="position:fixed"]').forEach(function(d) {
      const style = d.getAttribute('style') || '';
      // Heurystyka: full-screen overlay div z high z-index + brak <video> w środku.
      if (/z-index:\\s*(\\d{4,}|2147)/.test(style) && !d.querySelector('video')) {
        const rect = d.getBoundingClientRect();
        if (rect.width > window.innerWidth * 0.7 && rect.height > window.innerHeight * 0.5) {
          d.remove();
        }
      }
    });
  };
  // -- 1.6. Play-poster auto-click -------------------------------------------
  // Bug-report 5e89ef7e (porndoe): "Trzeba wejść na porndoe, zaakceptować
  // cookies, dać Play i dopiero idzie wideo". Porndoe (i niektóre inne) NIE
  // ładują video.src do DOM dopóki user nie kliknie poster-overlay z play
  // arrow. Bez kliku player JS nie inicjalizuje, INJECTED_JS XHR sniffer się
  // nie odpala — user widzi statyczny obrazek + reklamy obok.
  //
  // Markery klasy/id "play poster": player-poster-play (porndoe), big-play-button
  // (videojs), vjs-big-play-button, jw-icon-display (jwplayer), btn-big-play,
  // mejs__overlay-button (mediaelement.js), play-button, btn-play.
  // Bezpieczeństwo: musi być wewnątrz kontenera z player marker (≤6 przodków).
  const PLAY_POSTER_RE = /(player-poster-play|player-poster-arrow|big-play-button|vjs-big-play-button|jw-icon-display|btn-big-play|mejs__overlay-button|play-button|btn-play|videoPlayButton)/i;
  const PLAYER_CTX_RE = /(player|video-js|vjs|jw-player|jwplayer|mejs|videoplayer)/i;
  const clickPlayPoster = function() {
    const els = document.querySelectorAll('button, a, div, span, [role="button"]');
    for (let i = 0; i < els.length; i++) {
      const el = els[i];
      const sig = ((typeof el.className === 'string' ? el.className : '') + ' ' + (el.id || ''));
      if (!PLAY_POSTER_RE.test(sig)) continue;
      // ≤6-deep container z player markerem.
      let ctx = el.parentElement, depth = 0, inPlayer = false;
      while (ctx && depth < 6) {
        const csig = ((typeof ctx.className === 'string' ? ctx.className : '') + ' ' + (ctx.id || '')).toLowerCase();
        if (PLAYER_CTX_RE.test(csig)) { inPlayer = true; break; }
        ctx = ctx.parentElement; depth++;
      }
      if (!inPlayer) continue;
      try {
        el.click();
        window.ReactNativeWebView.postMessage(JSON.stringify({type: 'play_poster_clicked'}));
      } catch (e) {}
    }
  };

  // Age-gate auto-accept: 4k69 ma modal "Are you 18 or above?" z przyciskiem
  // \`#pop_up_18_yes\`, który blokuje init jwplayera → INJECTED_JS nie wyłuska
  // streamu (report 5de3fbc5). Klik precyzyjnie po id — jednoznaczne, bezpieczne.
  const dismissAgeGate = function() {
    const btn = document.querySelector('#pop_up_18_yes, [id*="18_yes"], [id*="age_yes"], [id*="ageyes"]');
    if (btn) { try { btn.click(); } catch (e) {} }
  };

  setInterval(function() {
    removeAdIframes();
    dismissConsent();
    dismissAgeGate();
    clickPlayPoster();
  }, 1000);
  // Pierwsza próba consent/age-gate natychmiast (modal bywa w SSR HTML) — bez czekania
  // na pierwszy tick interwału.
  dismissConsent();
  dismissAgeGate();

  // -- 2. Auto-extract m3u8/mp4 -----------------------------------------------
  const VIDEO_RE = /https?:\\/\\/[^"'\\s<>]+\\.(?:m3u8|mp4|mpd)(?:\\?[^"'\\s<>]*)?/i;
  // Intermediate / session-bound URL-e: KVS \`get_file/...\` to NIE jest grywalny
  // strumień — to redirect (302) zależny od cookies/sesji WebView. \`<video>.src\`
  // pokazuje właśnie ten intermediate, ale ExoPlayer robi osobny request (bez cookies
  // WebView) → CDN zwraca 410 (bug yespornvip 2026-05-31). Prawdziwy CDN URL (po 302)
  // NIE pojawia się w video.src ani XHR/fetch (native media loader go ładuje), ale JEST
  // w Performance resource timing — skanujemy je niżej. Skip get_file tutaj, żeby nie
  // wysłać niegrywalnego URL-a do ExoPlayera.
  const INTERMEDIATE_RE = /\\/get_file\\//i;
  // Scrubber preview / heatmap / sprite / storyboard mp4 (np. \`..._preview_v6_a-352x220.mp4\`)
  // — NIE jest właściwym video. Wymiary NxN albo preview/heatmap markery.
  const PREVIEW_RE = /(preview|heatmap|sprite|storyboard|thumb|[?&/]\\d{2,3}x\\d{2,3}[/._])/i;
  const seen = new Set();
  function report(url) {
    if (!url || seen.has(url)) return;
    if (!VIDEO_RE.test(url)) return;
    if (INTERMEDIATE_RE.test(url)) return;  // KVS get_file — niegrywalny standalone
    if (PREVIEW_RE.test(url)) return;        // scrubber preview / heatmap clip
    if (isAdHost(url)) return;               // VAST preroll mp4 (trafostatic/bkcdn/...) — NIE content
    seen.add(url);
    try {
      window.ReactNativeWebView.postMessage(JSON.stringify({type: 'video_url', url: url}));
    } catch (e) {}
  }

  const xhrOpen = XMLHttpRequest.prototype.open;
  XMLHttpRequest.prototype.open = function(method, url) {
    report(url);
    return xhrOpen.apply(this, arguments);
  };
  const origFetch = window.fetch;
  window.fetch = function(input, init) {
    const u = typeof input === 'string' ? input : (input && input.url) || '';
    report(u);
    return origFetch.apply(this, arguments);
  };

  // -- 3. Auto-tap + DOM scan -------------------------------------------------
  let ticks = 0;
  const interval = setInterval(function() {
    ticks++;
    document.querySelectorAll('video, source').forEach(function(el) {
      if (el.src) report(el.src);
      if (el.currentSrc) report(el.currentSrc);
      // Auto-play: niektóre playery wymagają jawnego play() po click. Jeśli paused
      // i ma valid src, próbujemy odpalić.
      if (el.tagName === 'VIDEO' && el.paused && (el.src || el.currentSrc)) {
        try {
          // Start muted (UX request 2026-06-07: dźwięk dopiero po geście usera).
          // Bonus: muted autoplay jest dozwolony przez politykę przeglądarki, więc
          // video faktycznie rusza bez gestu → szybsza ekstrakcja CDN URL (potem
          // nav.replace na NativeVideoPlayer). W odsłoniętym WebView dźwięk włącza
          // user przez własne kontrolki playera hostera.
          el.muted = true;
          const p = el.play();
          if (p && p.catch) p.catch(function(){});
        } catch (e) {}
      }
    });
    // Performance resource scan — łapie REALNY CDN media URL (po 302 z get_file), który
    // <video> faktycznie pobiera, a którego nie widać w video.src/XHR/fetch. Cross-origin
    // entries mają .name (URL) czytelne mimo braku Timing-Allow-Origin. To kluczowy fix
    // dla KVS (yespornvip/freshporno/...): ExoPlayer dostaje portable CDN URL zamiast
    // session-bound get_file → koniec 410.
    try {
      const res = performance.getEntriesByType('resource');
      for (let i = 0; i < res.length; i++) report(res[i].name);
    } catch (e) {}
    // Jeśli mamy video URL i video się odpaliło, możemy zatrzymać polling.
    // Próg podniesiony 5→15: po auto-dismiss cookie consent kt_player (hqporner)
    // potrzebuje kilku sekund na init — zbyt wczesny stop łapał tylko preroll-ad
    // URL zanim pojawił się prawdziwy <source>. 15 ticków = ~15s retry window.
    if (seen.size > 0 && ticks > 15) clearInterval(interval);
    if (ticks > 90) clearInterval(interval);
  }, 1000);

  true;
})();
`;

function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
  const client = useClient();
  const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Player'>>();
  const { url, sceneId, entityKind, durationSec, refererHost } = params;
  // Dispatch dla movie vs scene progress endpoint (jak w NativeVideoPlayer).
  const upsertWatchProgress = React.useCallback(
    (body: { position_sec: number; duration_sec?: number; finished?: boolean }) =>
      entityKind === 'movie'
        ? client.upsertMovieProgress(sceneId, body)
        : client.upsertProgress(sceneId, body),
    [client, sceneId, entityKind],
  );
  const [extractedUrl, setExtractedUrl] = React.useState<string | null>(null);
  const [resolveStatus, setResolveStatus] = React.useState<'idle' | 'pending' | 'failed'>('idle');
  const [resolveError, setResolveError] = React.useState<string | null>(null);
  const [skipResolve, setSkipResolve] = React.useState(false);
  const [resolveAttempted, setResolveAttempted] = React.useState(false);
  // Anti-ads: trzymamy nieprzezroczysty cover NA WebView dopóki INJECTED_JS nie
  // wyciągnie URL — user nie widzi ad-heavy strony hostera (bug yespornvip "otwiera
  // reklamy" 2026-05-31). WebView gra pod spodem (jest malowany, media leci, scrape
  // łapie CDN). Jeśli po REVEAL_AFTER_MS autoplay nie zaskoczył (host wymaga gestu) —
  // odsłaniamy WebView żeby user mógł ręcznie tapnąć play (graceful fallback).
  const [revealEmbed, setRevealEmbed] = React.useState(false);
  React.useEffect(() => {
    if (extractedUrl) return;
    const t = setTimeout(() => setRevealEmbed(true), 15000);
    return () => clearTimeout(t);
  }, [extractedUrl]);
  const [geoHintShown, setGeoHintShown] = React.useState(false);

  const { markBroken, canMark, busy: markBusy } = useMarkSourceBroken(params);
  // WebView main-document 404/410 → strona hostera usunięta (fpo/sxyprn „404
  // którego apka nie potrafi zidentyfikować”, bug a78cc3b6). Pokazujemy overlay
  // z opcją oznaczenia źródła jako broken zamiast surowej 404-strony hostera.
  const [httpDead, setHttpDead] = React.useState<number | null>(null);
  const onHttpError = React.useCallback(
    (e: { nativeEvent?: { statusCode?: number; url?: string } }) => {
      const ne = e?.nativeEvent;
      const code = ne?.statusCode;
      if (code !== 404 && code !== 410) return;
      // Tylko główny dokument (ten sam host + ścieżka co załadowany URL) — nie
      // false-trigger na 404 ad-subresource/thumbnail z tego samego hosta.
      try {
        const a = new URL(ne!.url || '');
        const b = new URL(url);
        if (a.hostname !== b.hostname || a.pathname !== b.pathname) return;
      } catch {
        if (ne?.url !== url) return;
      }
      setHttpDead(code);
    },
    [url],
  );

  // Geo/ISP-block monit: jeśli po 25s WebView (residential IP usera) nie wyciągnął
  // wideo i nie ma 404/410, to najczęściej źródło jest blokowane w regionie/sieci
  // usera (porndoe itp. — sprawdzone: gra z innego residential IP). Jednorazowy monit
  // "to pewnie po Twojej stronie", żeby user nie myślał że apka jest zepsuta.
  React.useEffect(() => {
    if (extractedUrl || httpDead || geoHintShown || resolveStatus === 'pending') return;
    const t = setTimeout(() => {
      setGeoHintShown(true);
      Alert.alert(
        'Source not loading',
        'This source may be blocked in your region or by your network/ISP. ' +
          'It usually works on other connections — try another source on the scene, or a VPN.',
        [{ text: 'OK' }],
      );
    }, 25000);
    return () => clearTimeout(t);
  }, [extractedUrl, httpDead, geoHintShown, resolveStatus]);

  // Stage 0.8: mobile-side hoster resolvery. Mobile IP usera unika Cloudflare
  // Turnstile / CAPTCHA gate który blokuje Hetzner VPS — embed page renderuje
  // pełny HTML z player config, z którego liczymy direct m3u8/mp4 URL.
  //   - DoodStream-variant (playmogo/dood*) — pass_md5 protokół (doodstream.ts)
  //   - P.A.C.K.E.R.-JWPlayer (luluvid/streamwish) — eval-unpack (packerHoster.ts)
  //   - filemoon "Byse" SPA — JSON API + AES-256-GCM (filemoonHoster.ts)
  // Sukces → NativeVideoPlayer (bez reklam/cookie). Fail → fallback do WebView.
  React.useEffect(() => {
    if (resolveAttempted || skipResolve) return;
    (async () => {
      const source = refererHost ? `https://${refererHost.replace(/^https?:\/\//, '')}/` : undefined;
      const { isDoodStream, resolveDoodStream } = await import('../lib/doodstream');
      const { isPackerHoster, resolvePackerHoster } = await import('../lib/packerHoster');
      const { isFilemoonHoster, resolveFilemoonHoster } = await import('../lib/filemoonHoster');

      let result: { url?: string; headers?: Record<string, string>; error?: string } | null = null;
      if (isDoodStream(url)) {
        setResolveStatus('pending');
        result = await resolveDoodStream(url, source);
      } else if (isPackerHoster(url)) {
        setResolveStatus('pending');
        result = await resolvePackerHoster(url, source);
      } else if (isFilemoonHoster(url)) {
        setResolveStatus('pending');
        result = await resolveFilemoonHoster(url, source);
      }

      setResolveAttempted(true);
      if (!result) {
        return; // nie nasz hoster — WebView fallback (INJECTED_JS) zajmie się resztą
      }
      if (result.url) {
        setResolveStatus('idle');
        nav.replace('Player', {
          url: result.url,
          sceneId,
          playbackId: params.playbackId,
          entityKind,
          durationSec,
          refererHost,
          headers: result.headers,
          mode: 'video',
        });
      } else {
        setResolveStatus('failed');
        setResolveError(result.error || 'unknown');
      }
    })();
  }, [url, refererHost, sceneId, durationSec, resolveAttempted, skipResolve, nav]);

  const sourceHeaders = React.useMemo<Record<string, string>>(() => {
    const h: Record<string, string> = {};
    if (refererHost) {
      h['Referer'] = refererHost.startsWith('http') ? refererHost : `https://${refererHost}/`;
    }
    return h;
  }, [refererHost]);

  // WebView blokuje cross-origin nav — initialHostRef trzyma host pierwszego URL,
  // używany do allow-listy redirectów same-eTLD+1. DECLARED HERE (przed useEffectami
  // które go używają) żeby uniknąć ref-before-decl errors w lint.
  const initialHostRef = React.useRef<string | null>(null);
  React.useEffect(() => {
    try {
      initialHostRef.current = new URL(url).hostname;
    } catch {
      initialHostRef.current = null;
    }
  }, [url]);

  // Filter ad / preroll / midroll URLs — player JS hostów (xhamster, KVS, etc.)
  // czasem fetcha ad video PRZED rzeczywistym contentem. INJECTED_JS reportuje
  // każdy mp4/m3u8 — bierzemy pierwszy NIE-ad URL.
  const isLikelyAd = React.useCallback((u: string): boolean => {
    const low = u.toLowerCase();
    if (/\/(preroll|midroll|postroll|adroll|ad-?\d+|advert)/.test(low)) return true;
    if (/(tsyndicate|exoclick|trafficstars|adskeeper|popmyads|magsrv|adcash)\./.test(low)) return true;
    // Trailer / preview thumbnails — często `_preview.mp4` lub thumb `.jpg.mp4`
    if (/_preview\.(mp4|m3u8)/.test(low)) return true;
    if (/\.jpg\.mp4/.test(low)) return true;
    return false;
  }, []);

  const onMessage = React.useCallback((evt: WebViewMessageEvent) => {
    try {
      const data = JSON.parse(evt.nativeEvent.data);
      if (data.type === 'video_url' && typeof data.url === 'string') {
        if (isLikelyAd(data.url)) return;
        setExtractedUrl((curr) => curr ?? data.url);
      }
    } catch {
      // not JSON
    }
  }, [isLikelyAd]);

  // Gdy INJECTED_JS wyłapał czysty video URL z DOM/XHR — przerzucamy na
  // NativeVideoPlayer (expo-video / ExoPlayer) BEZ reklam, cookie consent,
  // related videos. Bez tego user widział tylko stronę hostera w WebView.
  React.useEffect(() => {
    if (!extractedUrl) return;
    nav.replace('Player', {
      url: extractedUrl,
      sceneId,
      durationSec,
      refererHost: refererHost || (initialHostRef.current || undefined),
      mode: 'video',
    });
  }, [extractedUrl, nav, sceneId, durationSec, refererHost]);

  React.useEffect(() => {
    upsertWatchProgress({ position_sec: 0, duration_sec: durationSec ?? undefined }).catch(
      () => {},
    );
  }, [upsertWatchProgress, durationSec]);

  // WebView blokuje cross-origin nav (popunder ad opens new tab/page poza embeddem).
  // Pierwsze ładowanie (`url`) ma `navigationType=undefined` lub `other` — przepuszczamy.
  // Cokolwiek innego (klik, JS redirect) z innego origin → block i log.
  // initialHostRef już declared wyżej (przed useEffect który robi nav.replace).
  const onShouldStartLoad = React.useCallback(
    (req: { url: string; navigationType?: string; isTopFrame?: boolean }) => {
      // Pierwsze załadowanie strony — przepuść.
      if (req.url === url) return true;
      // Subframes (iframe player) — przepuść BIASES wyjątek dla ad-network domen.
      // Player JS rzuca XHR-y + sub-iframe-y do CDN-ów media; ad-network subframe loads
      // (mavrtracktor/exoclick/itp.) blokujemy żeby popundery nie wstrzyknęły się.
      if (req.isTopFrame === false) {
        try {
          const host = new URL(req.url).hostname.toLowerCase();
          for (let i = 0; i < AD_HOSTS.length; i++) {
            if (host === AD_HOSTS[i] || host.endsWith('.' + AD_HOSTS[i])) return false;
          }
        } catch {
          // ignore
        }
        return true;
      }
      // Top-frame nav: pozwól na same-eTLD+1 (paradisehill.cc → en.paradisehill.cc,
      // pervl3.io → pervl5.io); blokuj cross-domain nav (adware popundery).
      try {
        const target = new URL(req.url).hostname.toLowerCase();
        const initial = initialHostRef.current?.toLowerCase();
        if (initial && target === initial) return true;
        if (initial) {
          const tParts = target.split('.');
          const iParts = initial.split('.');
          if (tParts.length >= 2 && iParts.length >= 2 &&
              tParts.slice(-2).join('.') === iParts.slice(-2).join('.')) {
            return true;
          }
        }
      } catch {
        // ignore
      }
      return false;
    },
    [url],
  );

  // DoodStream resolver in-flight — pokaż spinner zamiast WebView
  if (resolveStatus === 'pending') {
    return (
      <View style={[styles.root, styles.overlay]}>
        <ActivityIndicator color={theme.fg} size="large" />
        <Text style={styles.overlayText}>Resolving direct link...</Text>
      </View>
    );
  }

  // Resolver failed — pokaż error + przycisk żeby przejść do WebView
  if (resolveStatus === 'failed' && !skipResolve) {
    return (
      <View style={[styles.root, styles.overlay]}>
        <Text style={[styles.overlayText, { fontSize: 16, marginBottom: 6 }]}>
          DoodStream resolver fail
        </Text>
        <Text style={[styles.overlayText, { fontSize: 12, opacity: 0.7, marginBottom: 20 }]}>
          {resolveError || 'unknown'}
        </Text>
        <Pressable
          onPress={() => setSkipResolve(true)}
          style={{
            backgroundColor: theme.accent,
            paddingHorizontal: 24,
            paddingVertical: 10,
            borderRadius: 8,
          }}
        >
          <Text style={{ color: theme.fg, fontWeight: '600' }}>Open in WebView</Text>
        </Pressable>
      </View>
    );
  }

  return (
    <View style={styles.root}>
      <WebView
        source={{ uri: url, headers: sourceHeaders }}
        style={styles.video}
        userAgent={DEFAULT_UA}
        injectedJavaScriptBeforeContentLoaded={INJECTED_JS}
        onMessage={onMessage}
        onShouldStartLoadWithRequest={onShouldStartLoad}
        onHttpError={onHttpError}
        setSupportMultipleWindows={false}
        allowsInlineMediaPlayback
        mediaPlaybackRequiresUserAction={false}
        javaScriptEnabled
        domStorageEnabled
        thirdPartyCookiesEnabled
        mixedContentMode="always"
        allowsFullscreenVideo
        startInLoadingState
        renderLoading={() => (
          <View style={styles.overlay} pointerEvents="none">
            <ActivityIndicator color={theme.fg} size="large" />
            <Text style={styles.overlayText}>Loading hoster…</Text>
          </View>
        )}
      />
      {!extractedUrl && !revealEmbed && !httpDead && (
        <View style={styles.coverOverlay}>
          <ActivityIndicator color={theme.fg} size="large" />
          <Text style={styles.overlayText}>Loading video…</Text>
        </View>
      )}
      {httpDead && (
        <View style={[styles.coverOverlay, styles.overlay]}>
          <Text style={styles.errorTitle}>Source no longer available</Text>
          <Text style={styles.errorBody}>
            The host returned {httpDead} — this video was removed.
          </Text>
          <View style={styles.errorBtnRow}>
            {canMark && (
              <Pressable style={[styles.btn, styles.btnDanger]} onPress={markBroken} disabled={markBusy}>
                <Text style={styles.btnText}>{markBusy ? 'Marking…' : 'Mark broken'}</Text>
              </Pressable>
            )}
            <Pressable style={styles.btn} onPress={() => setHttpDead(null)}>
              <Text style={styles.btnText}>Try anyway</Text>
            </Pressable>
            <Pressable style={styles.btn} onPress={() => nav.goBack()}>
              <Text style={styles.btnText}>Back</Text>
            </Pressable>
          </View>
        </View>
      )}
      {extractedUrl && (
        <Pressable
          style={styles.extractBanner}
          onPress={() =>
            nav.replace('Player', {
              url: extractedUrl,
              sceneId,
              durationSec,
              refererHost,
              mode: 'video',
            })
          }
        >
          <Text style={styles.extractText}>▶ Open in native player</Text>
          <Text style={styles.extractSub}>{extractedUrl.slice(0, 80)}</Text>
        </Pressable>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  root: { flex: 1, backgroundColor: '#000' },
  video: { flex: 1, backgroundColor: '#000' },
  overlay: {
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
    bottom: 0,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: 'rgba(0,0,0,0.6)',
    paddingHorizontal: 32,
  },
  // Nieprzezroczysty cover na WebView — ukrywa ad-heavy stronę hostera podczas gdy
  // INJECTED_JS auto-play + scrape działają pod spodem. Solidne tło (nie alpha) =
  // zero podglądu reklam.
  coverOverlay: {
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
    bottom: 0,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: theme.bg,
    paddingHorizontal: 32,
  },
  overlayText: { color: theme.fg, marginTop: 12, fontSize: 13, textAlign: 'center' },
  errorTitle: { color: theme.bad, fontSize: 18, fontWeight: '700', marginBottom: 8 },
  errorBody: { color: theme.fg, fontSize: 14, marginBottom: 16, textAlign: 'center' },
  btn: {
    backgroundColor: theme.accent,
    paddingHorizontal: 22,
    paddingVertical: 10,
    borderRadius: 10,
  },
  btnText: { color: theme.fg, fontWeight: '700' },
  errorBtnRow: { flexDirection: 'row', gap: 12 },
  btnDanger: { backgroundColor: theme.bad },
  extractBanner: {
    position: 'absolute',
    bottom: 24,
    left: 12,
    right: 12,
    backgroundColor: theme.accent,
    paddingHorizontal: 16,
    paddingVertical: 12,
    borderRadius: 12,
  },
  extractText: { color: theme.fg, fontWeight: '700', fontSize: 15 },
  extractSub: { color: theme.fg, opacity: 0.85, fontSize: 11, marginTop: 2 },

  // ----- custom controls overlay --------------------------------------------
  controlsTop: {
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
    paddingTop: 28,
    paddingHorizontal: 14,
    paddingBottom: 12,
    flexDirection: 'row',
    alignItems: 'center',
    gap: 12,
    backgroundColor: 'rgba(0,0,0,0.45)',
  },
  controlsCenter: {
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
    bottom: 0,
    alignItems: 'center',
    justifyContent: 'center',
  },
  controlsBottom: {
    position: 'absolute',
    bottom: 0,
    left: 0,
    right: 0,
    paddingTop: 10,
    paddingBottom: 18,
    paddingHorizontal: 14,
    flexDirection: 'row',
    alignItems: 'center',
    gap: 10,
    backgroundColor: 'rgba(0,0,0,0.45)',
  },
  iconBtn: {
    width: 36,
    height: 36,
    alignItems: 'center',
    justifyContent: 'center',
  },
  iconText: { color: theme.fg, fontSize: 22, fontWeight: '600' },
  titleText: {
    flex: 1,
    color: theme.fg,
    fontSize: 14,
    fontWeight: '600',
  },
  playBtn: {
    width: 76,
    height: 76,
    borderRadius: 38,
    backgroundColor: 'rgba(0,0,0,0.55)',
    alignItems: 'center',
    justifyContent: 'center',
  },
  playBtnText: { color: theme.fg, fontSize: 26, fontWeight: '700' },
  timeText: {
    color: theme.fg,
    fontSize: 12,
    fontVariant: ['tabular-nums'],
    minWidth: 44,
    textAlign: 'center',
  },
  // Scrubber: 48px hit-box (≥ Material's 48dp tap target). Track + fill + thumb
  // wszystkie absolute z explicit `top`, żeby były wycentrowane wzgl. siebie
  // i przeżyły zmianę wysokości containera bez rozjazdu.
  scrubTouchArea: {
    flex: 1,
    height: 48,
  },
  scrubTrack: {
    position: 'absolute',
    left: 0,
    right: 0,
    top: 21,
    height: 6,
    backgroundColor: 'rgba(255,255,255,0.28)',
    borderRadius: 3,
  },
  scrubFill: {
    position: 'absolute',
    left: 0,
    top: 21,
    height: 6,
    backgroundColor: theme.accent,
    borderRadius: 3,
  },
  scrubThumb: {
    position: 'absolute',
    width: 20,
    height: 20,
    borderRadius: 10,
    backgroundColor: theme.accent,
    marginLeft: -10,
    top: 14,
    // subtle outer ring żeby thumb był widoczny na jasnym wideo
    borderWidth: 2,
    borderColor: 'rgba(0,0,0,0.45)',
  },
  seekBubble: {
    position: 'absolute',
    top: '50%',
    marginTop: -22,
    paddingVertical: 10,
    paddingHorizontal: 16,
    backgroundColor: 'rgba(0,0,0,0.7)',
    borderRadius: 22,
  },
  seekBubbleLeft: { left: '15%' },
  seekBubbleRight: { right: '15%' },
  seekBubbleText: { color: theme.fg, fontSize: 16, fontWeight: '700' },
  // Pan-seek preview bubble — duży, centered, dwie linie: target time + delta.
  panSeekBubble: {
    position: 'absolute',
    top: '40%',
    alignSelf: 'center',
    left: 0,
    right: 0,
    alignItems: 'center',
    justifyContent: 'center',
  },
  panSeekTime: {
    color: theme.fg,
    fontSize: 28,
    fontWeight: '700',
    fontVariant: ['tabular-nums'],
    backgroundColor: 'rgba(0,0,0,0.7)',
    paddingHorizontal: 18,
    paddingVertical: 8,
    borderRadius: 12,
    overflow: 'hidden',
  },
  panSeekDelta: {
    color: theme.accent,
    fontSize: 14,
    fontWeight: '700',
    marginTop: 6,
    fontVariant: ['tabular-nums'],
  },
  speedPill: {
    position: 'absolute',
    top: 80,
    alignSelf: 'center',
    left: 0,
    right: 0,
    flexDirection: 'row',
    justifyContent: 'center',
  },
  speedPillText: {
    color: theme.fg,
    fontSize: 14,
    fontWeight: '700',
    backgroundColor: 'rgba(0,0,0,0.7)',
    paddingVertical: 6,
    paddingHorizontal: 14,
    borderRadius: 14,
    overflow: 'hidden',
  },
});
