Jan feedback po pierwszym overhaulu: layout 2-col tile pasuje, ale aktywnie tylko na ScenesScreen - reszta ekranow scen (SiteScenes, PerformerScenes, StudioScenes, TagScenes) dalej w full-width row layoucie. Wyciagniety SceneTile do mobile/src/components/SceneTile.tsx ze wsparciem: - secondLine: 'studio' | 'performers' | 'date' | 'none' - per-ekran dobor metadanej (Studio na SiteScenes/Performer, performers na Studio, etc) - seenSince: ISO timestamp - pokazuje NEW badge gdy scene.created_at > seen (uzywane na Performer/Studio screens dla NEW od ostatniego markFavoriteSeen) - onLongPress: opcjonalny custom handler (default = animated preview) Refaktor 5 ekranow: - ScenesScreen: usuwa lokalna kopie SceneTile, import shared - SiteScenesScreen: SceneRow -> SceneTile (numColumns=2, secondLine='studio') - PerformerScenesScreen: FavoriteSceneRow -> SceneTile (numColumns=2) - StudioScenesScreen: FavoriteSceneRow -> SceneTile (numColumns=2, performers) - TagScenesScreen: lokalna SceneRow -> SceneTile FavoriteSceneRow component zostaje (legacy import w PerformerScenes - nie ruszamy bo moze byc uzyty w innym kontekscie). gridRow style scaffold (gap+ marginBottom) dodany w kazdym StyleSheet osobno bo te ekrany maja rozne paddingHorizontal w container. OTA: 9eea7ac6-df72-460e-9660-22bf6c39c3ac live, runtime 1.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
439 lines
14 KiB
TypeScript
439 lines
14 KiB
TypeScript
import { useNavigation } from '@react-navigation/native';
|
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
|
import React, { useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
FlatList,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TextInput,
|
|
View,
|
|
} from 'react-native';
|
|
import { Image } from 'expo-image';
|
|
import * as Haptics from 'expo-haptics';
|
|
import { SceneTile } from '../components/SceneTile';
|
|
import { Thumb } from '../components/Thumb';
|
|
import { useClient } from '../ClientContext';
|
|
import { theme } from '../theme';
|
|
import type { RootStackParamList } from '../navigation';
|
|
import type { SceneOut } from '../types';
|
|
import { DEFAULT_FILTER, FilterState, ScenesFilterModal } from './ScenesFilterModal';
|
|
|
|
export function ScenesScreen() {
|
|
const client = useClient();
|
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
|
|
const [q, setQ] = useState('');
|
|
const [debouncedQ, setDebouncedQ] = useState('');
|
|
const [filter, setFilter] = useState<FilterState>(DEFAULT_FILTER);
|
|
const [filterOpen, setFilterOpen] = useState(false);
|
|
|
|
React.useEffect(() => {
|
|
const t = setTimeout(() => setDebouncedQ(q), 350);
|
|
return () => clearTimeout(t);
|
|
}, [q]);
|
|
|
|
const PER_PAGE = 50;
|
|
const {
|
|
data,
|
|
isLoading,
|
|
error,
|
|
refetch,
|
|
isRefetching,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
} = useInfiniteQuery({
|
|
queryKey: ['scenes', debouncedQ, filter],
|
|
queryFn: ({ pageParam = 1 }) =>
|
|
client.listScenes({
|
|
q: debouncedQ || undefined,
|
|
tags: filter.tagSlugs.length ? filter.tagSlugs : undefined,
|
|
studio_slugs: filter.studioSlugs.length ? filter.studioSlugs : undefined,
|
|
performer_ids: filter.performerIds.length ? filter.performerIds : undefined,
|
|
has_playback: filter.hasPlayback || undefined,
|
|
sort: filter.sort,
|
|
include_stubs: filter.includeStubs || undefined,
|
|
origin: filter.origin.trim() || undefined,
|
|
page: pageParam,
|
|
per_page: PER_PAGE,
|
|
}),
|
|
initialPageParam: 1,
|
|
getNextPageParam: (lastPage) => {
|
|
const loaded = lastPage.page * lastPage.per_page;
|
|
return loaded < lastPage.total ? lastPage.page + 1 : undefined;
|
|
},
|
|
});
|
|
const items = data?.pages.flatMap((p) => p.items) ?? [];
|
|
const total = data?.pages[0]?.total ?? 0;
|
|
|
|
const activeCount =
|
|
filter.tagSlugs.length +
|
|
filter.studioSlugs.length +
|
|
filter.performerIds.length +
|
|
(filter.hasPlayback ? 1 : 0) +
|
|
(filter.origin.trim() ? 1 : 0);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.toolbar}>
|
|
<TextInput
|
|
style={styles.search}
|
|
value={q}
|
|
onChangeText={setQ}
|
|
placeholder="search title…"
|
|
placeholderTextColor={theme.muted}
|
|
autoCapitalize="none"
|
|
/>
|
|
<Pressable
|
|
onPress={() => setFilterOpen(true)}
|
|
style={[styles.toolbarButton, activeCount > 0 && styles.toolbarButtonActive]}
|
|
>
|
|
<Text style={[styles.toolbarButtonText, activeCount > 0 && { color: theme.accent }]}>
|
|
Filter{activeCount > 0 ? ` ${activeCount}` : ''}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<View style={styles.subToolbar}>
|
|
<Pressable
|
|
style={styles.subButton}
|
|
onPress={() => navigation.navigate('Performers')}
|
|
>
|
|
<Text style={styles.subButtonText}>Performers</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={styles.subButton}
|
|
onPress={() => navigation.navigate('Tags')}
|
|
>
|
|
<Text style={styles.subButtonText}>Tags</Text>
|
|
</Pressable>
|
|
<FavoritesButton />
|
|
</View>
|
|
|
|
{isLoading && <ActivityIndicator color={theme.fg} />}
|
|
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
|
|
|
<FlatList
|
|
data={items}
|
|
keyExtractor={(s) => s.id}
|
|
numColumns={2}
|
|
renderItem={({ item }) => <SceneTile scene={item} />}
|
|
columnWrapperStyle={styles.gridRow}
|
|
ListHeaderComponent={!debouncedQ && activeCount === 0 ? <ContinueWatchingRail /> : null}
|
|
refreshing={isRefetching}
|
|
onRefresh={refetch}
|
|
onEndReached={() => {
|
|
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
|
|
}}
|
|
onEndReachedThreshold={0.5}
|
|
ListFooterComponent={
|
|
isFetchingNextPage ? (
|
|
<ActivityIndicator color={theme.muted} style={{ marginVertical: 18 }} />
|
|
) : !hasNextPage && items.length > 0 ? (
|
|
<Text style={styles.muted}>{`${items.length} / ${total}`}</Text>
|
|
) : null
|
|
}
|
|
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
|
|
contentContainerStyle={{ paddingBottom: 24 }}
|
|
/>
|
|
|
|
<ScenesFilterModal
|
|
visible={filterOpen}
|
|
initial={filter}
|
|
onApply={(next) => {
|
|
setFilter(next);
|
|
setFilterOpen(false);
|
|
}}
|
|
onClose={() => setFilterOpen(false)}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ContinueWatchingRail() {
|
|
const client = useClient();
|
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
|
|
const { data } = useQuery({
|
|
queryKey: ['watch-recent'],
|
|
queryFn: () => client.listRecentWatch(10),
|
|
staleTime: 30_000,
|
|
});
|
|
const items = data?.items ?? [];
|
|
if (items.length === 0) return null;
|
|
|
|
return (
|
|
<View style={styles.rail}>
|
|
<Text style={styles.railLabel}>Continue watching</Text>
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={styles.railContent}
|
|
>
|
|
{items.map((entry) => {
|
|
const scene = entry.scene;
|
|
const thumb = scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url;
|
|
const dur = entry.duration_sec || scene.duration_sec || 0;
|
|
const pct = dur > 0 ? Math.min(100, Math.round((entry.position_sec / dur) * 100)) : 0;
|
|
return (
|
|
<Pressable
|
|
key={scene.id}
|
|
style={styles.railItem}
|
|
onPress={() => navigation.navigate('SceneDetail', { id: scene.id })}
|
|
>
|
|
<Thumb url={thumb} style={styles.railThumb} />
|
|
{pct > 0 ? (
|
|
<View style={styles.railProgressBg}>
|
|
<View style={[styles.railProgressFg, { width: `${pct}%` }]} />
|
|
</View>
|
|
) : null}
|
|
<Text style={styles.railTitle} numberOfLines={2}>
|
|
{scene.title}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function FavoritesButton() {
|
|
const client = useClient();
|
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
|
|
const { data } = useQuery({
|
|
queryKey: ['favorites'],
|
|
queryFn: () => client.listFavorites(),
|
|
refetchOnWindowFocus: true,
|
|
staleTime: 30_000,
|
|
});
|
|
const newTotal = data?.new_total ?? 0;
|
|
const hasNew = newTotal > 0;
|
|
|
|
return (
|
|
<Pressable
|
|
style={[styles.subButton, hasNew && styles.subButtonActive]}
|
|
onPress={() => navigation.navigate('Favorites')}
|
|
>
|
|
<Text style={[styles.subButtonText, hasNew && styles.subButtonTextActive]}>
|
|
★ {hasNew ? newTotal : ''}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function SceneRow({ scene }: { scene: SceneOut }) {
|
|
const navigation =
|
|
useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
|
|
const [isPreviewing, setIsPreviewing] = useState(false);
|
|
const performers = scene.performers
|
|
.slice(0, 3)
|
|
.map((p) => p.canonical_name)
|
|
.join(', ');
|
|
|
|
const animatedUrl = scene.playback_sources.find((s) => s.animated_thumbnail_url)
|
|
?.animated_thumbnail_url;
|
|
const staticUrl = scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url;
|
|
const displayUrl = isPreviewing && animatedUrl ? animatedUrl : staticUrl ?? animatedUrl;
|
|
|
|
const startPreview = () => {
|
|
if (!animatedUrl) return;
|
|
setIsPreviewing(true);
|
|
Haptics.selectionAsync().catch(() => {});
|
|
};
|
|
|
|
// Watched indicator: finished=True dim'uje cały kafelek; pośredni progress (>0,
|
|
// <finished) trzymamy bez dim'a żeby user widział "częściowo obejrzane" ale wraca chętnie.
|
|
const dim = scene.finished === true;
|
|
|
|
return (
|
|
<Pressable
|
|
style={[styles.row, dim && styles.rowDimmed]}
|
|
onPress={() => navigation.navigate('SceneDetail', { id: scene.id })}
|
|
onLongPress={startPreview}
|
|
onPressOut={() => setIsPreviewing(false)}
|
|
delayLongPress={180}
|
|
>
|
|
<Thumb url={displayUrl} style={styles.thumbnail} />
|
|
{scene.is_favorite ? (
|
|
<View style={styles.favBadge}>
|
|
<Text style={styles.favBadgeText}>★</Text>
|
|
</View>
|
|
) : null}
|
|
<View style={styles.rowContent}>
|
|
<Text style={styles.rowTitle} numberOfLines={1}>
|
|
{scene.title}
|
|
</Text>
|
|
{scene.release_date || scene.studio ? (
|
|
<Text style={styles.rowMuted} numberOfLines={1}>
|
|
{[scene.release_date, scene.studio?.name].filter(Boolean).join(' · ')}
|
|
</Text>
|
|
) : null}
|
|
{performers ? (
|
|
<Text style={styles.rowMuted} numberOfLines={1}>
|
|
{performers}
|
|
{scene.performers.length > 3 ? ` +${scene.performers.length - 3}` : ''}
|
|
</Text>
|
|
) : null}
|
|
<Text style={styles.rowSources}>
|
|
{[...new Set(scene.external_refs.map((r) => r.source))].join(' · ')}
|
|
{scene.playback_sources.length > 0
|
|
? ` ▶ ${scene.playback_sources.length}`
|
|
: ''}
|
|
{dim ? ' ✓ watched' : ''}
|
|
</Text>
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 16 },
|
|
toolbar: { flexDirection: 'row', gap: 12, marginBottom: 12 },
|
|
search: {
|
|
flex: 1,
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
borderWidth: 1.5,
|
|
borderRadius: 12,
|
|
color: theme.fg,
|
|
padding: 12,
|
|
fontSize: 16,
|
|
},
|
|
toolbarButton: {
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
borderWidth: 1.5,
|
|
borderRadius: 12,
|
|
paddingHorizontal: 16,
|
|
justifyContent: 'center',
|
|
},
|
|
toolbarButtonActive: { borderColor: theme.accent, backgroundColor: `${theme.accent}1A` },
|
|
toolbarButtonText: { color: theme.fg, fontWeight: '700' },
|
|
subToolbar: { flexDirection: 'row', gap: 8, marginBottom: 16 },
|
|
subButton: {
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
borderWidth: 1.5,
|
|
borderRadius: 12,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 8,
|
|
},
|
|
subButtonActive: {
|
|
backgroundColor: theme.accent,
|
|
borderColor: theme.accent,
|
|
shadowColor: theme.accent,
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: 0.5,
|
|
shadowRadius: 6,
|
|
elevation: 3,
|
|
},
|
|
subButtonText: { color: theme.accentSecondary, fontSize: 13, fontWeight: '700' },
|
|
subButtonTextActive: { color: theme.fg },
|
|
row: {
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
borderWidth: 1,
|
|
borderRadius: 12,
|
|
padding: 12,
|
|
marginBottom: 12,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.2,
|
|
shadowRadius: 4,
|
|
elevation: 3,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
},
|
|
rowDimmed: { opacity: 0.45 },
|
|
favBadge: {
|
|
position: 'absolute',
|
|
top: 6,
|
|
left: 6,
|
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
paddingHorizontal: 5,
|
|
paddingVertical: 1,
|
|
borderRadius: 8,
|
|
},
|
|
favBadgeText: { color: theme.accent, fontSize: 12, fontWeight: '700' },
|
|
thumbnail: {
|
|
width: 100,
|
|
height: 56,
|
|
borderRadius: 8,
|
|
backgroundColor: theme.border,
|
|
},
|
|
rowContent: {
|
|
flex: 1,
|
|
},
|
|
rowTitle: { color: theme.fg, fontWeight: '700', fontSize: 16, marginBottom: 4 },
|
|
rowMuted: { color: theme.muted, fontSize: 14, marginTop: 2 },
|
|
rowSources: { color: theme.accent, fontSize: 12, marginTop: 8, textTransform: 'uppercase', fontWeight: '600' },
|
|
muted: { color: theme.muted, textAlign: 'center', marginTop: 32, fontSize: 16 },
|
|
error: { color: theme.bad, padding: 16 },
|
|
|
|
chip: {
|
|
borderColor: theme.border,
|
|
borderWidth: 1.5,
|
|
borderRadius: 18,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 7,
|
|
backgroundColor: theme.card,
|
|
},
|
|
chipActive: {
|
|
borderColor: theme.accent,
|
|
backgroundColor: `${theme.accent}26`,
|
|
shadowColor: theme.accent,
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: 0.4,
|
|
shadowRadius: 6,
|
|
elevation: 2,
|
|
},
|
|
chipText: { color: theme.muted, fontSize: 13, fontWeight: '700' },
|
|
chipTextActive: { color: theme.accent },
|
|
|
|
// Continue watching rail
|
|
rail: { marginBottom: 16 },
|
|
railLabel: {
|
|
color: theme.muted,
|
|
fontSize: 11,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 1.2,
|
|
fontWeight: '700',
|
|
marginBottom: 8,
|
|
paddingLeft: 2,
|
|
},
|
|
railContent: { gap: 10, paddingRight: 8 },
|
|
railItem: { width: 160 },
|
|
railThumb: {
|
|
width: 160,
|
|
height: 90,
|
|
borderRadius: 8,
|
|
backgroundColor: theme.bgElevated,
|
|
},
|
|
railThumbPlaceholder: {
|
|
borderColor: theme.border,
|
|
borderWidth: 1,
|
|
},
|
|
railProgressBg: {
|
|
height: 3,
|
|
backgroundColor: theme.bgElevated,
|
|
borderRadius: 2,
|
|
marginTop: 4,
|
|
overflow: 'hidden',
|
|
},
|
|
railProgressFg: { height: 3, backgroundColor: theme.accent },
|
|
railTitle: {
|
|
color: theme.fg,
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
marginTop: 6,
|
|
lineHeight: 15,
|
|
},
|
|
|
|
// 2-col grid wrapper — SceneTile sam ma styling tile/thumb/etc, tu tylko
|
|
// odstęp między tilami w wierszu i pod wierszem.
|
|
gridRow: { gap: 10, marginBottom: 14 },
|
|
});
|