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, 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; // '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// // 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; 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>(); 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] }); } queryClient.invalidateQueries({ queryKey: ['scenes'] }); } 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>(); 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, 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 = { '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); // 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]); 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); } catch { // replace failed — przepuść do fallback chain niżej } 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, 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, playbackId: params.playbackId, entityKind, durationSec, refererHost, title, mode: 'webview', }); } }, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title, player, source, playerError]); 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); // 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 | 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], ); // 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(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 `