sxyprn's video token is bound to the IP that fetched the post page; on mobile the phone resolver works ~74% but ~26% fail when the egress IP shifts (CGNAT / network switch) or the token goes stale → native player hung on a dead URL (18 reports, 26% error rate in telemetry). Now on an initial-load error for these phone-resolved tubes, the player re-fetches the page fresh (new token bound to the current IP) and swaps the source before falling through to the proxy/WebView chain. Zero VPS bandwidth. Gated by resolvePageUrl so other tubes are completely unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1767 lines
72 KiB
TypeScript
1767 lines
72 KiB
TypeScript
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]);
|
||
|
||
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;
|
||
}
|
||
// Gate: dla IP-bound tubów (resolvePageUrl) poczekaj aż re-resolve się zakończy
|
||
// zanim ruszysz proxy/WebView. No-op gdy brak resolvePageUrl (reszta tubów).
|
||
if (resolvePageUrl && !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, 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;
|
||
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<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;
|
||
const displayedTime =
|
||
scrubbingRatio !== null
|
||
? scrubbingRatio * dur
|
||
: panSeekTarget !== null
|
||
? panSeekTarget
|
||
: position;
|
||
|
||
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>
|
||
|
||
<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' && !fallbackEmbedUrl && (
|
||
<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>
|
||
)}
|
||
{status === 'error' && fallbackEmbedUrl && (
|
||
<View style={styles.overlay} pointerEvents="none">
|
||
<ActivityIndicator color={theme.fg} size="large" />
|
||
<Text style={styles.overlayText}>Native player failed — switching to embed…</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;
|
||
|
||
// -- 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',
|
||
},
|
||
});
|