feat(mobile): gorne belki w jednym wierszu, pasek w Edge chowa sie przy przewijaniu

- SegmentTabs w ui.tsx: zakladki w wierszu narzedzi obok ikon
- Ulubione, performerka, studio, Performers i Tags: jeden wiersz pod naglowkiem
  zamiast trzech-czterech (etykieta z licznikiem, zakladki, podpowiedz, sortowanie)
- gwiazdka na ekranie performerki i studia bierze /favorites?counts=false
- Edge: kolejny klip chowa pasek, cofniecie pokazuje; podpis, przyciski i postep
  jada razem z paskiem; FAB zgloszen ukryty, lezal na "Not interested"
- changelog 2026-09-15c/d

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-09-15 15:37:53 +02:00
parent 37e6b7f03c
commit 7ed878ce16
12 changed files with 316 additions and 598 deletions

View file

@ -405,8 +405,9 @@ export class GoonClient {
});
}
async listFavorites(): Promise<FavoriteListOut> {
return this.request('/favorites');
/** `counts: false` = bez liczników +N (tylko stan gwiazdki, serwer odpowiada od razu). */
async listFavorites(opts: { counts?: boolean } = {}): Promise<FavoriteListOut> {
return this.request(opts.counts === false ? '/favorites?counts=false' : '/favorites');
}
async addFavorite(performerId: string): Promise<FavoriteAddOut> {
@ -428,8 +429,8 @@ export class GoonClient {
await this.request(`/favorites/${performerId}/seen`, { method: 'POST' });
}
async listFavoriteStudios(): Promise<FavoriteStudioListOut> {
return this.request('/favorites/studios');
async listFavoriteStudios(opts: { counts?: boolean } = {}): Promise<FavoriteStudioListOut> {
return this.request(opts.counts === false ? '/favorites/studios?counts=false' : '/favorites/studios');
}
async addFavoriteStudio(studioId: string): Promise<FavoriteStudioAddOut> {

View file

@ -35,6 +35,23 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-09-15d',
date: 'September 2026',
items: [
'Edge: the bottom bar slides away when you swipe to the next clip and comes back when you swipe back, so it no longer covers the caption, tags and progress line.',
'Edge: the bug report button is hidden there, it was sitting on top of the not-interested button. Reports still work from every other tab.',
],
minRuntime: '1.4',
},
{
id: '2026-09-15c',
date: 'September 2026',
items: [
'Favorites, performer and studio pages, Performers and Tags: tabs, sorting and buttons now share one row under the title instead of stacking three or four lines above the list.',
'Favorites and similar scenes load much faster, and opening a performer or studio no longer waits for the new-scene counters.',
],
},
{
id: '2026-09-15b',
date: 'September 2026',

View file

@ -106,19 +106,27 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
}, [unseen, client]);
// Re-render na zmianę current route, żeby FAB pojawiał/znikał per-screen.
const [currentRoute, setCurrentRoute] = useState<string | null>(null);
const [currentTab, setCurrentTab] = useState<string | null>(null);
React.useEffect(() => {
if (!navRef) return;
const unsub = navRef.addListener('state', () => {
try {
const r = navRef.isReady() ? navRef.getCurrentRoute() : null;
setCurrentRoute(r?.name ?? null);
const tab = (r?.params as { tab?: unknown } | undefined)?.tab;
setCurrentTab(typeof tab === 'string' ? tab : null);
} catch {
setCurrentRoute(null);
setCurrentTab(null);
}
});
return unsub;
}, [navRef]);
const hidden = currentRoute !== null && FAB_HIDDEN_SCREENS.has(currentRoute);
// Edge to wideo na caly ekran z kolumna przyciskow po prawej, a FAB lezal dokladnie na
// „Not interested" (2026-09-15). Zgloszenie da sie wyslac z kazdej innej zakladki.
const hidden =
currentRoute !== null &&
(FAB_HIDDEN_SCREENS.has(currentRoute) || (currentRoute === 'Scenes' && currentTab === 'Edge'));
// Na ekranie-gospodarzu (trasa 'Scenes') na dole wisi pasek zakladek, FAB idzie
// nad niego; na pozostalych ekranach zostaje przy krawedzi.
const insets = useSafeAreaInsets();

View file

@ -11,6 +11,11 @@
* double-tap → serce (ulubione) + wybuch serca + haptyka,
* przytrzymanie → 2x do puszczenia,
* przesuniecie w lewo → arkusz „nie interesuje" (pion zostaje dla pagera).
*
* Dolny pasek zakladek wisi nad feedem. Podpis, kolumna przyciskow i pasek postepu jada
* razem z nim (ta sama wartosc Animated `barHidden`): gdy pasek jest widoczny, stoja nad
* nim, gdy sie schowa, schodza na dol ekranu. Wczesniej pasek zaslanial podpis, tagi i
* postep (uwaga Jana 2026-09-15).
*/
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Image } from 'expo-image';
@ -19,7 +24,9 @@ import { VideoView } from 'expo-video';
import React from 'react';
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BOTTOM_BAR_HEIGHT, barHidden } from '../../lib/bottomBar';
import { fonts, theme } from '../../theme';
import type { ClipFeedItem } from '../../types';
@ -40,6 +47,15 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
const [progress, setProgress] = React.useState(0);
const [ready, setReady] = React.useState(false);
const heart = React.useRef(new Animated.Value(0)).current;
const insets = useSafeAreaInsets();
const lift = React.useMemo(
() =>
barHidden.interpolate({
inputRange: [0, 1],
outputRange: [-(BOTTOM_BAR_HEIGHT + insets.bottom), -insets.bottom],
}),
[insets.bottom],
);
// Postep i gotowosc tylko dla aktywnej strony z playerem.
React.useEffect(() => {
@ -158,61 +174,63 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
<MaterialCommunityIcons name="heart" size={120} color="#fff" />
</Animated.View>
{/* Kolumna przyciskow */}
<View style={styles.rail} pointerEvents="box-none">
<Pressable
onPress={() => {
burst();
onLike();
}}
style={styles.railBtn}
accessibilityLabel={liked ? 'Remove from favorites' : 'Add to favorites'}
>
<MaterialCommunityIcons name={liked ? 'heart' : 'heart-outline'} size={30} color={liked ? theme.accentText : '#fff'} />
</Pressable>
<Pressable onPress={onToggleMute} style={styles.railBtn} accessibilityLabel={muted ? 'Unmute' : 'Mute'}>
<MaterialCommunityIcons
name={clip.has_audio ? (muted ? 'volume-off' : 'volume-high') : 'volume-variant-off'}
size={28}
color={clip.has_audio ? '#fff' : '#9a9a9a'}
/>
</Pressable>
<Pressable onPress={onNotInterested} style={styles.railBtn} accessibilityLabel="Not interested">
<MaterialCommunityIcons name="thumb-down-outline" size={26} color="#fff" />
</Pressable>
</View>
{/* Podpis: tworca, tytul, tagi */}
<View style={styles.caption} pointerEvents="box-none">
<View style={styles.creatorRow}>
<Text style={styles.creator} numberOfLines={1}>
{clip.creator ? `@${clip.creator}` : clip.source}
</Text>
{clip.creator_verified ? (
<MaterialCommunityIcons name="check-decagram" size={15} color={theme.accentSecondary} style={{ marginLeft: 4 }} />
) : null}
{item.is_fresh ? <Text style={styles.fresh}>NEW</Text> : null}
<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateY: lift }] }]} pointerEvents="box-none">
{/* Kolumna przyciskow */}
<View style={styles.rail} pointerEvents="box-none">
<Pressable
onPress={() => {
burst();
onLike();
}}
style={styles.railBtn}
accessibilityLabel={liked ? 'Remove from favorites' : 'Add to favorites'}
>
<MaterialCommunityIcons name={liked ? 'heart' : 'heart-outline'} size={30} color={liked ? theme.accentText : '#fff'} />
</Pressable>
<Pressable onPress={onToggleMute} style={styles.railBtn} accessibilityLabel={muted ? 'Unmute' : 'Mute'}>
<MaterialCommunityIcons
name={clip.has_audio ? (muted ? 'volume-off' : 'volume-high') : 'volume-variant-off'}
size={28}
color={clip.has_audio ? '#fff' : '#9a9a9a'}
/>
</Pressable>
<Pressable onPress={onNotInterested} style={styles.railBtn} accessibilityLabel="Not interested">
<MaterialCommunityIcons name="thumb-down-outline" size={26} color="#fff" />
</Pressable>
</View>
{clip.title ? (
<Text style={styles.title} numberOfLines={2}>
{clip.title}
</Text>
) : null}
{tags.length ? (
<View style={styles.tagsRow}>
{tags.map((t) => (
<Pressable key={t} onPress={() => onTagPress?.(t)} style={styles.tagChip}>
<Text style={styles.tagText}>#{t}</Text>
</Pressable>
))}
</View>
) : null}
</View>
{/* Pasek postepu */}
<View style={styles.progressTrack} pointerEvents="none">
<View style={[styles.progressFill, { width: `${Math.round(progress * 100)}%` }]} />
</View>
{/* Podpis: tworca, tytul, tagi */}
<View style={styles.caption} pointerEvents="box-none">
<View style={styles.creatorRow}>
<Text style={styles.creator} numberOfLines={1}>
{clip.creator ? `@${clip.creator}` : clip.source}
</Text>
{clip.creator_verified ? (
<MaterialCommunityIcons name="check-decagram" size={15} color={theme.accentSecondary} style={{ marginLeft: 4 }} />
) : null}
{item.is_fresh ? <Text style={styles.fresh}>NEW</Text> : null}
</View>
{clip.title ? (
<Text style={styles.title} numberOfLines={2}>
{clip.title}
</Text>
) : null}
{tags.length ? (
<View style={styles.tagsRow}>
{tags.map((t) => (
<Pressable key={t} onPress={() => onTagPress?.(t)} style={styles.tagChip}>
<Text style={styles.tagText}>#{t}</Text>
</Pressable>
))}
</View>
) : null}
</View>
{/* Pasek postepu */}
<View style={styles.progressTrack} pointerEvents="none">
<View style={[styles.progressFill, { width: `${Math.round(progress * 100)}%` }]} />
</View>
</Animated.View>
</View>
</GestureDetector>
);
@ -231,7 +249,8 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
rail: { position: 'absolute', right: 8, bottom: 96, alignItems: 'center' },
// Nad dolnym paskiem pilnuje tego `lift`, wiec kolumna stoi na wysokosci podpisu.
rail: { position: 'absolute', right: 8, bottom: 20, alignItems: 'center' },
railBtn: {
width: 48,
height: 48,

View file

@ -23,6 +23,7 @@ import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { usePreferences } from '../PreferencesContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import { SegmentTabs, Toolbar } from '../ui';
import type { FavoriteMovieOut, FavoriteOut, FavoriteStudioOut } from '../types';
type Tab = 'scenes' | 'performers' | 'studios' | 'movies';
@ -115,12 +116,6 @@ export function FavoritesScreen() {
};
// performers/studios mają new_total badge; scenes/movies nie (single entity).
const activeData =
tab === 'performers'
? performersQuery.data
: tab === 'studios'
? studiosQuery.data
: null;
const activeQuery =
tab === 'performers'
? performersQuery
@ -129,7 +124,6 @@ export function FavoritesScreen() {
: tab === 'movies'
? moviesQuery
: scenesQuery;
const headerCount = activeData?.total ?? (activeQuery.data as { total?: number } | undefined)?.total ?? 0;
const isLoading = activeQuery.isLoading;
const isRefetching = activeQuery.isRefetching;
const error = activeQuery.error;
@ -137,47 +131,21 @@ export function FavoritesScreen() {
return (
<View style={styles.container}>
<View style={styles.headerRow}>
<Text style={styles.headerLabel}>Favorites</Text>
{activeData && activeData.new_total > 0 ? (
<View style={styles.totalBadge}>
<Text style={styles.totalBadgeText}>+{activeData.new_total} new</Text>
</View>
) : (
<Text style={styles.headerCount}>{headerCount}</Text>
)}
</View>
<View style={styles.tabRow}>
<TabButton
label="Scenes"
active={tab === 'scenes'}
newCount={0}
onPress={() => setTab('scenes')}
{/* Jeden wiersz zakładek (2026-09-15). Wcześniej pod natywnym nagłówkiem
"Favorites" stała druga etykieta FAVORITES z licznikiem, wiersz zakładek i
podpowiedź, czyli cztery linie przed listą. Podpowiedź zeszła pod listę. */}
<Toolbar>
<SegmentTabs
tabs={[
{ key: 'scenes', label: 'Scenes' },
{ key: 'performers', label: 'Performers', badge: performersQuery.data?.new_total ?? 0 },
{ key: 'studios', label: 'Studios', badge: studiosQuery.data?.new_total ?? 0 },
{ key: 'movies', label: 'Movies' },
]}
value={tab}
onChange={setTab}
/>
<TabButton
label="Performers"
active={tab === 'performers'}
newCount={performersQuery.data?.new_total ?? 0}
onPress={() => setTab('performers')}
/>
<TabButton
label="Studios"
active={tab === 'studios'}
newCount={studiosQuery.data?.new_total ?? 0}
onPress={() => setTab('studios')}
/>
<TabButton
label="Movies"
active={tab === 'movies'}
newCount={0}
onPress={() => setTab('movies')}
/>
</View>
<Text style={styles.hint}>
tap to open scenes (resets the new badge) · long-press to remove
</Text>
</Toolbar>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
@ -221,6 +189,7 @@ export function FavoritesScreen() {
</Text>
) : null
}
ListFooterComponent={scenesQuery.data?.items.length ? <Text style={styles.hint}>long-press to remove</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }}
/>
) : tab === 'movies' ? (
@ -260,6 +229,7 @@ export function FavoritesScreen() {
</Text>
) : null
}
ListFooterComponent={moviesQuery.data?.items.length ? <Text style={styles.hint}>long-press to remove</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }}
/>
) : tab === 'performers' ? (
@ -306,6 +276,7 @@ export function FavoritesScreen() {
</Text>
) : null
}
ListFooterComponent={performersQuery.data?.items.length ? <Text style={styles.hint}>tap to open (clears the new badge) · long-press to remove</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }}
/>
) : (
@ -351,6 +322,7 @@ export function FavoritesScreen() {
</Text>
) : null
}
ListFooterComponent={studiosQuery.data?.items.length ? <Text style={styles.hint}>tap to open (clears the new badge) · long-press to remove</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }}
/>
)}
@ -358,29 +330,6 @@ export function FavoritesScreen() {
);
}
function TabButton({
label,
active,
newCount,
onPress,
}: {
label: string;
active: boolean;
newCount: number;
onPress: () => void;
}) {
return (
<Pressable style={[styles.tabBtn, active && styles.tabBtnActive]} onPress={onPress}>
<Text style={[styles.tabBtnText, active && styles.tabBtnTextActive]}>{label}</Text>
{newCount > 0 ? (
<View style={styles.tabBadge}>
<Text style={styles.tabBadgeText}>+{newCount}</Text>
</View>
) : null}
</Pressable>
);
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/);
const first = parts[0]?.[0] ?? '';
@ -520,63 +469,7 @@ function StudioRow({
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 12 },
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 4,
},
headerLabel: {
color: theme.muted,
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 1.2,
fontWeight: '700',
},
headerCount: { color: theme.fg, fontSize: 22, fontWeight: '800' },
totalBadge: {
backgroundColor: theme.accent,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 10,
shadowColor: theme.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 6,
elevation: 3,
},
totalBadgeText: {
color: theme.fg,
fontWeight: '800',
fontSize: 13,
letterSpacing: 0.3,
},
tabRow: { flexDirection: 'row', gap: 8, marginTop: 10, marginBottom: 4 },
tabBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 8,
borderRadius: 8,
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
},
tabBtnActive: { borderColor: theme.accentText, backgroundColor: theme.bgElevated },
tabBtnText: { color: theme.muted, fontWeight: '600', fontSize: 14 },
tabBtnTextActive: { color: theme.fg },
tabBadge: {
backgroundColor: theme.accent,
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 4,
},
tabBadgeText: { color: theme.bg, fontWeight: '800', fontSize: 11 },
hint: { color: theme.mutedDim, fontSize: 11, marginTop: 8, marginBottom: 12 },
hint: { color: theme.mutedDim, fontSize: 11, textAlign: 'center', marginTop: 4, marginBottom: 8 },
gridRow: { gap: 10, marginBottom: 14 },

View file

@ -12,8 +12,6 @@ import {
ActivityIndicator,
Alert,
FlatList,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
@ -28,7 +26,7 @@ import { ErrorBoundary } from '../ErrorBoundary';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import type { SceneOut } from '../types';
import { HeaderIconButton, IconButton, Toolbar } from '../ui';
import { HeaderIconButton, IconButton, SegmentTabs, Toolbar } from '../ui';
type Tab = 'scenes' | 'movies';
@ -50,9 +48,12 @@ export function PerformerScenesScreen() {
const [tab, setTab] = React.useState<Tab>('scenes');
// Sam stan gwiazdki, bez liczników +N: pełne /favorites liczy nowe sceny wszystkich
// ulubionych (sekundy przy setkach), a ten ekran potrzebuje tylko listy id.
// Klucz pod ['favorites'], więc każde invalidateQueries(['favorites']) odświeża też to.
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: () => client.listFavorites(),
queryKey: ['favorites', 'ids'],
queryFn: () => client.listFavorites({ counts: false }),
staleTime: 30_000,
});
const isFavorite = !!favoritesQuery.data?.items.find((f) => f.performer_id === id);
@ -240,21 +241,40 @@ export function PerformerScenesScreen() {
setTab(next);
}, [id, name, tab]);
// Jeden wiersz (2026-09-15): zakładki z licznikami po lewej, ikony sceny po prawej.
// Wcześniej zakładki, pod nimi wiersz "200+ scenes" z ikonami, czyli trzy linie
// razem z nagłówkiem, a licznik stał dwa razy.
const tabRow = (
<View style={styles.tabRow}>
<TabButton
label="Scenes"
count={scenesTotal}
active={tab === 'scenes'}
onPress={() => handleTabSwitch('scenes')}
<Toolbar>
<SegmentTabs
tabs={[
{ key: 'scenes', label: 'Scenes', count: scenesQuery.data ? `${scenesTotal}${scenesSuffix}` : undefined },
{ key: 'movies', label: 'Movies', count: moviesTotal },
]}
value={tab}
onChange={handleTabSwitch}
/>
<TabButton
label="Movies"
count={moviesTotal}
active={tab === 'movies'}
onPress={() => handleTabSwitch('movies')}
/>
</View>
{tab === 'scenes' ? (
<>
{refreshMutation.isPending ? (
<ActivityIndicator color={theme.accentText} style={{ width: 44 }} />
) : (
<IconButton
icon="magnify-scan"
accessibilityLabel="Search more scenes"
onPress={() => refreshMutation.mutate()}
/>
)}
<IconButton
icon="filter-variant"
accessibilityLabel="Filters"
active={filterActiveCount > 0}
badge={filterActiveCount}
onPress={() => setFilterOpen(true)}
/>
</>
) : null}
</Toolbar>
);
return (
@ -286,36 +306,6 @@ export function PerformerScenesScreen() {
if (filter.sort === 'random') setSeed(Math.random().toString(36).slice(2) + Date.now().toString(36));
else scenesQuery.refetch();
}}
ListHeaderComponent={
<View>
{/* Jeden wiersz (2026-09-07): licznik scen po lewej, po prawej ikony
„szukaj wiecej" i filtr z licznikiem. Chipsy tagow pod spodem
zniknely, tagi sa w filtrze. */}
<Toolbar>
<Text style={[styles.subtitle, { flex: 1 }]} numberOfLines={1}>
{scenesQuery.data
? `${scenesTotal}${scenesSuffix} ${scenesTotal === 1 ? 'scene' : 'scenes'}`
: ' '}
</Text>
{refreshMutation.isPending ? (
<ActivityIndicator color={theme.accentText} style={{ width: 44 }} />
) : (
<IconButton
icon="magnify-scan"
accessibilityLabel="Search more scenes"
onPress={() => refreshMutation.mutate()}
/>
)}
<IconButton
icon="filter-variant"
accessibilityLabel="Filters"
active={filterActiveCount > 0}
badge={filterActiveCount}
onPress={() => setFilterOpen(true)}
/>
</Toolbar>
</View>
}
ListEmptyComponent={
!scenesQuery.isLoading ? <Text style={styles.muted}>no scenes</Text> : null
}
@ -363,15 +353,6 @@ export function PerformerScenesScreen() {
}}
refreshing={moviesQuery.isRefetching}
onRefresh={moviesQuery.refetch}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.subtitle}>
{moviesQuery.data
? `${moviesTotal} ${moviesTotal === 1 ? 'film' : 'films'}`
: ' '}
</Text>
</View>
}
ListEmptyComponent={
!moviesQuery.isLoading ? (
<Text style={styles.muted}>no movies for this performer</Text>
@ -399,31 +380,6 @@ export function PerformerScenesScreen() {
);
}
function TabButton({
label,
count,
active,
onPress,
}: {
label: string;
count: number;
active: boolean;
onPress: () => void;
}) {
return (
<Pressable style={[styles.tabBtn, active && styles.tabBtnActive]} onPress={onPress}>
<Text style={[styles.tabBtnText, active && styles.tabBtnTextActive]}>
{label}
</Text>
{count > 0 ? (
<View style={styles.tabCount}>
<Text style={styles.tabCountText}>{count}</Text>
</View>
) : null}
</Pressable>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 },
gridRow: { gap: 10, marginBottom: 14 },
@ -475,27 +431,4 @@ const styles = StyleSheet.create({
tagChipActive: { backgroundColor: theme.accent, borderColor: theme.accentText },
tagChipText: { color: theme.muted, fontSize: 12, fontWeight: '600' },
tagChipTextActive: { color: theme.fg, fontWeight: '700' },
tabRow: { flexDirection: 'row', gap: 8, marginBottom: 8 },
tabBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 8,
borderRadius: 8,
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
},
tabBtnActive: { borderColor: theme.accentText, backgroundColor: theme.bgElevated },
tabBtnText: { color: theme.muted, fontWeight: '600', fontSize: 14 },
tabBtnTextActive: { color: theme.fg },
tabCount: {
backgroundColor: theme.border,
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 4,
},
tabCountText: { color: theme.muted, fontWeight: '700', fontSize: 11 },
});

View file

@ -12,12 +12,12 @@ import {
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import { SearchInput, SegmentTabs, Toolbar } from '../ui';
import type { PerformerCount } from '../types';
type Order = 'scene_count' | 'name';
@ -29,7 +29,6 @@ export function PerformersScreen() {
const [q, setQ] = useState('');
const [debouncedQ, setDebouncedQ] = useState('');
const [order, setOrder] = useState<Order>('scene_count');
const [searchFocused, setSearchFocused] = useState(false);
const route = useRoute<RouteProp<RootStackParamList, 'Performers'>>();
const vertical = route.params?.vertical;
@ -44,41 +43,23 @@ export function PerformersScreen() {
client.listPerformers({ q: debouncedQ || undefined, order, per_page: 200, vertical }),
});
const total = data?.items.length ?? 0;
return (
<View style={styles.container}>
<View style={styles.headerRow}>
<Text style={styles.headerLabel}>Performers</Text>
<Text style={styles.headerCount}>{total}</Text>
</View>
<Text style={styles.hint}>tap a row → all their scenes</Text>
<View style={styles.toolbar}>
<TextInput
style={[styles.search, searchFocused && styles.searchFocused]}
value={q}
onChangeText={setQ}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
placeholder="search performer…"
placeholderTextColor={theme.mutedDim}
autoCapitalize="none"
{/* Jeden wiersz (2026-09-15): pole i przełącznik sortowania obok siebie. Wcześniej
pod nagłówkiem była druga etykieta z licznikiem, podpowiedź, pole i osobny
wiersz Top/A-Z, czyli cztery linie przed listą. */}
<Toolbar>
<SearchInput value={q} onChangeText={setQ} placeholder="search performer…" />
<SegmentTabs
style={styles.order}
tabs={[
{ key: 'scene_count', label: 'Top' },
{ key: 'name', label: 'A-Z' },
]}
value={order}
onChange={setOrder}
/>
</View>
<View style={styles.segment}>
<SegButton
active={order === 'scene_count'}
onPress={() => setOrder('scene_count')}
label="Top"
/>
<SegButton
active={order === 'name'}
onPress={() => setOrder('name')}
label="A-Z"
/>
</View>
</Toolbar>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
@ -109,27 +90,6 @@ export function PerformersScreen() {
);
}
function SegButton({
active,
onPress,
label,
}: {
active: boolean;
onPress: () => void;
label: string;
}) {
return (
<Pressable
onPress={onPress}
style={[styles.segButton, active && styles.segButtonActive]}
>
<Text style={[styles.segButtonText, active && styles.segButtonTextActive]}>
{label}
</Text>
</Pressable>
);
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length === 0) return '?';
@ -176,64 +136,7 @@ function PerformerRow({
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 12 },
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 4,
},
headerLabel: {
color: theme.muted,
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 1.2,
fontWeight: '700',
},
headerCount: { color: theme.fg, fontSize: 22, fontWeight: '800' },
hint: { color: theme.mutedDim, fontSize: 11, marginBottom: 12 },
toolbar: { flexDirection: 'row', gap: 12, marginBottom: 10 },
search: {
flex: 1,
backgroundColor: theme.card,
borderColor: theme.borderStrong,
borderWidth: 1.5,
borderRadius: 12,
color: theme.fg,
padding: 12,
fontSize: 16,
},
searchFocused: { borderColor: theme.borderFocus },
segment: {
flexDirection: 'row',
backgroundColor: theme.bgElevated,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 12,
padding: 4,
marginBottom: 14,
alignSelf: 'flex-start',
},
segButton: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 8,
},
segButtonActive: {
backgroundColor: theme.accent,
shadowColor: theme.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 6,
elevation: 2,
},
segButtonText: {
color: theme.muted,
fontWeight: '700',
fontSize: 13,
},
segButtonTextActive: { color: theme.fg },
order: { flex: 0, width: 124 },
row: {
flexDirection: 'row',

View file

@ -27,8 +27,8 @@ export function StudioMoviesScreen() {
const { slug, name, studioId } = route.params;
const favoritesQuery = useQuery({
queryKey: ['favorites-studios'],
queryFn: () => client.listFavoriteStudios(),
queryKey: ['favorites-studios', 'ids'],
queryFn: () => client.listFavoriteStudios({ counts: false }),
staleTime: 30_000,
});
const isFavorite = !!favoritesQuery.data?.items.find((f) => f.studio_id === studioId);

View file

@ -17,7 +17,6 @@ import {
ActivityIndicator,
Alert,
FlatList,
Pressable,
StyleSheet,
Text,
View,
@ -30,7 +29,7 @@ import { useClient } from '../ClientContext';
import { usePreferences } from '../PreferencesContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import { HeaderIconButton } from '../ui';
import { HeaderIconButton, SegmentTabs, Toolbar } from '../ui';
import type { SceneOut } from '../types';
type Tab = 'scenes' | 'movies';
@ -51,9 +50,11 @@ export function StudioScenesScreen() {
const { id: slug, name, seenSince, studioId, vertical } = route.params;
const [tab, setTab] = React.useState<Tab>('scenes');
// Sam stan gwiazdki (bez liczników +N, patrz PerformerScenesScreen). Klucz pod
// ['favorites-studios'], więc invalidate tamtego klucza odświeża też ten.
const favoritesQuery = useQuery({
queryKey: ['favorites-studios'],
queryFn: () => client.listFavoriteStudios(),
queryKey: ['favorites-studios', 'ids'],
queryFn: () => client.listFavoriteStudios({ counts: false }),
staleTime: 30_000,
});
const isFavorite = !!favoritesQuery.data?.items.find((f) => f.studio_id === studioId);
@ -158,21 +159,18 @@ export function StudioScenesScreen() {
<View style={styles.container}>
{/* Pasek zakładek ZAWSZE widoczny, też podczas ładowania: gdyby siedział w
ListHeaderComponent, pierwsze wejście na Movies dałoby pusty ekran bez
drogi powrotnej (ta sama pułapka co w PerformerScenesScreen). */}
<View style={styles.tabRow}>
<TabButton
label="Scenes"
count={data?.total ?? 0}
active={tab === 'scenes'}
onPress={() => setTab('scenes')}
drogi powrotnej (ta sama pułapka co w PerformerScenesScreen). Liczniki są
w zakładkach, osobny wiersz "N scenes" pod nimi zniknął (2026-09-15). */}
<Toolbar>
<SegmentTabs
tabs={[
{ key: 'scenes', label: 'Scenes', count: data ? `${data.total}${data.total_capped ? '+' : ''}` : undefined },
{ key: 'movies', label: 'Movies', count: moviesTotal },
]}
value={tab}
onChange={setTab}
/>
<TabButton
label="Movies"
count={moviesTotal}
active={tab === 'movies'}
onPress={() => setTab('movies')}
/>
</View>
</Toolbar>
{tab === 'scenes' ? (
<>
@ -190,13 +188,6 @@ export function StudioScenesScreen() {
)}
refreshing={isRefetching}
onRefresh={refetch}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.subtitle}>
{data ? `${data.total}${data.total_capped ? '+' : ''} ${data.total === 1 ? 'scene' : 'scenes'}` : ' '}
</Text>
</View>
}
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }}
/>
@ -226,15 +217,6 @@ export function StudioScenesScreen() {
)}
refreshing={moviesQuery.isRefetching}
onRefresh={moviesQuery.refetch}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.subtitle}>
{moviesQuery.data
? `${moviesTotal} ${moviesTotal === 1 ? 'film' : 'films'}`
: ' '}
</Text>
</View>
}
ListEmptyComponent={
!moviesQuery.isLoading ? (
<Text style={styles.muted}>no movies for this studio</Text>
@ -249,61 +231,9 @@ export function StudioScenesScreen() {
);
}
function TabButton({
label,
count,
active,
onPress,
}: {
label: string;
count: number;
active: boolean;
onPress: () => void;
}) {
return (
<Pressable style={[styles.tabBtn, active && styles.tabBtnActive]} onPress={onPress}>
<Text style={[styles.tabBtnText, active && styles.tabBtnTextActive]}>{label}</Text>
{count > 0 ? (
<View style={styles.tabCount}>
<Text style={styles.tabCountText}>{count}</Text>
</View>
) : null}
</Pressable>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 },
gridRow: { gap: 10, marginBottom: 14 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
paddingHorizontal: 4,
},
subtitle: { color: theme.muted },
tabRow: { flexDirection: 'row', gap: 8, marginBottom: 10, paddingHorizontal: 4 },
tabBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: 999,
borderWidth: 1,
borderColor: theme.border,
},
tabBtnActive: { borderColor: theme.accentText },
tabBtnText: { color: theme.muted, fontSize: 13 },
tabBtnTextActive: { color: theme.accentText, fontWeight: '700' },
tabCount: {
backgroundColor: theme.border,
borderRadius: 999,
paddingHorizontal: 6,
paddingVertical: 1,
},
tabCountText: { color: theme.muted, fontSize: 11 },
muted: { color: theme.muted, textAlign: 'center', marginTop: 24 },
error: { color: theme.bad, padding: 12 },
});

View file

@ -26,6 +26,7 @@ import { ActivityIndicator, AppState, Modal, Pressable, ScrollView, StyleSheet,
import { useClient } from '../ClientContext';
import { ClipCell } from '../components/swipe/ClipCell';
import { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion';
import { setBarHidden } from '../lib/bottomBar';
import { usePersistedState } from '../lib/persistedState';
import { SwipeTelemetry, leaveKind, newSessionId } from '../lib/swipeTelemetry';
import { fonts, theme } from '../theme';
@ -99,6 +100,21 @@ export function SwipeScreen() {
}
}, [index, items.length, feed.hasNextPage, feed.isFetchingNextPage, feed]);
// ---- dolny pasek ----------------------------------------------------------------------
// Jak na listach: przewijanie do przodu chowa pasek, cofniecie o klip go przywraca, na
// pierwszym klipie jest zawsze. Zeby wrocic do innych zakladek, wystarczy cofnac sie
// o jeden klip. Po wyjsciu z Edge pasek musi byc widoczny.
React.useEffect(() => () => setBarHidden(false), []);
const onPageSelected = React.useCallback(
(e: { nativeEvent: { position: number } }) => {
const next = e.nativeEvent.position;
if (next === 0 || next < index) setBarHidden(false);
else if (next > index) setBarHidden(true);
setIndex(next);
},
[index],
);
// ---- telemetria ----------------------------------------------------------------------
const telemetry = React.useRef<SwipeTelemetry | null>(null);
if (!telemetry.current) telemetry.current = new SwipeTelemetry(client, newSessionId());
@ -261,6 +277,7 @@ export function SwipeScreen() {
telemetry.current = new SwipeTelemetry(client, newSessionId());
telemetry.current.start();
prevIndexRef.current = null;
setBarHidden(false);
setIndex(0);
setSeedKey((k) => k + 1);
}, [client]);
@ -317,7 +334,7 @@ export function SwipeScreen() {
initialPage={0}
offscreenPageLimit={1}
overdrag
onPageSelected={(e: { nativeEvent: { position: number } }) => setIndex(e.nativeEvent.position)}
onPageSelected={onPageSelected}
>
{items.map((it, i) => (
<View key={it.clip.id} style={styles.page} collapsable={false}>

View file

@ -12,12 +12,12 @@ import {
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import { SearchInput, SegmentTabs, Toolbar } from '../ui';
import type { TagCount } from '../types';
type Order = 'popular' | 'name';
@ -31,7 +31,6 @@ export function TagsScreen() {
const [q, setQ] = useState('');
const [debouncedQ, setDebouncedQ] = useState('');
const [order, setOrder] = useState<Order>('popular');
const [searchFocused, setSearchFocused] = useState(false);
React.useEffect(() => {
const t = setTimeout(() => setDebouncedQ(q), 350);
@ -53,41 +52,23 @@ export function TagsScreen() {
}),
});
const total = data?.items.length ?? 0;
return (
<View style={styles.container}>
<View style={styles.headerRow}>
<Text style={styles.headerLabel}>Tags</Text>
<Text style={styles.headerCount}>{total}</Text>
</View>
<Text style={styles.hint}>tap a chip → filter scenes by tag</Text>
<View style={styles.toolbar}>
<TextInput
style={[styles.search, searchFocused && styles.searchFocused]}
value={q}
onChangeText={setQ}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
placeholder="search tag…"
placeholderTextColor={theme.mutedDim}
autoCapitalize="none"
{/* Jeden wiersz (2026-09-15): pole i przełącznik sortowania obok siebie. Wcześniej
pod nagłówkiem była druga etykieta z licznikiem, podpowiedź, pole i osobny
wiersz Top/A-Z, czyli cztery linie przed listą. */}
<Toolbar>
<SearchInput value={q} onChangeText={setQ} placeholder="search tag…" />
<SegmentTabs
style={styles.order}
tabs={[
{ key: 'popular', label: 'Top' },
{ key: 'name', label: 'A-Z' },
]}
value={order}
onChange={setOrder}
/>
</View>
<View style={styles.segment}>
<SegButton
active={order === 'popular'}
onPress={() => setOrder('popular')}
label="Top"
/>
<SegButton
active={order === 'name'}
onPress={() => setOrder('name')}
label="A-Z"
/>
</View>
</Toolbar>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
@ -114,27 +95,6 @@ export function TagsScreen() {
);
}
function SegButton({
active,
onPress,
label,
}: {
active: boolean;
onPress: () => void;
label: string;
}) {
return (
<Pressable
onPress={onPress}
style={[styles.segButton, active && styles.segButtonActive]}
>
<Text style={[styles.segButtonText, active && styles.segButtonTextActive]}>
{label}
</Text>
</Pressable>
);
}
function TagChip({ tag, onPress }: { tag: TagCount; onPress: () => void }) {
return (
<Pressable
@ -154,56 +114,7 @@ function TagChip({ tag, onPress }: { tag: TagCount; onPress: () => void }) {
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 12 },
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 4,
},
headerLabel: {
color: theme.muted,
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 1.2,
fontWeight: '700',
},
headerCount: { color: theme.fg, fontSize: 22, fontWeight: '800' },
hint: { color: theme.mutedDim, fontSize: 11, marginBottom: 12 },
toolbar: { flexDirection: 'row', gap: 12, marginBottom: 10 },
search: {
flex: 1,
backgroundColor: theme.card,
borderColor: theme.borderStrong,
borderWidth: 1.5,
borderRadius: 12,
color: theme.fg,
padding: 12,
fontSize: 16,
},
searchFocused: { borderColor: theme.borderFocus },
segment: {
flexDirection: 'row',
backgroundColor: theme.bgElevated,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 12,
padding: 4,
marginBottom: 14,
alignSelf: 'flex-start',
},
segButton: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 8 },
segButtonActive: {
backgroundColor: theme.accent,
shadowColor: theme.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 6,
elevation: 2,
},
segButtonText: { color: theme.muted, fontWeight: '700', fontSize: 13 },
segButtonTextActive: { color: theme.fg },
order: { flex: 0, width: 124 },
gridRow: { gap: 10, marginBottom: 10 },
chip: {

View file

@ -186,6 +186,66 @@ export function Toolbar({ children }: { children: React.ReactNode }) {
return <View style={styles.toolbar}>{children}</View>;
}
export interface SegmentTab<T extends string> {
key: T;
label: string;
/** Liczba po etykiecie (np. 200+), pomijana gdy pusta albo 0. */
count?: number | string;
/** Nowe pozycje: czerwony licznik w rogu, jak na IconButton. */
badge?: number;
}
/**
* Zakladki ekranu w jednym wierszu o wysokosci pola wyszukiwania (2026-09-15). Wczesniej
* ekrany podrzedne mialy pod naglowkiem osobny wiersz zakladek, pod nim wiersz z
* licznikiem i ikonami, a ulubione jeszcze etykiete i podpowiedz, czyli trzy albo cztery
* linie zanim zaczynala sie lista. Teraz zakladki siedza w Toolbarze obok ikon.
*/
export function SegmentTabs<T extends string>({
tabs,
value,
onChange,
style,
}: {
tabs: SegmentTab<T>[];
value: T;
onChange: (key: T) => void;
style?: StyleProp<ViewStyle>;
}) {
return (
<View style={[styles.segWrap, style]} accessibilityRole="tablist">
{tabs.map((t) => {
const on = t.key === value;
const showCount = t.count !== undefined && t.count !== 0 && t.count !== '';
return (
<Pressable
key={t.key}
onPress={() => onChange(t.key)}
accessibilityRole="tab"
accessibilityState={{ selected: on }}
accessibilityLabel={showCount ? `${t.label}, ${t.count}` : t.label}
style={[styles.seg, on && styles.segActive]}
>
<Text numberOfLines={1} style={[styles.segText, on && styles.segTextActive]}>
{t.label}
</Text>
{showCount ? (
<Text numberOfLines={1} style={styles.segCount}>
{t.count}
</Text>
) : null}
{t.badge && t.badge > 0 ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>+{t.badge}</Text>
</View>
) : null}
</Pressable>
);
})}
</View>
);
}
const styles = StyleSheet.create({
toolbar: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
headerIcon: { paddingHorizontal: 6, paddingVertical: 4 },
@ -237,4 +297,30 @@ const styles = StyleSheet.create({
pillCompact: { height: 36, paddingHorizontal: 14 },
pillActive: { borderColor: theme.accentText, backgroundColor: `${theme.accent}1A` },
pillText: { fontFamily: fonts.medium, fontSize: 14 },
segWrap: {
flex: 1,
height: CONTROL_H,
flexDirection: 'row',
padding: 3,
gap: 3,
backgroundColor: theme.card,
borderColor: theme.borderStrong,
borderWidth: 1,
borderRadius: RADIUS,
},
seg: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 5,
paddingHorizontal: 4,
borderRadius: RADIUS - 3,
borderWidth: 1,
borderColor: 'transparent',
},
segActive: { borderColor: theme.accentText, backgroundColor: `${theme.accent}1A` },
segText: { fontFamily: fonts.medium, fontSize: 14, color: theme.muted, flexShrink: 1 },
segTextActive: { color: theme.accentText },
segCount: { fontFamily: fonts.body, fontSize: 12, color: theme.mutedDim },
});