Audit przez impeccable.style/slop: aktualny theme byl literal "AI default palette" - deep navy #08090F + purple #8B5CF6 + glow #A78BFA + brak custom typography. Plus user feedback "wieksze miniaturki, mniej tekstu - to portal video". theme.ts: - Warm dark: bg #15110D (charcoal z orange undertone), card #26201A, fg warm off-white #F5EDE0 - Accent: oxblood #B23A48 + amber secondary #D89B4A (brak purple, brak glow) - type + space scale (1.25 ratio, 8/16/24/32 spacing) eksportowane - Backwards-compat: accentDeep/Glow/Secondary/good/warn/bad zachowane - Font scaffold: komentarz z instrukcja jak dodac General Sans + Geist Mono (Fontshare/Vercel free) - czeka na expo-font install GoonWordmark + GoonMark: custom letterform SVG (4 litery jako path geometry, flat ellipses + descender hook). Monogram standalone dla icon/splash. Wstrzykniety do TopTabs (header) zamiast plain "" title. ScenesScreen: - 2-col 16:9 grid (SceneTile) zamiast full-width SceneRow (6 lini tekstu) - Title 1 linijka, studio uppercase micro - Wyrzucone z listy: performers, release_date, sources count, "watched" string - replaced check badge + dim - Duration badge bottom-right thumb, fav badge top-left OTA: faad1f92-541a-4241-81fd-9cf159173b7e live, runtime 1.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
import {
|
|
DefaultTheme,
|
|
NavigationContainer,
|
|
useNavigationContainerRef,
|
|
} from '@react-navigation/native';
|
|
import { BugReportFAB } from './components/BugReportFAB';
|
|
import { GoonWordmark } 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 { 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 { StudioScenesScreen } from './screens/StudioScenesScreen';
|
|
import { TagScenesScreen } from './screens/TagScenesScreen';
|
|
import { TagsScreen } from './screens/TagsScreen';
|
|
import { theme } from './theme';
|
|
|
|
export type RootStackParamList = {
|
|
Scenes: undefined;
|
|
Movies: undefined;
|
|
Sites: 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 };
|
|
SceneDetail: { id: string };
|
|
Performers: 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 };
|
|
// 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 };
|
|
Favorites: undefined;
|
|
Tags: undefined;
|
|
TagScenes: { slug: string; name: string };
|
|
AppLockSettings: undefined;
|
|
Donate: undefined;
|
|
Player: {
|
|
url: string;
|
|
sceneId: 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';
|
|
durationSec?: number | null;
|
|
refererHost?: string;
|
|
title?: string;
|
|
mode?: 'video' | 'webview';
|
|
// 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';
|
|
|
|
function TopTabs({
|
|
current,
|
|
onNavigate,
|
|
}: {
|
|
current: TopTab;
|
|
onNavigate: (tab: TopTab) => void;
|
|
}) {
|
|
const tabs: TopTab[] = ['Scenes', 'Movies', 'Sites'];
|
|
return (
|
|
<View style={{ flexDirection: 'row', gap: 16, paddingHorizontal: 12, alignItems: 'center' }}>
|
|
{/* Wordmark — branding po lewej, dystans 14px do pierwszego tabu. */}
|
|
<GoonWordmark width={64} color={theme.fg} />
|
|
<View style={{ width: 1, height: 18, backgroundColor: theme.border, marginRight: 2 }} />
|
|
{tabs.map((t) => {
|
|
const active = t === current;
|
|
return (
|
|
<Pressable key={t} onPress={() => (active ? null : onNavigate(t))} hitSlop={12}>
|
|
<Text
|
|
style={{
|
|
color: active ? theme.accent : theme.muted,
|
|
fontSize: 13,
|
|
fontWeight: active ? '700' : '500',
|
|
letterSpacing: 0.3,
|
|
}}
|
|
>
|
|
{t}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</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 },
|
|
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.accent, 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={{ color: theme.muted, fontSize: 13 }}>Sign out</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={{ color: theme.muted, fontSize: 13 }}>Sign out</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={{ color: theme.muted, fontSize: 13 }}>Sign out</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="Tags" component={TagsScreen} options={{ title: 'Tags' }} />
|
|
<Stack.Screen
|
|
name="TagScenes"
|
|
component={TagScenesScreen}
|
|
options={{ title: 'Tag scenes' }}
|
|
/>
|
|
<Stack.Screen
|
|
name="AppLockSettings"
|
|
options={{ title: 'Blokada aplikacji' }}
|
|
>
|
|
{() => <AppLockSettingsScreen />}
|
|
</Stack.Screen>
|
|
<Stack.Screen
|
|
name="Donate"
|
|
component={DonateScreen}
|
|
options={{ title: 'Support project' }}
|
|
/>
|
|
<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>
|
|
);
|
|
}
|