goon/mobile/src/screens/PlayerScreen.tsx
jtrzupek 813bf741b9 fix(mobile): re-resolve IP-bound tubes on playback error (sxyprn/eporner/fpoxxx)
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>
2026-06-25 11:11:21 +02:00

1767 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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',
},
});