Zgloszenie 2026-08-18 (cztery zrzuty obok siebie): "rozne odstepy miedzy zakladkami, kazda zakladka wyglada inaczej, rozne wielkosci text fields, rozne obramowki, chyba nawet inne czcionki". Przyczyna strukturalna: kazdy ekran-lista mial WLASNA kopie stylow search/toolbar/ przycisku filtra. Zmierzony dryf: rog pola 12 vs 10, ramka 1,5 vs 1, font 16 vs 14, odstep toolbara 12 vs 8, dwa promienie pigulek (8 i 12), padding kontenera 16/16 vs 16/12 vs 0. "Inne czcionki" = etykiety mialy fontWeight:'700' BEZ fontFamily, a RN nie syntezuje pogrubienia dla fontu custom -> chudy Regular zamiast bold. Nowy `src/ui.tsx`: SearchInput, PillButton, Toolbar + wspolne stale SCREEN_PAD_*. Jedna wysokosc kontrolek w wierszu, jeden promien, jedna ramka, waga przez fontFamily (nie fontWeight), stan aktywny robi kolor. Scenes/Movies/Sites przepiete na to, ich lokalne duplikaty stylow usuniete (w tym osierocony SegButton w Sites). JAV i Anime reuzywaja ScenesScreen, wiec dostaja to samo za darmo. Zweryfikowane na emulatorze: cztery zakladki jeden przy drugim maja identyczne pole, przycisk, pigulki i marginesy. 403 na liscie to build debug spoza allowlisty, nie UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
544 lines
19 KiB
TypeScript
544 lines
19 KiB
TypeScript
import { useNavigation, useRoute } from '@react-navigation/native';
|
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import React, { useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
FlatList,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import { Image } from 'expo-image';
|
|
import * as Haptics from 'expo-haptics';
|
|
import { MigrationBanner } from '../components/MigrationBanner';
|
|
import { PillButton, SCREEN_PAD_H, SCREEN_PAD_TOP, SearchInput, Toolbar } from '../ui';
|
|
import { SceneTile, sceneGridProps } from '../components/SceneTile';
|
|
import { SceneGridSkeleton } from '../components/SceneGridSkeleton';
|
|
import { Thumb } from '../components/Thumb';
|
|
import { useClient } from '../ClientContext';
|
|
import { usePreferences } from '../PreferencesContext';
|
|
import { theme } from '../theme';
|
|
import type { RootStackParamList } from '../navigation';
|
|
import type { SceneOut } from '../types';
|
|
import { DEFAULT_FILTER, FilterState, ScenesFilterModal } from './ScenesFilterModal';
|
|
|
|
export function ScenesScreen({
|
|
jav: javProp,
|
|
vertical: verticalProp,
|
|
}: {
|
|
/** Nadpisuje param trasy. HomeScreen renderuje ten ekran BEZ nawigacji (zakładki to
|
|
* stan lokalny, nie osobne trasy), więc wertykał przychodzi propsem, nie paramem. */
|
|
jav?: boolean;
|
|
vertical?: string;
|
|
} = {}) {
|
|
const client = useClient();
|
|
const { gridColumns } = usePreferences();
|
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
|
|
// Zakładki wertykałów reużywają ten ekran: JAV przez `jav`, Anime przez
|
|
// `vertical='animation'`. Brak obu → główny feed, z którego backend odcina wertykały.
|
|
const route = useRoute();
|
|
const params = route.params as { jav?: boolean; vertical?: string } | undefined;
|
|
const jav = javProp ?? (params?.jav === true);
|
|
const vertical = verticalProp ?? params?.vertical;
|
|
// Wspólna nazwa wertykału do zapytań o tagi (stary `jav` też ma swój słownik).
|
|
const verticalKey = vertical ?? (jav ? 'jav' : undefined);
|
|
// Animacja NIE MA obsady i to nie brak danych, tylko wlasciwosc tresci: dla postaci
|
|
// rysowanych nie istnieje kanon, wiec zaden scraper nie ma czego przypisac (0 performerow
|
|
// na 12 698 scen, pomiar 2026-08-14). Przycisk prowadzilby do gwarantowanie pustej listy.
|
|
const hasCast = verticalKey !== 'animation';
|
|
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, jav, vertical],
|
|
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,
|
|
jav: jav || undefined,
|
|
vertical,
|
|
min_quality_p: filter.minQualityP > 0 ? filter.minQualityP : undefined,
|
|
// 0 = "Any" → undefined → backend stosuje bazowy próg 60s. >0 podnosi minimum.
|
|
// JAV nie ma duration (javflix nie podaje) → w sekcji JAV wyłączamy próg 60s
|
|
// (explicit 0), inaczej NULL >= 60 = false i cała sekcja byłaby pusta.
|
|
// JAV i animacja: część źródeł nie podaje długości (javflix, hentaigasm,
|
|
// hentaiplay) → NULL >= 60 = false wycięłoby całą sekcję. Explicit 0 wyłącza próg.
|
|
min_duration_sec: verticalKey
|
|
? 0
|
|
: filter.minDurationSec > 0
|
|
? filter.minDurationSec
|
|
: undefined,
|
|
page: pageParam,
|
|
per_page: PER_PAGE,
|
|
}),
|
|
initialPageParam: 1,
|
|
getNextPageParam: (lastPage) => {
|
|
// Paginuj po has_more (źródło prawdy z fetcha per_page+1). `total` jest dla
|
|
// list filtrowanych bounded ("1000+"), więc NIE nadaje się do liczenia stron.
|
|
// Fallback na loaded<total gdy stary backend nie zwraca has_more.
|
|
const more =
|
|
lastPage.has_more ?? lastPage.page * lastPage.per_page < lastPage.total;
|
|
return more ? lastPage.page + 1 : undefined;
|
|
},
|
|
});
|
|
// Dedup po id przy sklejaniu stron. OFFSET-paginacja + ciągły ingest = kolejne strony
|
|
// potrafią nałożyć się na siebie (nowe sceny zsuwają offset w dół), przez co ta sama
|
|
// scena wracała w feedzie przy scrollu (report 304c9258). Zostawiamy pierwsze
|
|
// wystąpienie (najświeższe). Backend ma teraz też deterministyczny tie-break (Scene.id).
|
|
const items = React.useMemo(() => {
|
|
const flat = data?.pages.flatMap((p) => p.items) ?? [];
|
|
const seen = new Set<string>();
|
|
return flat.filter((s) => (seen.has(s.id) ? false : (seen.add(s.id), true)));
|
|
}, [data?.pages]);
|
|
const total = data?.pages[0]?.total ?? 0;
|
|
// total bywa bounded ("1000+") dla list filtrowanych (q/tag), patrz backend _COUNT_CAP.
|
|
const totalLabel = `${total}${data?.pages[0]?.total_capped ? '+' : ''}`;
|
|
|
|
const activeCount =
|
|
filter.tagSlugs.length +
|
|
filter.studioSlugs.length +
|
|
filter.performerIds.length +
|
|
(filter.hasPlayback ? 1 : 0) +
|
|
(filter.origin.trim() ? 1 : 0);
|
|
|
|
// Saved searches, zapisane słowa kluczowe (user-report mobilism).
|
|
const queryClient = useQueryClient();
|
|
const savedQuery = useQuery({
|
|
queryKey: ['saved-searches'],
|
|
queryFn: () => client.listSavedSearches(),
|
|
});
|
|
const saved = savedQuery.data ?? [];
|
|
const addSaved = useMutation({
|
|
mutationFn: (query: string) => client.addSavedSearch(query),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['saved-searches'] }),
|
|
});
|
|
const removeSaved = useMutation({
|
|
mutationFn: (id: string) => client.removeSavedSearch(id),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['saved-searches'] }),
|
|
});
|
|
const applySaved = (query: string) => {
|
|
setQ(query);
|
|
setDebouncedQ(query); // natychmiast, bez czekania na debounce
|
|
};
|
|
const alreadySaved = saved.some((s) => s.query === q.trim());
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<Toolbar>
|
|
<SearchInput value={q} onChangeText={setQ} placeholder="search title…" />
|
|
{q.trim().length > 0 ? (
|
|
<PillButton
|
|
label={alreadySaved ? '★ Saved' : '☆ Save'}
|
|
active={alreadySaved}
|
|
onPress={() => {
|
|
const v = q.trim();
|
|
if (v && !alreadySaved) addSaved.mutate(v);
|
|
}}
|
|
/>
|
|
) : null}
|
|
<PillButton
|
|
label={`Filter${activeCount > 0 ? ` ${activeCount}` : ''}`}
|
|
active={activeCount > 0}
|
|
onPress={() => setFilterOpen(true)}
|
|
/>
|
|
</Toolbar>
|
|
|
|
{saved.length > 0 ? (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={styles.savedRow}
|
|
contentContainerStyle={styles.savedRowContent}
|
|
>
|
|
{saved.map((s) => {
|
|
const active = debouncedQ === s.query;
|
|
return (
|
|
<Pressable
|
|
key={s.id}
|
|
style={[styles.savedChip, active && styles.savedChipActive]}
|
|
onPress={() => applySaved(s.query)}
|
|
onLongPress={() =>
|
|
Alert.alert('Remove saved search', `Remove "${s.query}"?`, [
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{
|
|
text: 'Remove',
|
|
style: 'destructive',
|
|
onPress: () => removeSaved.mutate(s.id),
|
|
},
|
|
])
|
|
}
|
|
>
|
|
<Text
|
|
style={[styles.savedChipText, active && styles.savedChipTextActive]}
|
|
numberOfLines={1}
|
|
>
|
|
{s.query}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
) : null}
|
|
|
|
<View style={styles.subToolbar}>
|
|
{hasCast ? (
|
|
<PillButton
|
|
label="Performers"
|
|
tone="secondary"
|
|
compact
|
|
onPress={() =>
|
|
navigation.navigate('Performers', verticalKey ? { vertical: verticalKey } : undefined)
|
|
}
|
|
/>
|
|
) : null}
|
|
<PillButton
|
|
label="Tags"
|
|
tone="secondary"
|
|
compact
|
|
onPress={() => navigation.navigate('Tags', verticalKey ? { vertical: verticalKey } : undefined)}
|
|
/>
|
|
<FavoritesButton />
|
|
</View>
|
|
|
|
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
|
|
|
{/* Nad listą, nie w ListHeaderComponent: tam znikałby przy wyszukiwaniu i odjeżdżał
|
|
przy scrollu, a to komunikat, którego przegapienie kosztuje usera ulubione. */}
|
|
<MigrationBanner />
|
|
|
|
{isLoading ? (
|
|
<SceneGridSkeleton count={8} />
|
|
) : (
|
|
<FlatList
|
|
{...sceneGridProps(gridColumns)}
|
|
data={items}
|
|
keyExtractor={(s) => s.id}
|
|
// removeClippedSubviews + okno renderowania (windowSize itd.) idą z sceneGridProps
|
|
// patrz SceneTile. Perf bug-report 5b7ca1e1 (jank/obciążenie przy długim scrollu).
|
|
renderItem={({ item }) => <SceneTile scene={item} />}
|
|
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} / ${totalLabel}`}</Text>
|
|
) : null
|
|
}
|
|
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
|
|
contentContainerStyle={{ paddingBottom: 24 }}
|
|
/>
|
|
)}
|
|
|
|
<ScenesFilterModal
|
|
visible={filterOpen}
|
|
initial={filter}
|
|
vertical={verticalKey}
|
|
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 (
|
|
<PillButton
|
|
label={`★ ${hasNew ? newTotal : ''}`.trim()}
|
|
tone="secondary"
|
|
compact
|
|
active={hasNew}
|
|
onPress={() => navigation.navigate('Favorites')}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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: SCREEN_PAD_H, paddingTop: SCREEN_PAD_TOP },
|
|
savedRow: { flexGrow: 0, marginBottom: 12 },
|
|
savedRowContent: { gap: 8, paddingRight: 12 },
|
|
savedChip: {
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
borderWidth: 1,
|
|
borderRadius: 14,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
maxWidth: 200,
|
|
},
|
|
savedChipActive: { borderColor: theme.accentText, backgroundColor: `${theme.accent}1A` },
|
|
savedChipText: { color: theme.fg, fontSize: 13 },
|
|
savedChipTextActive: { color: theme.accentText, fontWeight: '600' },
|
|
subToolbar: { flexDirection: 'row', gap: 8, marginBottom: 16 },
|
|
subButtonActive: {
|
|
backgroundColor: theme.accent,
|
|
borderColor: theme.accentText,
|
|
shadowColor: theme.accent,
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: 0.5,
|
|
shadowRadius: 6,
|
|
elevation: 3,
|
|
},
|
|
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.accentText, 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.accentText, 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.accentText,
|
|
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.accentText },
|
|
|
|
// 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 },
|
|
});
|