goon/mobile/src/screens/PlayerScreen.tsx
https://github.com/goon-foss/goon feef312e8d Mobile: P.A.C.K.E.R. hoster resolver (luluvid/streamwish)
Backend (VPS IP) dostaje CAPTCHA od luluvid/streamwish → try_extract
zwraca type='hoster' → WebView fallback ze stroną+reklamami. Mobile IP
usera renderuje pełny embed z packed JWPlayer config.

- packerHoster.ts: port unpack_packer (hoster.py) do TS — eval-unpack
  P.A.C.K.E.R. → JWPlayer sources file URL, ad-roll filter
- PlayerScreen: resolve useEffect probuje DoodStream LUB P.A.C.K.E.R.
  → sukces = NativeVideoPlayer bez reklam, fail = WebView fallback

Naprawia latestpornvideo (luluvid) — bug 02444895 "Luluvid czarny ekran".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 11:40:03 +02:00

1316 lines
49 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 { useEvent } from 'expo';
import * as Haptics from 'expo-haptics';
import * as ScreenOrientation from 'expo-screen-orientation';
import { useVideoPlayer, VideoView, type VideoSource } from 'expo-video';
import React from 'react';
import {
ActivityIndicator,
Animated,
LayoutChangeEvent,
Pressable,
StatusBar,
StyleSheet,
Text,
View,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
interface RouteParams {
url: string;
sceneId: string;
durationSec?: number | null;
refererHost?: string;
title?: string;
mode?: 'video' | 'webview';
fallbackEmbedUrl?: string;
}
const DEFAULT_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
const VIDEO_EXT_RE = /\.(mp4|m3u8|mpd|webm|ts|mov)(?:[?/#]|$)/i;
const PLAYER_PAGE_RE = /(?:\/player\/|\/embed\/|\/e\/|\.php(?:[?/#]|$))/i;
// Ad-network domains — block at subframe load + match w INJECTED_JS adblock.
// Sync z app/extractors/tubes/_embed_iframe.py:AD_DOMAIN_RE.
const AD_HOSTS = [
'hoirms.com', 'propellerads.com', 'popads.net', 'popcash.net', 'trafficstars.com',
'exoclick.com', 'adsterra.com', 'happyleafmotion.com', 'adskeeper.com',
'hilltopads.com', 'juicyads.com', 'trafficjunky.net', 'adblade.com',
'mavrtracktor.com', 'adtng.com', 'bluetrafficstream.com', 'smartpop.io',
'mypornclub.com', 'cdntrafficstars.com', 'trafficfactory.biz', 'popcrn.com',
'popmyads.com', 'adcash.com', 'chaturbate.com', 'stripchat.com', 'streamate.com',
'willingcease.com', 'doubleclick.net', 'googlesyndication.com',
'google-analytics.com', 'googletagmanager.com',
'traffichaus.com', 'plugrush.com', 'clickadu.com',
'redirectvoluum.com', 'clickaine.com', 'hilltopads.net', 'popshq.com',
];
function detectMode(url: string): 'video' | 'webview' {
if (VIDEO_EXT_RE.test(url)) return 'video';
if (PLAYER_PAGE_RE.test(url)) return 'webview';
if (/\/proxy\//.test(url)) return 'video';
return 'video';
}
export function PlayerScreen() {
const route = useRoute<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, durationSec, refererHost, title, fallbackEmbedUrl, headers: paramHeaders, fallbackProxyUrl } = params;
const source: VideoSource = React.useMemo(() => {
// Backend dostarczone headers (Referer + UA z extractor) mają precedencję —
// CDN binduje URL do konkretnego Referera embed page'a (np. watchporn.to dla
// 0dayxx scen). Bez tego CDN zwraca 410.
if (paramHeaders) {
return { uri: url, headers: paramHeaders };
}
// Legacy fallback: stary path z refererHost (proxy URL nie wymaga headers).
const hdr: Record<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;
p.play();
});
const statusEvent = useEvent(player, 'statusChange', { status: player.status });
const status = statusEvent?.status ?? player.status;
const playerError = statusEvent?.error;
const playingEvent = useEvent(player, 'playingChange', { isPlaying: player.playing });
const isPlaying = playingEvent?.isPlaying ?? player.playing;
// Auto-fallback na WebView gdy native ExoPlayer dostanie błąd, a backend dostarczył
// embed URL. Najczęstsza przyczyna: IP-bound CDN URL (luluvids/iceyfile/tnmr) —
// backend extracted z VPS IP, mobile dostaje 403. WebView fetch'uje URL we własnym
// kontekście strony (cookies, Chrome JA3, IP w session) → działa.
// Fallback chain (na error):
// 1. direct CDN URL z headers — preferred (0 VPS bandwidth)
// 2. → proxy URL (VPS re-fetchuje, streamuje do mobile) — gdy CDN IP-bound
// 3. → WebView z embed URL — gdy ExoPlayer w ogóle nie radzi sobie z formatem
// Każdy step ma osobną ref żeby nie loopować.
const didFallbackProxyRef = React.useRef(false);
const didFallbackWebViewRef = React.useRef(false);
React.useEffect(() => {
if (status !== 'error') return;
// Step 1 → 2: direct fail (403/410/etc), spróbuj proxy URL.
if (fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) {
didFallbackProxyRef.current = true;
nav.replace('Player', {
url: fallbackProxyUrl,
sceneId,
durationSec,
refererHost,
title,
// Proxy URL nie wymaga headers (proxy sam dodaje Referer przy upstream fetch).
headers: undefined,
fallbackProxyUrl: undefined, // już użyty
fallbackEmbedUrl, // zostawiamy do step 3
});
return;
}
// Step 2 → 3: proxy też failed, spróbuj WebView z embed URL.
if (fallbackEmbedUrl && !didFallbackWebViewRef.current) {
didFallbackWebViewRef.current = true;
nav.replace('Player', {
url: fallbackEmbedUrl,
sceneId,
durationSec,
refererHost,
title,
mode: 'webview',
});
}
}, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title]);
const lastReportedRef = React.useRef(0);
// Lokalny tick co 500ms — driver dla custom scrubber + time labels. expo-video
// ma `timeUpdate` event ale firuje z mniejszą częstotliwością niż chcemy dla UI.
const [position, setPosition] = React.useState(0);
const [knownDuration, setKnownDuration] = React.useState<number>(durationSec ?? 0);
React.useEffect(() => {
const tick = setInterval(() => {
try {
const pos = player.currentTime || 0;
setPosition(pos);
const dur = player.duration || 0;
if (dur > 0 && Math.abs(dur - knownDuration) > 0.5) setKnownDuration(dur);
const posInt = Math.floor(pos);
const durInt = Math.floor(dur || durationSec || 0) || null;
if (posInt > 0 && Math.abs(posInt - lastReportedRef.current) >= 10) {
lastReportedRef.current = posInt;
client
.upsertProgress(sceneId, {
position_sec: posInt,
duration_sec: durInt ?? undefined,
// "Watched" = email-style "read": min. 30s aktywnej odtwarzania (bo 1 sec
// to przypadkowy klik). Tile z `finished=true` jest dim'owany w listach
// jako sygnał "to widziałem już". Nie chcemy threshold 95% — to nie Netflix,
// user przegląda materiał, decyduje po pierwszych sekundach.
finished: posInt >= 30,
})
.catch(() => {});
}
} catch {
// disposed
}
}, 500);
return () => {
clearInterval(tick);
try {
const pos = Math.floor(player.currentTime || 0);
const dur = Math.floor(player.duration || durationSec || 0) || null;
if (pos > 0) {
client
.upsertProgress(sceneId, {
position_sec: pos,
duration_sec: dur ?? undefined,
finished: pos >= 30,
})
.catch(() => {});
}
} catch {
// best-effort
}
};
}, [player, sceneId, durationSec, client, knownDuration]);
// ----- controls visibility (auto-hide po 3.5s bez interakcji) ----------------
const [controlsVisible, setControlsVisible] = React.useState(true);
const fade = React.useRef(new Animated.Value(1)).current;
const hideTimerRef = React.useRef<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(() => {});
setControlsVisible(true);
})
.runOnJS(true),
[player, showSeekHint],
);
// Long-press → 2× speed dopóki trzymasz. minDuration 220ms żeby nie konfliktowało
// z double-tap (drugi tap trwa krócej). onTouchesUp finalizuje gest.
const longPress = React.useMemo(
() =>
Gesture.LongPress()
.minDuration(220)
.maxDistance(30)
.onStart(() => {
try {
player.playbackRate = 2.0;
} catch {
// ignore
}
setSpeedActive(true);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
})
// onEnd palpi gdy gesture state → END (palec puszczony NORMALNIE).
// onFinalize to safety-net dla CANCELLED/FAILED — np. gdy systemowy
// gesture (scroll, swipe back) przejmie palec. Bez tego playbackRate
// zostawał na 2.0 i user musiał drugi raz tapnąć żeby wyłączyć
// (zgłoszone 2026-05-10, bug-report #f53e50b9). Wcześniejszy kod
// używał nieudokumentowanego `.onTouchesUp/Cancelled` które na LongPress
// gesture nie wywoływały się w wszystkich scenariuszach.
.onEnd(() => {
try {
player.playbackRate = 1.0;
} catch {
// ignore
}
setSpeedActive(false);
})
.onFinalize(() => {
try {
if (player.playbackRate !== 1.0) {
player.playbackRate = 1.0;
}
} catch {
// ignore
}
setSpeedActive(false);
})
.runOnJS(true),
[player],
);
const dur = knownDuration || durationSec || 0;
const panSeek = React.useMemo(
() =>
Gesture.Pan()
// Aktywacja dopiero po 20px w bok — niżej myliłoby się z drgnięciem palca
// przy tap. Pionowy ruch >40px abortuje (rezerwa na future zoom/brightness).
.activeOffsetX([-20, 20])
.failOffsetY([-40, 40])
.onStart(() => {
cancelHide();
panStartTimeRef.current = player.currentTime || 0;
setControlsVisible(true);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
})
.onUpdate((e) => {
const w = layoutWidthRef.current;
if (w <= 0 || dur <= 0) return;
const deltaSec = (e.translationX / w) * dur;
const target = Math.max(0, Math.min(dur, panStartTimeRef.current + deltaSec));
setPanSeekTarget(target);
})
.onEnd((e) => {
const w = layoutWidthRef.current;
if (w > 0 && dur > 0) {
const deltaSec = (e.translationX / w) * dur;
const target = Math.max(0, Math.min(dur, panStartTimeRef.current + deltaSec));
try {
player.currentTime = target;
} catch {
// ignore
}
}
setPanSeekTarget(null);
scheduleHide();
})
.onFinalize((_e, success) => {
if (!success) setPanSeekTarget(null);
})
.runOnJS(true),
[player, dur, cancelHide, scheduleHide],
);
// Race: pierwszy aktywny gest wygrywa. Single-tap musi czekać aż double-tap fail.
// panSeek na początku — gdy palec ruszy >20px, wygrywa nad tap/long-press.
// longPress PRZED doubleTap: Exclusive priorytetyzuje po kolejności, a doubleTap
// ma maxDelay=280ms "waiting state" który blokował longPress (minDuration=220ms)
// — palec trzymany 220ms nigdy nie aktywował 2x speed bo doubleTap wciąż "myślał"
// czy będzie drugi tap. Bug-report 2026-05-16 #7c13a549/#cdff6341 (eporner/hqporner).
const composedGesture = React.useMemo(
() => Gesture.Exclusive(panSeek, longPress, doubleTap, singleTap),
[panSeek, longPress, doubleTap, singleTap],
);
// ----- scrubber pan ---------------------------------------------------------
const [scrubX, setScrubX] = React.useState<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={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}>Playback failed</Text>
<Text style={styles.errorBody}>
{playerError?.message ?? 'Stream nie odpalił się.'}
</Text>
<Pressable style={styles.btn} onPress={() => nav.goBack()}>
<Text style={styles.btnText}>Back</Text>
</Pressable>
</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;
// -- 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',
];
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();
}
});
// 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();
}
}
});
};
setInterval(function() {
removeAdIframes();
dismissConsent();
}, 1000);
// Pierwsza próba consent natychmiast (banner bywa w SSR HTML) — bez czekania
// na pierwszy tick interwału.
dismissConsent();
// -- 2. Auto-extract m3u8/mp4 -----------------------------------------------
const VIDEO_RE = /https?:\\/\\/[^"'\\s<>]+\\.(?:m3u8|mp4|mpd)(?:\\?[^"'\\s<>]*)?/i;
const seen = new Set();
function report(url) {
if (!url || seen.has(url)) return;
if (!VIDEO_RE.test(url)) return;
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 {
el.muted = false;
const p = el.play();
if (p && p.catch) p.catch(function(){});
} 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, durationSec, refererHost } = params;
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);
// 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)
// 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');
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);
}
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,
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(() => {
client
.upsertProgress(sceneId, { position_sec: 0, duration_sec: durationSec ?? undefined })
.catch(() => {});
}, [client, sceneId, 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}>Resolwuję bezpośredni 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' }}>Otwórz w 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}
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 && (
<Pressable
style={styles.extractBanner}
onPress={() =>
nav.replace('Player', {
url: extractedUrl,
sceneId,
durationSec,
refererHost,
mode: 'video',
})
}
>
<Text style={styles.extractText}> Otwórz w native playerze</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,
},
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' },
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',
},
});