fix(mobile): tablety w poziomie, porndoe w stronie po 403
Some checks are pending
Backend tests / test (push) Waiting to run

Tablety: obrot odblokowany przy starcie aplikacji, odtwarzacz go nie rusza.
Wczesniej aplikacja byla pionowym paskiem, a odblokowanie w odtwarzaczu
zmienialo najmniejszy wymiar okna, aktywnosc byla tworzona od nowa i
ExoPlayer zwalniany po 2 s. Manifest dostaje smallestScreenSize (nastepne APK).

porndoe: po bledzie w ExoPlayerze gramy w stronie zamiast wyciagac adres.
canPlayType dla HLS zwraca pusto, bo WebView mowi "maybe", a nie gra (strona
bierze wtedy wlasne hls.js). Sukces liczy sie od filmu dluzszego niz minuta,
nie od reklamy. Zapamietujemy tryb na 7 dni dopiero po udanym odtworzeniu.
Telemetria via=page, blad page_no_play po 25 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-10-12 10:59:19 +02:00
parent 0def1a3619
commit ffb4ecda18
8 changed files with 219 additions and 10 deletions

View file

@ -35,6 +35,7 @@ import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
import { APP_VERSION, NATIVE_RUNTIME, isNewerVersion, nativeBuildNumber } from './src/lib/appVersion';
import { DEFAULT_API_KEY, DEFAULT_BACKEND_URL } from './src/lib/backend';
import { getSettings as getLockSettings } from './src/lib/applock';
import { unlockOrientationOnTablet } from './src/lib/tablet';
import { AppNavigator } from './src/navigation';
import { AgeGateScreen } from './src/screens/AgeGateScreen';
import { AppLockScreen } from './src/screens/AppLockScreen';
@ -163,6 +164,11 @@ export default function App() {
const [installing, setInstalling] = useState(false);
const backgroundedAt = useRef<number | null>(null);
// Tablet: obrot odblokowany od startu, zanim cokolwiek zagra (opis w src/lib/tablet.ts).
useEffect(() => {
unlockOrientationOnTablet();
}, []);
useEffect(() => {
(async () => {
try {

View file

@ -49,7 +49,7 @@
gdy Chromium probowal podpiac skaner kodow (GOON-3B, 3 crashe od lipca). -->
<meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/>
<activity android:name="com.reactnative.googlecast.RNGCExpandedControllerActivity"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait" android:resizeableActivity="true">
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait" android:resizeableActivity="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
@ -64,7 +64,7 @@
<!-- Android TV. Bez wpisu LEANBACK_LAUNCHER apka nie pojawia sie na ekranie glownym
telewizora, mimo ze instaluje sie bez bledu. Banner (320x180) jest w <application>.
Osobna aktywnosc, bo tu NIE MA blokady pionu, patrz TvMainActivity. -->
<activity android:name=".TvMainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:resizeableActivity="true">
<activity android:name=".TvMainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:resizeableActivity="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>

View file

@ -35,6 +35,14 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-10-12b',
date: 'October 2026',
items: [
'Tablets: the app fills the whole screen in landscape instead of a narrow strip in the middle, and videos keep playing when you rotate the tablet or switch to fullscreen.',
'porndoe: when a video refuses to load in the player on your network, it now plays on the porndoe page itself, with sound. If that works, the app goes straight to the page for that site for the next week.',
],
},
{
id: '2026-10-12a',
date: 'October 2026',

View file

@ -0,0 +1,50 @@
/**
* Zrodla grane "w stronie" na tym urzadzeniu: WebView gra sam, bez wyciagania adresu do
* ExoPlayera.
*
* Po co: porndoe podpisuje link pod adres, z ktorego przyszlo zapytanie. Na czesci sieci
* (2026-10-12: 59 urzadzen, zawsze 403, 0 sukcesow) link pobrany na telefonie nie gra
* w ExoPlayerze. Prawdopodobna przyczyna to rozjazd IPv4/IPv6: z porndoe.com telefon laczy sie przez
* IPv6, z ich CDN przez IPv4 (albo odwrotnie). Na emulatorze z IPv6 przez 6to4 to samo trafia
* Chromium, wiec strona tez nie jest pewniakiem: jest zapasem po 403 i dopiero gdy film
* w stronie faktycznie ruszy, kolejne odtworzenia tego zrodla ida od razu do strony.
* Po 7 dniach od tego sukcesu znowu probujemy natywnie (siec moze sie zmienic).
* Telemetria: via='page' (success albo error page_no_play) w playback_events.
*/
import * as SecureStore from 'expo-secure-store';
const KEY = 'goon.page_mode_origins';
const TTL_MS = 7 * 24 * 3600 * 1000;
/** Zrodla, ktore po padnietym odtwarzaczu przechodza do grania w stronie. */
export const PAGE_MODE_ORIGINS = ['tube:porndoecom'];
let _cache: Record<string, number> | null = null;
async function load(): Promise<Record<string, number>> {
if (_cache) return _cache;
try {
const raw = await SecureStore.getItemAsync(KEY);
_cache = raw ? (JSON.parse(raw) as Record<string, number>) : {};
} catch {
_cache = {};
}
return _cache;
}
export async function prefersPageMode(origin: string): Promise<boolean> {
if (!PAGE_MODE_ORIGINS.includes(origin)) return false;
const m = await load();
const at = m[origin];
return !!at && Date.now() - at < TTL_MS;
}
export async function rememberPageMode(origin: string): Promise<void> {
const m = await load();
m[origin] = Date.now();
try {
await SecureStore.setItemAsync(KEY, JSON.stringify(m));
} catch {
// zostaje w pamieci do konca sesji
}
}

30
mobile/src/lib/tablet.ts Normal file
View file

@ -0,0 +1,30 @@
/**
* Tablety: obrot calej aplikacji zamiast pionu na sztywno.
*
* MainActivity ma w manifescie screenOrientation="portrait", wiec na tablecie trzymanym w
* poziomie aplikacja byla waskim pionowym paskiem posrodku ekranu (zgloszenie 923df1ae,
* Samsung SM-X400). Do tego odtwarzacz zwalnial blokade pionu i okno rozszerzalo sie z paska
* na caly ekran. Zmienial sie wtedy najmniejszy wymiar ekranu, ktorego aktywnosc w APK 0.4.2
* nie obsluguje sama (brak smallestScreenSize w configChanges), wiec Android tworzyl ja od nowa
* i odtwarzacz byl zwalniany po 2 sekundach: "w poziomie film nie gra" (zgloszenie e78a1194,
* Xiaomi Pad 7; odtworzone na emulatorze Pixel Tablet, w logu "relaunch" i Release ExoPlayera).
*
* Odblokowujemy obrot raz, przy starcie aplikacji, kiedy nic jeszcze nie gra (zostaje jedno
* przebudowanie aktywnosci na starcie). Odtwarzacz na tablecie potem niczego nie przestawia,
* a zwykly obrot nie zmienia najmniejszego wymiaru, wiec film gra dalej (sprawdzone na APK
* 0.4.2). Blokade obrotu z ustawien Androida system respektuje (UNSPECIFIED). Manifest ma juz
* smallestScreenSize w configChanges; dojdzie z nastepnym APK i zdejmie tez to przebudowanie
* na starcie oraz przy dzieleniu ekranu.
*/
import { Dimensions } from 'react-native';
import * as ScreenOrientation from 'expo-screen-orientation';
/** Krotszy bok ekranu >= 600 dp, typowa granica tabletu na Androidzie. */
export const IS_TABLET = (() => {
const { width, height } = Dimensions.get('screen');
return Math.min(width, height) >= 600;
})();
export function unlockOrientationOnTablet(): void {
if (IS_TABLET) ScreenOrientation.unlockAsync().catch(() => {});
}

View file

@ -120,6 +120,8 @@ export type RootStackParamList = {
// telefonie w SceneDetail), 'backend', 'hoster' (resolver hostera w Playerze),
// 'webview' (wyciagniety ze strony), 'proxy' (zapasowe proxy), 'movie'.
via?: string;
// WebView gra sam w stronie, bez wyciagania adresu do ExoPlayera (patrz lib/pageMode.ts).
inPage?: boolean;
};
};

View file

@ -32,6 +32,8 @@ import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import type { StreamLink } from '../types';
import { PAGE_MODE_ORIGINS, rememberPageMode } from '../lib/pageMode';
import { IS_TABLET } from '../lib/tablet';
interface RouteParams {
/** Lista jakości tego samego wideo → przełącznik w playerze (user-request 89845707). */
@ -64,6 +66,8 @@ interface RouteParams {
fallbackEmbedUrl?: string;
// Skad przyszedl adres, do telemetrii (patrz RootStackParamList.Player.via).
via?: string;
// Graj w stronie, bez wyciagania adresu do ExoPlayera (lib/pageMode.ts).
inPage?: boolean;
}
// Wspólny helper dla native + WebView ekranu błędu: oznacza playback source jako
@ -241,8 +245,15 @@ function PlayerScreenActive() {
// (Settings → Playback), player zostaje w pionie, telefon leżący płasko na stole
// nie skacze między orientacjami (bug-report 55da1c3f). Przycisk pełnego ekranu dalej
// pozwala wejść w poziom ręcznie, więc wyłączenie nie odbiera funkcji.
// Tablet: obrot jest odblokowany od startu aplikacji (src/lib/tablet.ts), a player go nie
// rusza. Kazda zmiana blokady w trakcie filmu przebudowywala aktywnosc i ubijala odtwarzacz.
const { autoRotate } = usePreferences();
React.useEffect(() => {
if (IS_TABLET) {
return () => {
ScreenOrientation.unlockAsync().catch(() => {});
};
}
if (autoRotate) ScreenOrientation.unlockAsync().catch(() => {});
else {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
@ -618,6 +629,9 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
// Step 2 → 3: proxy też failed, spróbuj WebView z embed URL.
if (fallbackEmbedUrl && !didFallbackWebViewRef.current) {
didFallbackWebViewRef.current = true;
// porndoe: link podpisany pod adres nie gra w ExoPlayerze na czesci sieci, wiec
// zapasem jest granie w stronie (lib/pageMode.ts), nie wyciaganie adresu z WebView.
const inPage = PAGE_MODE_ORIGINS.includes(playOrigin ?? '');
nav.replace('Player', {
url: fallbackEmbedUrl,
sceneId,
@ -630,6 +644,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
mode: 'webview',
// Droga, ktora wlasnie padla; WebView dopisze do niej '>wv', gdy wyciagnie adres.
via: viaRef.current,
inPage,
});
}
}, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title, player, source, playerError, resolvePageUrl, reResolveApplicable, reResolveDone, playOrigin]);
@ -1000,9 +1015,12 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
const [isLandscape, setIsLandscape] = React.useState(false);
const toggleFullscreen = React.useCallback(async () => {
if (isLandscape) {
if (IS_TABLET) await ScreenOrientation.unlockAsync().catch(() => {});
else {
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(
() => {},
);
}
setIsLandscape(false);
} else {
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE).catch(
@ -1280,6 +1298,41 @@ function formatTime(sec: number): string {
// 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.
// Tryb "w stronie" (lib/pageMode.ts): doklejane PRZED INJECTED_JS.
// - Dzwiek od razu: blokada wyciszenia do pierwszego gestu jest po to, zeby nie bylo slychac
// strony w trakcie wyciagania adresu, a tu strona gra sama.
// - canPlayType('...mpegurl') zwraca pusto. WebView odpowiada "maybe", ale HLS wprost w <video>
// nie zagra (MEDIA_ERR_SRC_NOT_SUPPORTED, porndoe 2026-10-12). Na pusto strona bierze wlasne
// hls.js przez MediaSource i gra.
// - Jedno zgloszenie do aplikacji, gdy film faktycznie idzie. Reklamy przed filmem (30 s)
// i podglady (kilka sekund) tez sa <video>, wiec liczy sie tylko dluzszy niz minuta.
// 'timeupdate' nie babelkuje, stad nasluch w fazie capture na dokumencie. W tym momencie
// wlaczamy dzwiek: strona startuje wyciszona (autoplay), a WebView ma
// mediaPlaybackRequiresUserAction={false}, wiec odciszenie bez gestu nie pauzuje filmu.
const IN_PAGE_JS = `
window.__goonInPage = true;
window.__goonUserGestured = true;
(function () {
try {
var cpt = HTMLMediaElement.prototype.canPlayType;
HTMLMediaElement.prototype.canPlayType = function (t) {
if (/mpegurl/i.test(String(t || ''))) return '';
return cpt.call(this, t);
};
} catch (x) {}
document.addEventListener('timeupdate', function (e) {
try {
var v = e.target;
if (window.__goonPlayingSent || !v || v.tagName !== 'VIDEO' || v.paused) return;
if (!(v.duration >= 60) || v.currentTime < 2 || !window.ReactNativeWebView) return;
window.__goonPlayingSent = true;
try { if (v.muted) v.muted = false; if (!(v.volume > 0)) v.volume = 1; } catch (x) {}
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'page_playing' }));
} catch (x) {}
}, true);
})();
`;
const INJECTED_JS = `
(function() {
if (window.__goonPatched) return;
@ -1850,6 +1903,10 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
const client = useClient();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Player'>>();
const { url, sceneId, entityKind, durationSec, refererHost } = params;
const inPage = !!params.inPage;
// Tryb "w stronie": wideo gra w WebView, sukces zglasza strona, gdy <video> ruszy.
const [pagePlaying, setPagePlaying] = React.useState(false);
const pagePlayingRef = React.useRef(false);
// Keep-screen-on dla ścieżki WebView: nie mamy tu sygnału play/pause (wideo gra
// wewnątrz strony hostera), więc trzymamy wake-lock przez cały czas życia ekranu.
@ -1878,7 +1935,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
// 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);
const [revealEmbed, setRevealEmbed] = React.useState(inPage);
React.useEffect(() => {
if (extractedUrl) return;
const t = setTimeout(() => setRevealEmbed(true), 15000);
@ -1915,9 +1972,18 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
// 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;
if (extractedUrl || pagePlaying || httpDead || geoHintShown || resolveStatus === 'pending') return;
const t = setTimeout(() => {
setGeoHintShown(true);
if (inPage && params.origin) {
client.reportPlaybackEvent({
origin: params.origin,
status: 'error',
scene_id: sceneId,
error_kind: 'page_no_play',
via: 'page',
});
}
Alert.alert(
'Source not loading',
'This source may be blocked in your region or by your network/ISP. ' +
@ -1926,7 +1992,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
);
}, 25000);
return () => clearTimeout(t);
}, [extractedUrl, httpDead, geoHintShown, resolveStatus]);
}, [extractedUrl, pagePlaying, httpDead, geoHintShown, resolveStatus, inPage, params.origin, client, sceneId]);
// Stage 0.8: mobile-side hoster resolvery. Mobile IP usera unika Cloudflare
// Turnstile / CAPTCHA gate który blokuje Hetzner VPS, embed page renderuje
@ -2082,7 +2148,28 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
const onMessage = React.useCallback((evt: WebViewMessageEvent) => {
try {
const data = JSON.parse(evt.nativeEvent.data);
if (inPage && data.type === 'page_playing') {
if (!pagePlayingRef.current) {
pagePlayingRef.current = true;
setPagePlaying(true);
if (params.origin) {
// Strona zagrala tam, gdzie ExoPlayer nie dal rady: nastepnym razem od razu tu.
// Nie przedluzamy przy wejsciu prosto do strony, zeby po 7 dniach wrocic do proby
// natywnej.
if (params.via !== 'page') rememberPageMode(params.origin).catch(() => {});
client.reportPlaybackEvent({
origin: params.origin,
status: 'success',
scene_id: sceneId,
via: 'page',
});
}
}
return;
}
if (data.type === 'video_url' && typeof data.url === 'string') {
// W trybie "w stronie" niczego nie przenosimy do ExoPlayera.
if (inPage) return;
if (isLikelyAd(data.url)) return;
setExtractedUrl((curr) => curr ?? data.url);
}
@ -2096,7 +2183,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
} catch {
// not JSON
}
}, [isLikelyAd]);
}, [isLikelyAd, inPage, params.origin, client, sceneId]);
// Gdy INJECTED_JS wyłapał czysty video URL z DOM/XHR, przerzucamy na
// NativeVideoPlayer (expo-video / ExoPlayer) BEZ reklam, cookie consent,
@ -2219,7 +2306,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
source={{ uri: url, headers: sourceHeaders }}
style={styles.video}
userAgent={webViewUa}
injectedJavaScriptBeforeContentLoaded={INJECTED_JS}
injectedJavaScriptBeforeContentLoaded={inPage ? IN_PAGE_JS + INJECTED_JS : INJECTED_JS}
injectedJavaScript={DOOD_OVERLAY_JS}
onMessage={onMessage}
onShouldStartLoadWithRequest={onShouldStartLoad}

View file

@ -26,6 +26,7 @@ import { resolveSxyprnPage } from '../lib/sxyprnResolver';
import { resolveEpornerPage } from '../lib/epornerResolver';
import { resolveFpoxxxPage } from '../lib/fpoxxxResolver';
import { resolvePorndoePage } from '../lib/porndoeResolver';
import { prefersPageMode } from '../lib/pageMode';
import type { RootStackParamList } from '../navigation';
import { fonts, theme } from '../theme';
import { HeaderIconButton } from '../ui';
@ -687,6 +688,31 @@ function PlaybackButton({
return;
}
// Ten telefon dostal juz 403 na tym zrodle (lib/pageMode.ts): od razu do strony, bez
// kilkunastu sekund nieudanych prob w ExoPlayerze.
if (await prefersPageMode(source.origin)) {
let pageHost: string | undefined;
try {
pageHost = new URL(source.page_url).hostname;
} catch {
// ignore
}
markStarted();
nav.navigate('Player', {
url: source.page_url,
sceneId,
origin: source.origin,
playbackId: source.id,
durationSec: sceneDurationSec,
refererHost: pageHost,
title: source.origin,
mode: 'webview',
inPage: true,
via: 'page',
});
return;
}
// Phone-side resolve dla IP-bound tubów: CDN token jest bound do IP które POBRAŁO
// stronę (audit 2026-06-11). Backend resolvuje z VPS → telefon dostaje URL bound do IP
// VPS → direct 403 / 10B placeholder. Telefon sam pobiera stronę (phone IP) → token