feat(mobile): column toggle, duration filter, saved searches, screen protection (mobilism feedback)

Batch from user feedback: (1) Grid columns 1/2/3 setting (PreferencesContext, persisted) across all scene grids — default 2 was too small on phones. (2) Min-duration filter chips (5/10/20/30+ min) to hide ad-clips. (3) Saved-search chips + Save button (backed by /saved-searches). (4) Re-enabled screen-capture protection (Recents hide + screenshot block) for distributed users — verified active on emulator (screencap returns 0 bytes). (5) 'Checking for updates' gate before the PIN screen so a background OTA restart no longer causes a double PIN prompt. Changelog entry added. Published OTA runtime 1.1 (a9620b12).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-16 13:52:27 +02:00
parent bcee5851e9
commit 00f4779abe
15 changed files with 337 additions and 45 deletions

View file

@ -23,6 +23,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GoonClient } from './src/api'; import { GoonClient } from './src/api';
import { ClientProvider } from './src/ClientContext'; import { ClientProvider } from './src/ClientContext';
import { PreferencesProvider } from './src/PreferencesContext';
import { SceneActionsProvider } from './src/SceneActionsContext'; import { SceneActionsProvider } from './src/SceneActionsContext';
import { WhatsNewModal } from './src/components/WhatsNewModal'; import { WhatsNewModal } from './src/components/WhatsNewModal';
import { ErrorBoundary } from './src/ErrorBoundary'; import { ErrorBoundary } from './src/ErrorBoundary';
@ -112,6 +113,9 @@ export default function App() {
const [bootError, setBootError] = useState<string | null>(null); const [bootError, setBootError] = useState<string | null>(null);
const [locked, setLocked] = useState(false); const [locked, setLocked] = useState(false);
const [lockReady, setLockReady] = useState(false); const [lockReady, setLockReady] = useState(false);
// OTA-check settled — gate'uje ekran PIN, żeby ewentualny reloadAsync (silent OTA)
// wydarzył się PRZED pytaniem o PIN, nie po (inaczej "PIN ×2", user-report mobilism).
const [updateSettled, setUpdateSettled] = useState(false);
const backgroundedAt = useRef<number | null>(null); const backgroundedAt = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
@ -160,10 +164,11 @@ export default function App() {
}, [client]); }, [client]);
// FLAG_SECURE — blocks screenshots and hides app preview from app switcher. // FLAG_SECURE — blocks screenshots and hides app preview from app switcher.
// TEMP 2026-06-02: wyłączone żeby umożliwić debug na emulatorze (screencap + // WŁĄCZONE 2026-06-16: realni userzy (mobilism) — NSFW treść nie ma wyciekać do
// playback verification — FLAG_SECURE czyni screenshoty czarnymi). Jan jest na // Recents/screenshotów (user feedback). Bundle OTA jest wspólny, więc to dotyczy
// razie jedynym userem. PRZYWRÓCIĆ `true` przed szerszą dystrybucją. // też emulatora — na czas debugu (adb screencap) flipnij tymczasowo na false
const SCREEN_CAPTURE_PROTECTION = false; // lokalnie i NIE publikuj tej zmiany.
const SCREEN_CAPTURE_PROTECTION = true;
useEffect(() => { useEffect(() => {
if (!SCREEN_CAPTURE_PROTECTION) return; if (!SCREEN_CAPTURE_PROTECTION) return;
ScreenCapture.preventScreenCaptureAsync('goon-applock').catch(() => {}); ScreenCapture.preventScreenCaptureAsync('goon-applock').catch(() => {});
@ -185,6 +190,9 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (!client || updateChecked.current) return; if (!client || updateChecked.current) return;
updateChecked.current = true; updateChecked.current = true;
// Safety: nie trzymaj "Checking for updates" w nieskończoność gdy OTA call wisi
// (zła sieć). Po 15s odblokuj PIN — normalny check+fetch JS-bundla to ~1-3s.
const safety = setTimeout(() => setUpdateSettled(true), 15000);
(async () => { (async () => {
// Etap B: silent JS update // Etap B: silent JS update
if (Updates.isEnabled) { if (Updates.isEnabled) {
@ -193,6 +201,7 @@ export default function App() {
if (upd.isAvailable) { if (upd.isAvailable) {
await Updates.fetchUpdateAsync(); await Updates.fetchUpdateAsync();
// Cichy restart — user zobaczy splash na 200ms i jest w nowej wersji. // Cichy restart — user zobaczy splash na 200ms i jest w nowej wersji.
// Restart następuje TU (przed PIN-em), więc PIN pyta tylko raz po reloadzie.
await Updates.reloadAsync(); await Updates.reloadAsync();
return; // reloadAsync nie wraca return; // reloadAsync nie wraca
} }
@ -200,6 +209,9 @@ export default function App() {
// OTA fail (brak sieci / 204 / TLS pin mismatch) — leciemy do A. // OTA fail (brak sieci / 204 / TLS pin mismatch) — leciemy do A.
} }
} }
// Brak OTA (lub disabled/fail) → odblokuj ekran PIN/login/browse.
clearTimeout(safety);
setUpdateSettled(true);
// Etap A: APK update prompt (native bump albo Expo Updates disabled) // Etap A: APK update prompt (native bump albo Expo Updates disabled)
try { try {
const out = await client.getServerVersion(); const out = await client.getServerVersion();
@ -318,6 +330,33 @@ export default function App() {
); );
} }
// PIN dopiero PO sprawdzeniu OTA — inaczej user wpisuje PIN, w tle ściąga się
// update, reloadAsync restartuje i PIN pyta znowu ("PIN ×2", user-report mobilism).
// Krótki "Checking for updates" zamiast od razu PIN; jeśli jest OTA, restart
// nastąpi tu, przed PIN-em. Dotyczy tylko zablokowanych (bez locka → prosto do browse).
if (locked && !updateSettled) {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<StatusBar style="light" />
<View
style={{
flex: 1,
backgroundColor: theme.bg,
justifyContent: 'center',
alignItems: 'center',
}}
>
<ActivityIndicator color={theme.fg} />
<RNText style={{ color: theme.muted, marginTop: 14, fontSize: 13 }}>
Checking for updates
</RNText>
</View>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
// App lock takes precedence over everything (even login). Refresh settings every // App lock takes precedence over everything (even login). Refresh settings every
// unlock so a freshly-disabled lock doesn't bounce back next background. // unlock so a freshly-disabled lock doesn't bounce back next background.
if (locked) { if (locked) {
@ -354,6 +393,7 @@ export default function App() {
) : null} ) : null}
{client ? ( {client ? (
<ClientProvider client={client}> <ClientProvider client={client}>
<PreferencesProvider>
<SceneActionsProvider> <SceneActionsProvider>
<AppNavigator <AppNavigator
client={client} client={client}
@ -366,6 +406,7 @@ export default function App() {
/> />
<WhatsNewModal /> <WhatsNewModal />
</SceneActionsProvider> </SceneActionsProvider>
</PreferencesProvider>
</ClientProvider> </ClientProvider>
) : ( ) : (
<LoginScreen <LoginScreen

View file

@ -0,0 +1,38 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { getGridColumns, setGridColumns as persistGridColumns } from './storage';
/**
* Globalne preferencje UI (na razie tylko liczba kolumn siatki scen). Ładowane raz
* przy montażu z SecureStore (async) i dalej trzymane synchronicznie w state, żeby
* ekrany siatek mogły czytać wartość w renderze bez await. Default 2 do czasu załadowania.
*/
interface Prefs {
gridColumns: number;
setGridColumns: (n: number) => void;
}
const Ctx = createContext<Prefs | null>(null);
export function PreferencesProvider({ children }: { children: React.ReactNode }) {
const [gridColumns, setCols] = useState(2);
useEffect(() => {
getGridColumns()
.then(setCols)
.catch(() => {});
}, []);
const setGridColumns = (n: number) => {
setCols(n);
persistGridColumns(n).catch(() => {});
};
return <Ctx.Provider value={{ gridColumns, setGridColumns }}>{children}</Ctx.Provider>;
}
export function usePreferences(): Prefs {
const c = useContext(Ctx);
if (!c) throw new Error('usePreferences outside PreferencesProvider');
return c;
}

View file

@ -21,6 +21,7 @@ import type {
ResolveAction, ResolveAction,
ResolveOut, ResolveOut,
ResolveResult, ResolveResult,
SavedSearchOut,
ScenesListParams, ScenesListParams,
SceneListOut, SceneListOut,
SceneOut, SceneOut,
@ -187,6 +188,22 @@ export class GoonClient {
} }
} }
// Saved searches — zapisane słowa kluczowe (user-report mobilism).
async listSavedSearches(): Promise<SavedSearchOut[]> {
return this.request('/saved-searches');
}
async addSavedSearch(query: string): Promise<SavedSearchOut> {
return this.request('/saved-searches', {
method: 'POST',
body: JSON.stringify({ query }),
});
}
async removeSavedSearch(id: string): Promise<void> {
await this.request(`/saved-searches/${id}`, { method: 'DELETE' });
}
async listTags(params: { async listTags(params: {
q?: string; q?: string;
order?: 'popular' | 'name'; order?: 'popular' | 'name';

View file

@ -16,6 +16,17 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-06-16',
date: 'June 2026',
items: [
'Grid columns: pick 1, 2 or 3 thumbnails per row in Settings (1 = bigger previews).',
'Filter scenes by minimum length — hide short clips (5/10/20/30+ min).',
'Save searches: tap ☆ Save next to the search box, then reuse them as chips.',
'Privacy: the app is hidden in Recents and blocks screenshots again.',
'Smoother startup when an update is downloading (no more double PIN prompt).',
],
},
{ {
id: '2026-06-13', id: '2026-06-13',
date: 'June 2026', date: 'June 2026',

View file

@ -156,7 +156,7 @@ export function SceneTile({ scene, secondLine = 'studio', seenSince, onLongPress
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
tile: { flex: 1 }, tile: { flex: 1, marginBottom: 14 },
thumbWrap: { thumbWrap: {
width: '100%', width: '100%',
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
@ -244,7 +244,15 @@ const styles = StyleSheet.create({
}, },
}); });
export const sceneTileGridProps = { /**
numColumns: 2 as const, * Props siatki scen dla danej liczby kolumn (1/2/3, z PreferencesContext).
columnWrapperStyle: { gap: 10, marginBottom: 14 }, * `key` wymusza remount FlatListy przy zmianie numColumns (RN nie wspiera zmiany w locie).
* columnWrapperStyle TYLKO gdy cols>1 przy 1 kolumnie RN rzuca jeśli ustawione.
*/
export function sceneGridProps(cols: number) {
return {
key: `scenegrid-${cols}`,
numColumns: cols,
columnWrapperStyle: cols > 1 ? { gap: 10 } : undefined,
}; };
}

View file

@ -23,6 +23,7 @@ import {
verifyPin, verifyPin,
} from '../lib/applock'; } from '../lib/applock';
import { APP_VERSION } from '../lib/appVersion'; import { APP_VERSION } from '../lib/appVersion';
import { usePreferences } from '../PreferencesContext';
import { theme } from '../theme'; import { theme } from '../theme';
import { PinEntry } from './PinEntry'; import { PinEntry } from './PinEntry';
@ -42,6 +43,7 @@ export function AppLockSettingsScreen() {
const [stage, setStage] = useState<Stage>('menu'); const [stage, setStage] = useState<Stage>('menu');
const [newPin, setNewPin] = useState(''); const [newPin, setNewPin] = useState('');
const [errorText, setErrorText] = useState<string | null>(null); const [errorText, setErrorText] = useState<string | null>(null);
const { gridColumns, setGridColumns } = usePreferences();
async function refresh() { async function refresh() {
const [s, bio] = await Promise.all([getSettings(), biometricAvailable()]); const [s, bio] = await Promise.all([getSettings(), biometricAvailable()]);
@ -258,6 +260,27 @@ export function AppLockSettingsScreen() {
</Text> </Text>
</View> </View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Grid</Text>
<Text style={styles.hint}>Thumbnails per row in scene lists</Text>
<View style={styles.chipRow}>
{[1, 2, 3].map((n) => {
const active = gridColumns === n;
return (
<Pressable
key={n}
style={[styles.chip, active && styles.chipActive]}
onPress={() => setGridColumns(n)}
>
<Text style={[styles.chipText, active && styles.chipTextActive]}>
{n === 1 ? '1 column' : `${n} columns`}
</Text>
</Pressable>
);
})}
</View>
</View>
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>About</Text> <Text style={styles.sectionTitle}>About</Text>
<View style={styles.row}> <View style={styles.row}>

View file

@ -18,7 +18,8 @@ import {
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import { SceneTile } from '../components/SceneTile'; import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { usePreferences } from '../PreferencesContext';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import type { FavoriteMovieOut, FavoriteOut, FavoriteStudioOut } from '../types'; import type { FavoriteMovieOut, FavoriteOut, FavoriteStudioOut } from '../types';
@ -27,6 +28,7 @@ type Tab = 'scenes' | 'performers' | 'studios' | 'movies';
export function FavoritesScreen() { export function FavoritesScreen() {
const client = useClient(); const client = useClient();
const { gridColumns } = usePreferences();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Favorites'>>(); const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Favorites'>>();
const [tab, setTab] = React.useState<Tab>('scenes'); const [tab, setTab] = React.useState<Tab>('scenes');
@ -181,12 +183,12 @@ export function FavoritesScreen() {
{tab === 'scenes' ? ( {tab === 'scenes' ? (
<FlatList <FlatList
// key per tab: scenes używa numColumns=2, reszta 1 — bez remountu RN rzuca {...sceneGridProps(gridColumns)}
// "Changing numColumns on the fly is not supported" (GOON-11). // key per tab + per liczba kolumn: bez remountu RN rzuca "Changing numColumns
key="fav-scenes" // on the fly is not supported" (GOON-11). Zmiana kolumn też wymaga remountu.
key={`fav-scenes-${gridColumns}`}
data={scenesQuery.data?.items ?? []} data={scenesQuery.data?.items ?? []}
keyExtractor={(s) => s.id} keyExtractor={(s) => s.id}
numColumns={2}
// patrz ScenesScreen: removeClippedSubviews blankuje miniaturki po scrollu. // patrz ScenesScreen: removeClippedSubviews blankuje miniaturki po scrollu.
removeClippedSubviews={false} removeClippedSubviews={false}
renderItem={({ item }) => ( renderItem={({ item }) => (
@ -209,7 +211,6 @@ export function FavoritesScreen() {
} }
/> />
)} )}
columnWrapperStyle={styles.gridRow}
refreshing={isRefetching} refreshing={isRefetching}
onRefresh={refetch} onRefresh={refetch}
ListEmptyComponent={ ListEmptyComponent={

View file

@ -21,7 +21,8 @@ import {
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import { FavoriteSceneRow } from '../components/FavoriteSceneRow'; import { FavoriteSceneRow } from '../components/FavoriteSceneRow';
import { MoviePosterCard } from '../components/MoviePosterCard'; import { MoviePosterCard } from '../components/MoviePosterCard';
import { SceneTile } from '../components/SceneTile'; import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { usePreferences } from '../PreferencesContext';
import { ErrorBoundary } from '../ErrorBoundary'; import { ErrorBoundary } from '../ErrorBoundary';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
@ -33,6 +34,7 @@ const MOVIE_COLS = 2;
export function PerformerScenesScreen() { export function PerformerScenesScreen() {
const client = useClient(); const client = useClient();
const { gridColumns } = usePreferences();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigation = const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'PerformerScenes'>>(); useNavigation<NativeStackNavigationProp<RootStackParamList, 'PerformerScenes'>>();
@ -232,10 +234,10 @@ export function PerformerScenesScreen() {
<Text style={styles.error}>{scenesQuery.error.message}</Text> <Text style={styles.error}>{scenesQuery.error.message}</Text>
)} )}
<FlatList <FlatList
key="scenes-list" {...sceneGridProps(gridColumns)}
key={`scenes-list-${gridColumns}`}
data={sortedScenes} data={sortedScenes}
keyExtractor={(s) => s.id} keyExtractor={(s) => s.id}
numColumns={2}
// Android default removeClippedSubviews=true odpina miniaturki poza // Android default removeClippedSubviews=true odpina miniaturki poza
// viewportem i expo-image często nie re-renderuje ich po powrocie → // viewportem i expo-image często nie re-renderuje ich po powrocie →
// "miniaturki znikają przy scrollu" (bug-report f181d382 2026-06-07). // "miniaturki znikają przy scrollu" (bug-report f181d382 2026-06-07).
@ -243,7 +245,6 @@ export function PerformerScenesScreen() {
renderItem={({ item }) => ( renderItem={({ item }) => (
<SceneTile scene={item} seenSince={seenSince} secondLine="studio" /> <SceneTile scene={item} seenSince={seenSince} secondLine="studio" />
)} )}
columnWrapperStyle={styles.gridRow}
refreshing={scenesQuery.isRefetching} refreshing={scenesQuery.isRefetching}
onRefresh={scenesQuery.refetch} onRefresh={scenesQuery.refetch}
ListHeaderComponent={ ListHeaderComponent={

View file

@ -25,6 +25,7 @@ export interface FilterState {
hasPlayback: boolean; hasPlayback: boolean;
includeStubs: boolean; includeStubs: boolean;
origin: string; origin: string;
minDurationSec: number; // 0 = bazowy próg (60s); >0 podnosi minimum (filtr na klipy-reklamy)
} }
export const DEFAULT_FILTER: FilterState = { export const DEFAULT_FILTER: FilterState = {
@ -35,6 +36,7 @@ export const DEFAULT_FILTER: FilterState = {
hasPlayback: true, // default: ukryj sceny bez playback linka (kompletność > świeżość) hasPlayback: true, // default: ukryj sceny bez playback linka (kompletność > świeżość)
includeStubs: false, // default: ukryj 5-10min hqporner trailery bez release_date includeStubs: false, // default: ukryj 5-10min hqporner trailery bez release_date
origin: '', // pusty = wszystkie źródła; substring match np. 'hqporner' origin: '', // pusty = wszystkie źródła; substring match np. 'hqporner'
minDurationSec: 0,
}; };
const SORT_OPTIONS: { value: ScenesSort; label: string }[] = [ const SORT_OPTIONS: { value: ScenesSort; label: string }[] = [
@ -44,6 +46,16 @@ const SORT_OPTIONS: { value: ScenesSort; label: string }[] = [
{ value: 'studio', label: 'studio' }, { value: 'studio', label: 'studio' },
]; ];
// Filtr długości — user-report mobilism: "short clips nowadays are mostly ads in
// disguise". "Any" = bazowy próg backendu (60s, ukrywa teasery); reszta podnosi minimum.
const DURATION_OPTIONS: { value: number; label: string }[] = [
{ value: 0, label: 'Any' },
{ value: 300, label: '5+ min' },
{ value: 600, label: '10+ min' },
{ value: 1200, label: '20+ min' },
{ value: 1800, label: '30+ min' },
];
export function ScenesFilterModal({ export function ScenesFilterModal({
visible, visible,
initial, initial,
@ -106,7 +118,8 @@ export function ScenesFilterModal({
draft.tagSlugs.length + draft.tagSlugs.length +
draft.studioSlugs.length + draft.studioSlugs.length +
draft.performerIds.length + draft.performerIds.length +
(draft.hasPlayback ? 1 : 0); (draft.hasPlayback ? 1 : 0) +
(draft.minDurationSec > 0 ? 1 : 0);
return ( return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}> <Modal visible={visible} animationType="slide" onRequestClose={onClose}>
@ -163,6 +176,30 @@ export function ScenesFilterModal({
</View> </View>
</Section> </Section>
<Section title="Min duration">
<View style={styles.sortRow}>
{DURATION_OPTIONS.map((opt) => (
<Pressable
key={opt.value}
style={[
styles.sortPill,
draft.minDurationSec === opt.value && styles.sortPillActive,
]}
onPress={() => setDraft({ ...draft, minDurationSec: opt.value })}
>
<Text
style={[
styles.sortPillText,
draft.minDurationSec === opt.value && styles.sortPillTextActive,
]}
>
{opt.label}
</Text>
</Pressable>
))}
</View>
</Section>
<Section title="Source / hoster"> <Section title="Source / hoster">
<TextInput <TextInput
style={styles.search} style={styles.search}

View file

@ -1,9 +1,10 @@
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
FlatList, FlatList,
Pressable, Pressable,
ScrollView, ScrollView,
@ -14,10 +15,11 @@ import {
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { SceneTile } from '../components/SceneTile'; import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { SceneGridSkeleton } from '../components/SceneGridSkeleton'; import { SceneGridSkeleton } from '../components/SceneGridSkeleton';
import { Thumb } from '../components/Thumb'; import { Thumb } from '../components/Thumb';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import { usePreferences } from '../PreferencesContext';
import { theme } from '../theme'; import { theme } from '../theme';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import type { SceneOut } from '../types'; import type { SceneOut } from '../types';
@ -25,6 +27,7 @@ import { DEFAULT_FILTER, FilterState, ScenesFilterModal } from './ScenesFilterMo
export function ScenesScreen() { export function ScenesScreen() {
const client = useClient(); const client = useClient();
const { gridColumns } = usePreferences();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>(); const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [debouncedQ, setDebouncedQ] = useState(''); const [debouncedQ, setDebouncedQ] = useState('');
@ -58,6 +61,8 @@ export function ScenesScreen() {
sort: filter.sort, sort: filter.sort,
include_stubs: filter.includeStubs || undefined, include_stubs: filter.includeStubs || undefined,
origin: filter.origin.trim() || undefined, origin: filter.origin.trim() || undefined,
// 0 = "Any" → undefined → backend stosuje bazowy próg 60s. >0 podnosi minimum.
min_duration_sec: filter.minDurationSec > 0 ? filter.minDurationSec : undefined,
page: pageParam, page: pageParam,
per_page: PER_PAGE, per_page: PER_PAGE,
}), }),
@ -83,6 +88,27 @@ export function ScenesScreen() {
(filter.hasPlayback ? 1 : 0) + (filter.hasPlayback ? 1 : 0) +
(filter.origin.trim() ? 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 ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.toolbar}> <View style={styles.toolbar}>
@ -94,6 +120,22 @@ export function ScenesScreen() {
placeholderTextColor={theme.muted} placeholderTextColor={theme.muted}
autoCapitalize="none" autoCapitalize="none"
/> />
{q.trim().length > 0 ? (
<Pressable
onPress={() => {
const v = q.trim();
if (v && !alreadySaved) addSaved.mutate(v);
}}
disabled={alreadySaved}
style={[styles.toolbarButton, alreadySaved && styles.toolbarButtonActive]}
>
<Text
style={[styles.toolbarButtonText, alreadySaved && { color: theme.accent }]}
>
{alreadySaved ? '★ Saved' : '☆ Save'}
</Text>
</Pressable>
) : null}
<Pressable <Pressable
onPress={() => setFilterOpen(true)} onPress={() => setFilterOpen(true)}
style={[styles.toolbarButton, activeCount > 0 && styles.toolbarButtonActive]} style={[styles.toolbarButton, activeCount > 0 && styles.toolbarButtonActive]}
@ -104,6 +146,43 @@ export function ScenesScreen() {
</Pressable> </Pressable>
</View> </View>
{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}> <View style={styles.subToolbar}>
<Pressable <Pressable
style={styles.subButton} style={styles.subButton}
@ -126,14 +205,13 @@ export function ScenesScreen() {
<SceneGridSkeleton count={8} /> <SceneGridSkeleton count={8} />
) : ( ) : (
<FlatList <FlatList
{...sceneGridProps(gridColumns)}
data={items} data={items}
keyExtractor={(s) => s.id} keyExtractor={(s) => s.id}
numColumns={2}
// Android removeClippedSubviews=true (default) blankuje miniaturki po scrollu — // Android removeClippedSubviews=true (default) blankuje miniaturki po scrollu —
// expo-image nie re-renderuje odpiętych subview. Bug-report "znikają miniaturki". // expo-image nie re-renderuje odpiętych subview. Bug-report "znikają miniaturki".
removeClippedSubviews={false} removeClippedSubviews={false}
renderItem={({ item }) => <SceneTile scene={item} />} renderItem={({ item }) => <SceneTile scene={item} />}
columnWrapperStyle={styles.gridRow}
ListHeaderComponent={!debouncedQ && activeCount === 0 ? <ContinueWatchingRail /> : null} ListHeaderComponent={!debouncedQ && activeCount === 0 ? <ContinueWatchingRail /> : null}
refreshing={isRefetching} refreshing={isRefetching}
onRefresh={refetch} onRefresh={refetch}
@ -325,6 +403,20 @@ const styles = StyleSheet.create({
}, },
toolbarButtonActive: { borderColor: theme.accent, backgroundColor: `${theme.accent}1A` }, toolbarButtonActive: { borderColor: theme.accent, backgroundColor: `${theme.accent}1A` },
toolbarButtonText: { color: theme.fg, fontWeight: '700' }, toolbarButtonText: { color: theme.fg, fontWeight: '700' },
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.accent, backgroundColor: `${theme.accent}1A` },
savedChipText: { color: theme.fg, fontSize: 13 },
savedChipTextActive: { color: theme.accent, fontWeight: '600' },
subToolbar: { flexDirection: 'row', gap: 8, marginBottom: 16 }, subToolbar: { flexDirection: 'row', gap: 8, marginBottom: 16 },
subButton: { subButton: {
backgroundColor: theme.card, backgroundColor: theme.card,

View file

@ -22,15 +22,17 @@ import {
TextInput, TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { SceneTile } from '../components/SceneTile'; import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { Thumb } from '../components/Thumb'; import { Thumb } from '../components/Thumb';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import { usePreferences } from '../PreferencesContext';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import type { SceneOut, TagOut } from '../types'; import type { SceneOut, TagOut } from '../types';
export function SiteScenesScreen() { export function SiteScenesScreen() {
const client = useClient(); const client = useClient();
const { gridColumns } = usePreferences();
const navigation = const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'SiteScenes'>>(); useNavigation<NativeStackNavigationProp<RootStackParamList, 'SiteScenes'>>();
const route = useRoute<RouteProp<RootStackParamList, 'SiteScenes'>>(); const route = useRoute<RouteProp<RootStackParamList, 'SiteScenes'>>();
@ -110,12 +112,11 @@ export function SiteScenesScreen() {
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>} {error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
<FlatList <FlatList
{...sceneGridProps(gridColumns)}
data={items} data={items}
keyExtractor={(s) => s.id} keyExtractor={(s) => s.id}
numColumns={2}
removeClippedSubviews={false} removeClippedSubviews={false}
renderItem={({ item }) => <SceneTile scene={item} secondLine="studio" />} renderItem={({ item }) => <SceneTile scene={item} secondLine="studio" />}
columnWrapperStyle={styles.gridRow}
refreshing={isRefetching} refreshing={isRefetching}
onRefresh={refetch} onRefresh={refetch}
onEndReached={() => { onEndReached={() => {

View file

@ -16,14 +16,16 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { FavoriteSceneRow } from '../components/FavoriteSceneRow'; import { FavoriteSceneRow } from '../components/FavoriteSceneRow';
import { SceneTile } from '../components/SceneTile'; import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import { usePreferences } from '../PreferencesContext';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import type { SceneOut } from '../types'; import type { SceneOut } from '../types';
export function StudioScenesScreen() { export function StudioScenesScreen() {
const client = useClient(); const client = useClient();
const { gridColumns } = usePreferences();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigation = const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'StudioScenes'>>(); useNavigation<NativeStackNavigationProp<RootStackParamList, 'StudioScenes'>>();
@ -135,14 +137,13 @@ export function StudioScenesScreen() {
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>} {error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
<FlatList <FlatList
{...sceneGridProps(gridColumns)}
data={sortedItems} data={sortedItems}
keyExtractor={(s) => s.id} keyExtractor={(s) => s.id}
numColumns={2}
removeClippedSubviews={false} removeClippedSubviews={false}
renderItem={({ item }) => ( renderItem={({ item }) => (
<SceneTile scene={item} seenSince={seenSince} secondLine="performers" /> <SceneTile scene={item} seenSince={seenSince} secondLine="performers" />
)} )}
columnWrapperStyle={styles.gridRow}
refreshing={isRefetching} refreshing={isRefetching}
onRefresh={refetch} onRefresh={refetch}
ListHeaderComponent={ ListHeaderComponent={

View file

@ -10,15 +10,17 @@ import {
Text, Text,
View, View,
} from 'react-native'; } from 'react-native';
import { SceneTile } from '../components/SceneTile'; import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { SceneGridSkeleton } from '../components/SceneGridSkeleton'; import { SceneGridSkeleton } from '../components/SceneGridSkeleton';
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import { usePreferences } from '../PreferencesContext';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import type { SceneOut } from '../types'; import type { SceneOut } from '../types';
export function TagScenesScreen() { export function TagScenesScreen() {
const client = useClient(); const client = useClient();
const { gridColumns } = usePreferences();
const navigation = const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'TagScenes'>>(); useNavigation<NativeStackNavigationProp<RootStackParamList, 'TagScenes'>>();
const route = useRoute<RouteProp<RootStackParamList, 'TagScenes'>>(); const route = useRoute<RouteProp<RootStackParamList, 'TagScenes'>>();
@ -47,12 +49,11 @@ export function TagScenesScreen() {
<SceneGridSkeleton count={10} /> <SceneGridSkeleton count={10} />
) : ( ) : (
<FlatList <FlatList
{...sceneGridProps(gridColumns)}
data={data?.items ?? []} data={data?.items ?? []}
keyExtractor={(s) => s.id} keyExtractor={(s) => s.id}
numColumns={2}
removeClippedSubviews={false} removeClippedSubviews={false}
renderItem={({ item }) => <SceneTile scene={item} secondLine="studio" />} renderItem={({ item }) => <SceneTile scene={item} secondLine="studio" />}
columnWrapperStyle={styles.gridRow}
refreshing={isRefetching} refreshing={isRefetching}
onRefresh={refetch} onRefresh={refetch}
ListHeaderComponent={ ListHeaderComponent={

View file

@ -50,6 +50,20 @@ export async function setLastSeenChangelog(id: string): Promise<void> {
await SecureStore.setItemAsync(CHANGELOG_SEEN_KEY, id); await SecureStore.setItemAsync(CHANGELOG_SEEN_KEY, id);
} }
// Liczba kolumn w siatkach scen (1/2/3). Feedback mobilism: domyślne 2 kol = za małe
// miniaturki na telefonie do rozpoznania performera. Default 2 (tablet-friendly).
const GRID_COLS_KEY = 'goon.grid_columns';
export async function getGridColumns(): Promise<number> {
const v = await SecureStore.getItemAsync(GRID_COLS_KEY);
const n = v ? parseInt(v, 10) : 2;
return n === 1 || n === 2 || n === 3 ? n : 2;
}
export async function setGridColumns(n: number): Promise<void> {
await SecureStore.setItemAsync(GRID_COLS_KEY, String(n));
}
export interface Credentials { export interface Credentials {
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;

View file

@ -354,6 +354,12 @@ export interface BlacklistOut {
export type BlacklistKind = 'performer' | 'studio' | 'tag'; export type BlacklistKind = 'performer' | 'studio' | 'tag';
export interface SavedSearchOut {
id: string;
query: string;
created_at: string;
}
export interface ProgressOut { export interface ProgressOut {
scene_id: string; scene_id: string;
position_sec: number; position_sec: number;