goon/mobile/src/navigation.tsx
goon-foss f48fbbd4ad fix(performers): wlasna obsada dla JAV, brak przycisku dla animacji
Domkniecie tej samej rodziny bledu co przy tagach i studiach. Pomiar rozstrzygnal,
ze oba wertykaly wymagaja INNEGO rozwiazania, wiec nie ma tu jednego filtra:

  animacja   12 698 scen ->     0 performerow
  JAV        54 888 scen -> 1 392 performerow

JAV: /performers dostaje parametr `vertical`, liczony na zywo, bo zdenormalizowany
`performers.scene_count` z zalozenia opisuje wylacznie glowny feed (job taxonomy_counts
wyklucza originy wertykalowe). Bez tego zakladka pokazywala zachodnia obsade, a wlasne
nazwiska mialy licznik 0 i ladowaly na koncu listy. Po zmianie: Satomi Suzuki (137),
Kei Mizushima (128), yui hatano (68).

Animacja: przycisk Performers jest chowany. Zero obsady to nie brak danych, tylko
wlasciwosc tresci: dla postaci rysowanych nie istnieje kanon (TPDB/StashDB ich nie maja),
wiec zaden scraper nie ma czego przypisac i zaden nigdy nie bedzie mial. Filtrowanie
dawaloby gwarantowanie pusta liste, czyli dokladnie ten sam objaw, ktory naprawiamy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:51:39 +02:00

355 lines
14 KiB
TypeScript

import {
DefaultTheme,
NavigationContainer,
useNavigationContainerRef,
} from '@react-navigation/native';
import { BugReportFAB } from './components/BugReportFAB';
import { LiquidTabs } from './components/LiquidTabs';
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 { 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 = {
Scenes: undefined;
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';
function TopTabs({
current,
onNavigate,
}: {
current: TopTab;
onNavigate: (tab: TopTab) => void;
}) {
const tabs: TopTab[] = ['Scenes', 'Movies', 'Sites', 'JAV', 'Anime'];
return (
// Logo + separator zostają statyczne, przewija się (i „roztapia") tylko pasek.
// `flexShrink` trzyma całość w swojej połowie nagłówka: bez tego pasek rozpycha
// się na całą szerokość i nachodzi na ikony po prawej.
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingLeft: 10, flexShrink: 1 }}>
<GoonMark size={28} />
<View style={{ width: 1, height: 16, backgroundColor: theme.border }} />
<View style={{ flex: 1, minWidth: 170 }}>
<LiquidTabs tabs={tabs} current={current} onSelect={onNavigate} />
</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"
component={ScenesScreen}
options={({ navigation }) => ({
title: '',
headerLeft: () => (
<TopTabs
current="Scenes"
onNavigate={(t) => navigation.replace(t)}
/>
),
headerRight: () => (
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
<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>
),
})}
/>
<Stack.Screen
name="Movies"
component={MoviesScreen}
options={({ navigation }) => ({
title: '',
headerBackVisible: false,
headerLeft: () => (
<TopTabs
current="Movies"
onNavigate={(t) => navigation.replace(t)}
/>
),
headerRight: () => (
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
<Pressable onPress={onLogout} hitSlop={12}>
<Text style={{ fontSize: 17 }}>🚪</Text>
</Pressable>
</View>
),
})}
/>
<Stack.Screen
name="Sites"
component={SitesScreen}
options={({ navigation }) => ({
title: '',
headerBackVisible: false,
headerLeft: () => (
<TopTabs
current="Sites"
onNavigate={(t) => navigation.replace(t)}
/>
),
headerRight: () => (
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
<Pressable onPress={onLogout} hitSlop={12}>
<Text style={{ fontSize: 17 }}>🚪</Text>
</Pressable>
</View>
),
})}
/>
<Stack.Screen
name="JAV"
component={ScenesScreen}
initialParams={{ jav: true }}
options={({ navigation }) => ({
title: '',
headerBackVisible: false,
headerLeft: () => (
<TopTabs
current="JAV"
onNavigate={(t) => navigation.replace(t)}
/>
),
headerRight: () => (
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
<Pressable onPress={onLogout} hitSlop={12}>
<Text style={{ fontSize: 17 }}>🚪</Text>
</Pressable>
</View>
),
})}
/>
<Stack.Screen
name="Anime"
component={ScenesScreen}
initialParams={{ vertical: 'animation' }}
options={({ navigation }) => ({
title: '',
headerBackVisible: false,
headerLeft: () => (
<TopTabs
current="Anime"
onNavigate={(t) => navigation.replace(t)}
/>
),
headerRight: () => (
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
<Pressable onPress={onLogout} hitSlop={12}>
<Text style={{ fontSize: 17 }}>🚪</Text>
</Pressable>
</View>
),
})}
/>
<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>
);
}