import type { RouteProp } from '@react-navigation/native'; import { useNavigation, useRoute } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; 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, 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'; interface RouteParams { url: string; sceneId: string; durationSec?: number | null; refererHost?: string; title?: string; mode?: 'video' | 'webview'; fallbackEmbedUrl?: string; } 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>(); 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 ; return ; } function NativeVideoPlayer({ params }: { params: RouteParams }) { const client = useClient(); const nav = useNavigation>(); const { url, sceneId, durationSec, refererHost, title, fallbackEmbedUrl, headers: paramHeaders, fallbackProxyUrl } = params; 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 = { '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; p.play(); }); 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); React.useEffect(() => { if (status !== 'error') 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, 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, durationSec, refererHost, title, mode: 'webview', }); } }, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title]); 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(durationSec ?? 0); React.useEffect(() => { const tick = setInterval(() => { try { const pos = player.currentTime || 0; setPosition(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; client .upsertProgress(sceneId, { 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) { client .upsertProgress(sceneId, { position_sec: pos, duration_sec: dur ?? undefined, finished: pos >= 30, }) .catch(() => {}); } } catch { // best-effort } }; }, [player, sceneId, durationSec, client, 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 | 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 | 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(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(() => {}); setControlsVisible(true); }) .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; setControlsVisible(true); 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], ); // Race: pierwszy aktywny gest wygrywa. Single-tap musi czekać aż double-tap fail. // panSeek na początku — gdy palec ruszy >20px, wygrywa nad tap/long-press. // longPress PRZED doubleTap: Exclusive priorytetyzuje po kolejności, a doubleTap // ma maxDelay=280ms "waiting state" który blokował longPress (minDuration=220ms) // — palec trzymany 220ms nigdy nie aktywował 2x speed bo doubleTap wciąż "myślał" // czy będzie drugi tap. Bug-report 2026-05-16 #7c13a549/#cdff6341 (eporner/hqporner). const composedGesture = React.useMemo( () => Gesture.Exclusive(panSeek, longPress, doubleTap, singleTap), [panSeek, longPress, doubleTap, singleTap], ); // ----- scrubber pan --------------------------------------------------------- const [scrubX, setScrubX] = React.useState(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; const displayedTime = scrubbingRatio !== null ? scrubbingRatio * dur : panSeekTarget !== null ? panSeekTarget : position; return ( {/* Hidden status bar — full-bleed video w landscape bez 24px paska systemu. */} ); } 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 `