Zgloszenie 2026-08-18: kropla "zrobila wiecej zlego niz dobrego". Trzy zarzuty,
wszystkie trafne:
- nie wygladala ladniej,
- rysunek SVG mial 44 px i wystawal ponad pasek, wiec naglowek spuchl i rozjechalo
sie wyrownanie wiersza do srodka,
- morfowanie ksztaltu nie przypominalo wzoru.
Research wzorcow z liderow (X/Twitter, YouTube, Reddit, App Store, Duolingo, apki
bankowe): gorne zakladki to wszedzie plaski, powsciagliwy pasek STALEJ WYSOKOSCI z
PRZESUWANYM wskaznikiem, nie morfowanym. Uzytkownik wybral podkreslenie (wzorzec
dominujacy na Androidzie).
TopTabsBar: aktywna zakladka = accentText + cienka kreska hugujaca tekst, przejezdza
w 220 ms ease-out (translateX + width z pomiarow onLayout). Wysokosc = wiersz tekstu,
wiec logo/zakladki/ikony znow sa w jednej linii. Naglowek wraca na theme.card (trik z
theme.bg byl tylko po to, zeby kropla byla widoczna).
Usuniete: LiquidTabs.tsx + lib/liquidTabPath.ts (caly aparat SVG-morph, martwy).
Zweryfikowane na emulatorze nagraniem klatka po klatce: kreska przejezdza Anime->Scenes
mijajac zakladki, kolory sie przelaczaja, laduje pod aktywna; wiersz wyrownany.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
307 lines
13 KiB
TypeScript
307 lines
13 KiB
TypeScript
import {
|
|
DefaultTheme,
|
|
NavigationContainer,
|
|
useNavigationContainerRef,
|
|
} from '@react-navigation/native';
|
|
import { BugReportFAB } from './components/BugReportFAB';
|
|
import { TopTabsBar } from './components/TopTabsBar';
|
|
import { GoonMark } from './components/GoonWordmark';
|
|
import { GoonClient } from './api';
|
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
|
import React from 'react';
|
|
import { Pressable, Text, View } from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen';
|
|
import { BlacklistScreen } from './screens/BlacklistScreen';
|
|
import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen';
|
|
import { DonateScreen } from './screens/DonateScreen';
|
|
import { FavoritesScreen } from './screens/FavoritesScreen';
|
|
import { MovieDetailScreen } from './screens/MovieDetailScreen';
|
|
import { MoviesScreen } from './screens/MoviesScreen';
|
|
import { PerformerScenesScreen } from './screens/PerformerScenesScreen';
|
|
import { PerformersScreen } from './screens/PerformersScreen';
|
|
import { PlayerScreen } from './screens/PlayerScreen';
|
|
import { ScenesScreen } from './screens/ScenesScreen';
|
|
import { SceneDetailScreen } from './screens/SceneDetailScreen';
|
|
import { SiteScenesScreen } from './screens/SiteScenesScreen';
|
|
import { SitesScreen } from './screens/SitesScreen';
|
|
import { StudioMoviesScreen } from './screens/StudioMoviesScreen';
|
|
import { StudioScenesScreen } from './screens/StudioScenesScreen';
|
|
import { TagScenesScreen } from './screens/TagScenesScreen';
|
|
import { TagsScreen } from './screens/TagsScreen';
|
|
import { fonts, theme } from './theme';
|
|
|
|
export type RootStackParamList = {
|
|
// `tab` odkłada HomeScreen (setParams) wyłącznie po to, żeby BugReportFAB wiedział,
|
|
// na której zakładce user był: zakładki to stan lokalny, nie osobne trasy.
|
|
Scenes: { tab?: string } | undefined;
|
|
// UWAGA: Movies/Sites/JAV/Anime NIE sa zarejestrowane w navigatorze od 2026-08-18 —
|
|
// zakladki to stan lokalny HomeScreen (trasa 'Scenes'). Klucze zostaja, bo ekrany
|
|
// typuja useNavigation<...,'Movies'> itd.; nawigowanie do nich rzuciloby bledem.
|
|
Movies: undefined;
|
|
Sites: undefined;
|
|
// JAV: osobna sekcja, ScenesScreen z paramem jav → backend ?jav=true.
|
|
JAV: { jav: true } | undefined;
|
|
// Anime: hentai i 3D, ScenesScreen z paramem vertical → backend ?vertical=animation.
|
|
// Osobna sekcja z tego samego powodu co JAV: inny namespace tytułów, inne słownictwo
|
|
// tagów (85% tagów animacji nie występuje nigdzie indziej), zero dedupu z resztą.
|
|
Anime: { vertical: 'animation' } | undefined;
|
|
// `origin`: raw playback_source.origin (np. 'tube:hqpornercom'). Idzie do
|
|
// listScenes({origin}), backend robi substring match. `name`: display name
|
|
// do title bara (np. 'hqporner.com').
|
|
SiteScenes: { origin: string; name: string };
|
|
MovieDetail: { id: string };
|
|
// autoplay: wejście z ▶ na kafelku (quick-play), SceneDetail sam odpala najlepsze źródło.
|
|
SceneDetail: { id: string; autoplay?: boolean };
|
|
Performers: { vertical?: string } | undefined;
|
|
// `seenSince` (ISO timestamp): jeśli ten performer jest favorited, FavoritesScreen
|
|
// przekazuje `last_seen_at` SPRZED markFavoriteSeen. Sceny z `created_at > seenSince`
|
|
// dostają NEW badge.
|
|
PerformerScenes: { id: string; name: string; seenSince?: string; vertical?: string | null };
|
|
// StudioScenes: `id` to studio.slug (do filter scen `studio_slugs`),
|
|
// `studioId` to UUID (do favorite/blacklist API). Backend filtruje scen po slug,
|
|
// ale favorites + blacklist używają UUID.
|
|
StudioScenes: { id: string; name: string; studioId: string; seenSince?: string; vertical?: string | null };
|
|
// StudioMovies: `slug` do filter movies `studio_slugs`, `studioId` (UUID) do favorite.
|
|
StudioMovies: { slug: string; name: string; studioId: string };
|
|
Favorites: undefined;
|
|
// `vertical` przynoszony z zakladki (Anime/JAV). Bez niego lista tagow pokazywala
|
|
// slownictwo GLOWNEGO feedu, wiec z Anime kazdy tag prowadzil donikad.
|
|
Tags: { vertical?: string } | undefined;
|
|
TagScenes: { slug: string; name: string; vertical?: string | null };
|
|
AppLockSettings: undefined;
|
|
Blacklist: undefined;
|
|
Donate: undefined;
|
|
// In-app incognito browser do diagnostyki hostera (long-press na linku playbacku).
|
|
DiagnosticBrowser: { url: string };
|
|
Player: {
|
|
url: string;
|
|
sceneId: string;
|
|
// 'tube:<sitetag>' źródła, telemetria odtwarzania zasilająca ranking źródeł.
|
|
// Opcjonalne; brak → telemetria pomijana (canonical/non-tube).
|
|
origin?: string;
|
|
// Post page URL dla IP-bound tubów (sxyprn/eporner/fpoxxx), re-resolve on error.
|
|
resolvePageUrl?: string;
|
|
// 'movie' = MovieDetail wywołał Player z movieId zamiast sceneId. Backend
|
|
// ma /movies/{id}/progress oddzielnie od /scenes/{id}/progress (2026-05-28).
|
|
// Default 'scene' dla back-compat z istniejącymi nav callami.
|
|
entityKind?: 'scene' | 'movie';
|
|
// playback_source.id, pozwala Playerowi oznaczyć źródło jako dead bez powrotu
|
|
// do SceneDetail (gdy CDN zwróci 404/410 przy odtwarzaniu, sxyprn/fpo „404
|
|
// którego apka nie potrafi zidentyfikować”, bug a78cc3b6). Undefined dla
|
|
// legacy/non-tube nav callów → przycisk „Mark broken” się nie pokazuje.
|
|
playbackId?: string;
|
|
durationSec?: number | null;
|
|
refererHost?: string;
|
|
title?: string;
|
|
mode?: 'video' | 'webview';
|
|
// Lista jakości tego samego wideo (z resolve). Pozwala przełączyć jakość BEZ
|
|
// wychodzenia z playera (user-request 89845707), player robi replace() i wraca
|
|
// na zapamiętaną pozycję. Undefined = brak wyboru (jedno źródło).
|
|
qualityLinks?: { label: string; url: string; headers?: Record<string, string> }[];
|
|
// Headers do bezpośredniego fetchu CDN URL'a (Referer + User-Agent). Mobile
|
|
// próbuje direct PIERWSZY, bandwidth idzie device→CDN, nie przez VPS proxy.
|
|
// Backend zwraca te headers w StreamLink.headers przy resolve.
|
|
headers?: Record<string, string>;
|
|
// Proxy URL fallback gdy direct fails (CDN IP-bound, np. fpo.xxx z kt_remote_ips).
|
|
// Native player na error → replace na ten URL (proxy re-fetchuje z VPS IP).
|
|
fallbackProxyUrl?: string;
|
|
// Gdy native VideoView dostanie błąd (typowe dla IP-bound CDN URL'i z luluvids/
|
|
// iceyfile/tnmr, extracted z VPS IP, mobile fetch dostaje 403), apka przełącza
|
|
// się automatycznie na WebView z tym URL'em, tam JS embed page sam pobiera
|
|
// m3u8 we własnym kontekście (Cookies + JA3 + IP set ok).
|
|
fallbackEmbedUrl?: string;
|
|
};
|
|
};
|
|
|
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
|
|
|
type TopTab = 'Scenes' | 'Movies' | 'Sites' | 'JAV' | 'Anime';
|
|
|
|
/**
|
|
* Jeden ekran-gospodarz dla pięciu zakładek. Zakładka to STAN LOKALNY, nie trasa.
|
|
*
|
|
* Poprzednio każda zakładka była osobnym ekranem native stacka, a przełączenie robiło
|
|
* `navigation.replace(t)` — wymieniało cały ekran RAZEM z nagłówkiem, więc pasek zakładek
|
|
* montował się od nowa ze wskaźnikiem już na docelowej pozycji i nie mógł się animować
|
|
* (user widział teleport, zgłoszenie 2026-08-18). Pasek musi przeżywać przełączenie,
|
|
* żeby wskaźnik przejeżdżał, stąd jeden ekran-gospodarz.
|
|
*
|
|
* `key={tab}` na treści wymusza czysty remount przy przełączeniu — to samo, co robiło
|
|
* `replace` (reset wyszukiwania/scrolla). Bez key przełączenie Scenes→JAV reużyłoby
|
|
* instancję ScenesScreen i zostawiło stary tekst wyszukiwania w nowej sekcji.
|
|
*/
|
|
function HomeScreen({
|
|
navigation,
|
|
onLogout,
|
|
}: {
|
|
navigation: { setParams: (p: { tab?: string }) => void; navigate: (r: 'Donate' | 'AppLockSettings') => void };
|
|
onLogout: () => void;
|
|
}) {
|
|
const insets = useSafeAreaInsets();
|
|
const tabs: TopTab[] = ['Scenes', 'Movies', 'Sites', 'JAV', 'Anime'];
|
|
const [tab, setTab] = React.useState<TopTab>('Scenes');
|
|
|
|
// BugReportFAB czyta zakładkę z route.params.tab (nazwa trasy to zawsze 'Scenes').
|
|
React.useEffect(() => {
|
|
navigation.setParams({ tab });
|
|
}, [tab, navigation]);
|
|
|
|
return (
|
|
<View style={{ flex: 1 }}>
|
|
{/* Własny nagłówek zamiast natywnego (headerShown:false): natywny należy do
|
|
ekranu, a ten pasek musi przeżywać przełączanie zakładek. Ikony po prawej są
|
|
teraz jednakowe na każdej zakładce (wcześniej ♥ i ⚙ miał tylko Scenes). */}
|
|
<View
|
|
style={{
|
|
backgroundColor: theme.card,
|
|
paddingTop: insets.top,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
paddingLeft: 10,
|
|
paddingRight: 14,
|
|
paddingBottom: 2,
|
|
}}
|
|
>
|
|
<GoonMark size={28} />
|
|
<View style={{ width: 1, height: 16, backgroundColor: theme.border }} />
|
|
<View style={{ flex: 1, minWidth: 170 }}>
|
|
<TopTabsBar tabs={tabs} current={tab} onSelect={setTab} />
|
|
</View>
|
|
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center', marginLeft: 6 }}>
|
|
<Pressable onPress={() => navigation.navigate('Donate')} hitSlop={12}>
|
|
<Text style={{ color: theme.accentText, fontSize: 18 }}>♥</Text>
|
|
</Pressable>
|
|
<Pressable onPress={() => navigation.navigate('AppLockSettings')} hitSlop={12}>
|
|
<Text style={{ color: theme.muted, fontSize: 18 }}>⚙</Text>
|
|
</Pressable>
|
|
<Pressable onPress={onLogout} hitSlop={12}>
|
|
<Text style={{ fontSize: 17 }}>🚪</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
<View style={{ flex: 1 }} key={tab}>
|
|
{tab === 'Movies' ? (
|
|
<MoviesScreen />
|
|
) : tab === 'Sites' ? (
|
|
<SitesScreen />
|
|
) : tab === 'JAV' ? (
|
|
<ScenesScreen jav />
|
|
) : tab === 'Anime' ? (
|
|
<ScenesScreen vertical="animation" />
|
|
) : (
|
|
<ScenesScreen />
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const navTheme = {
|
|
...DefaultTheme,
|
|
dark: true,
|
|
colors: {
|
|
...DefaultTheme.colors,
|
|
background: theme.bg,
|
|
card: theme.card,
|
|
text: theme.fg,
|
|
border: theme.border,
|
|
primary: theme.accent,
|
|
notification: theme.bad,
|
|
},
|
|
};
|
|
|
|
interface AppNavigatorProps {
|
|
onLogout: () => void;
|
|
client: GoonClient | null;
|
|
appVersion: string;
|
|
}
|
|
|
|
export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps) {
|
|
// Ref przypisany do NavigationContainer pozwala BugReportFAB (sibling
|
|
// Stack.Navigator) wyciągnąć current route bez bycia w screen context,
|
|
// useNavigationState wymagałby NavigationContext którego siblings nie mają.
|
|
const navRef = useNavigationContainerRef();
|
|
return (
|
|
<NavigationContainer ref={navRef} theme={navTheme}>
|
|
<View style={{ flex: 1 }}>
|
|
<Stack.Navigator
|
|
screenOptions={{
|
|
headerStyle: { backgroundColor: theme.card },
|
|
headerTitleStyle: { color: theme.fg, fontFamily: fonts.display },
|
|
headerTintColor: theme.accent,
|
|
contentStyle: { backgroundColor: theme.bg },
|
|
}}
|
|
>
|
|
<Stack.Screen name="Scenes" options={{ headerShown: false }}>
|
|
{({ navigation }) => <HomeScreen navigation={navigation} onLogout={onLogout} />}
|
|
</Stack.Screen>
|
|
<Stack.Screen
|
|
name="SiteScenes"
|
|
component={SiteScenesScreen}
|
|
options={{ title: 'Site scenes' }}
|
|
/>
|
|
<Stack.Screen name="MovieDetail" component={MovieDetailScreen} options={{ title: '' }} />
|
|
<Stack.Screen name="SceneDetail" component={SceneDetailScreen} options={{ title: '' }} />
|
|
<Stack.Screen name="Performers" component={PerformersScreen} options={{ title: 'Performers' }} />
|
|
<Stack.Screen name="Favorites" component={FavoritesScreen} options={{ title: 'Favorites' }} />
|
|
<Stack.Screen
|
|
name="PerformerScenes"
|
|
component={PerformerScenesScreen}
|
|
options={{ title: 'Performer scenes' }}
|
|
/>
|
|
<Stack.Screen
|
|
name="StudioScenes"
|
|
component={StudioScenesScreen}
|
|
options={{ title: 'Studio scenes' }}
|
|
/>
|
|
<Stack.Screen
|
|
name="StudioMovies"
|
|
component={StudioMoviesScreen}
|
|
options={{ title: 'Studio movies' }}
|
|
/>
|
|
<Stack.Screen name="Tags" component={TagsScreen} options={{ title: 'Tags' }} />
|
|
<Stack.Screen
|
|
name="TagScenes"
|
|
component={TagScenesScreen}
|
|
options={{ title: 'Tag scenes' }}
|
|
/>
|
|
<Stack.Screen
|
|
name="AppLockSettings"
|
|
options={{ title: 'App lock' }}
|
|
>
|
|
{() => <AppLockSettingsScreen />}
|
|
</Stack.Screen>
|
|
<Stack.Screen
|
|
name="Blacklist"
|
|
component={BlacklistScreen}
|
|
options={{ title: 'Hidden content' }}
|
|
/>
|
|
<Stack.Screen
|
|
name="Donate"
|
|
component={DonateScreen}
|
|
options={{ title: 'Support project' }}
|
|
/>
|
|
<Stack.Screen
|
|
name="DiagnosticBrowser"
|
|
component={DiagnosticBrowserScreen}
|
|
options={{ title: 'Diagnostics (incognito)' }}
|
|
/>
|
|
<Stack.Screen
|
|
name="Player"
|
|
component={PlayerScreen}
|
|
options={{
|
|
// Player ma własny custom overlay z back button (controlsTop). Native header
|
|
// dawał ~80-90px czarnego paska w landscape, letterboxowało wideo.
|
|
headerShown: false,
|
|
contentStyle: { backgroundColor: '#000' },
|
|
orientation: 'all',
|
|
}}
|
|
/>
|
|
</Stack.Navigator>
|
|
<BugReportFAB client={client} appVersion={appVersion} navRef={navRef} />
|
|
</View>
|
|
</NavigationContainer>
|
|
);
|
|
}
|