diff --git a/mobile/App.tsx b/mobile/App.tsx index 2b19ffe..d36162c 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -23,6 +23,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { GoonClient } from './src/api'; import { ClientProvider } from './src/ClientContext'; +import { PreferencesProvider } from './src/PreferencesContext'; import { SceneActionsProvider } from './src/SceneActionsContext'; import { WhatsNewModal } from './src/components/WhatsNewModal'; import { ErrorBoundary } from './src/ErrorBoundary'; @@ -112,6 +113,9 @@ export default function App() { const [bootError, setBootError] = useState(null); const [locked, setLocked] = 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(null); useEffect(() => { @@ -160,10 +164,11 @@ export default function App() { }, [client]); // FLAG_SECURE — blocks screenshots and hides app preview from app switcher. - // TEMP 2026-06-02: wyłączone żeby umożliwić debug na emulatorze (screencap + - // playback verification — FLAG_SECURE czyni screenshoty czarnymi). Jan jest na - // razie jedynym userem. PRZYWRÓCIĆ `true` przed szerszą dystrybucją. - const SCREEN_CAPTURE_PROTECTION = false; + // WŁĄCZONE 2026-06-16: realni userzy (mobilism) — NSFW treść nie ma wyciekać do + // Recents/screenshotów (user feedback). Bundle OTA jest wspólny, więc to dotyczy + // też emulatora — na czas debugu (adb screencap) flipnij tymczasowo na false + // lokalnie i NIE publikuj tej zmiany. + const SCREEN_CAPTURE_PROTECTION = true; useEffect(() => { if (!SCREEN_CAPTURE_PROTECTION) return; ScreenCapture.preventScreenCaptureAsync('goon-applock').catch(() => {}); @@ -185,6 +190,9 @@ export default function App() { useEffect(() => { if (!client || updateChecked.current) return; 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 () => { // Etap B: silent JS update if (Updates.isEnabled) { @@ -193,6 +201,7 @@ export default function App() { if (upd.isAvailable) { await Updates.fetchUpdateAsync(); // 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(); return; // reloadAsync nie wraca } @@ -200,6 +209,9 @@ export default function App() { // 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) try { 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 ( + + + + + + + Checking for updates… + + + + + ); + } + // App lock takes precedence over everything (even login). Refresh settings every // unlock so a freshly-disabled lock doesn't bounce back next background. if (locked) { @@ -354,18 +393,20 @@ export default function App() { ) : null} {client ? ( - - { - await clearCredentials(); - setClient(null); - queryClient.clear(); - }} - /> - - + + + { + await clearCredentials(); + setClient(null); + queryClient.clear(); + }} + /> + + + ) : ( void; +} + +const Ctx = createContext(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 {children}; +} + +export function usePreferences(): Prefs { + const c = useContext(Ctx); + if (!c) throw new Error('usePreferences outside PreferencesProvider'); + return c; +} diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 20094d8..7ddb0ad 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -21,6 +21,7 @@ import type { ResolveAction, ResolveOut, ResolveResult, + SavedSearchOut, ScenesListParams, SceneListOut, SceneOut, @@ -187,6 +188,22 @@ export class GoonClient { } } + // Saved searches — zapisane słowa kluczowe (user-report mobilism). + async listSavedSearches(): Promise { + return this.request('/saved-searches'); + } + + async addSavedSearch(query: string): Promise { + return this.request('/saved-searches', { + method: 'POST', + body: JSON.stringify({ query }), + }); + } + + async removeSavedSearch(id: string): Promise { + await this.request(`/saved-searches/${id}`, { method: 'DELETE' }); + } + async listTags(params: { q?: string; order?: 'popular' | 'name'; diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 2ef1763..ab0b77c 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,17 @@ export type 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', date: 'June 2026', diff --git a/mobile/src/components/SceneTile.tsx b/mobile/src/components/SceneTile.tsx index a62cb4f..13b004a 100644 --- a/mobile/src/components/SceneTile.tsx +++ b/mobile/src/components/SceneTile.tsx @@ -156,7 +156,7 @@ export function SceneTile({ scene, secondLine = 'studio', seenSince, onLongPress } const styles = StyleSheet.create({ - tile: { flex: 1 }, + tile: { flex: 1, marginBottom: 14 }, thumbWrap: { width: '100%', aspectRatio: 16 / 9, @@ -244,7 +244,15 @@ const styles = StyleSheet.create({ }, }); -export const sceneTileGridProps = { - numColumns: 2 as const, - columnWrapperStyle: { gap: 10, marginBottom: 14 }, -}; +/** + * Props siatki scen dla danej liczby kolumn (1/2/3, z PreferencesContext). + * `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, + }; +} diff --git a/mobile/src/screens/AppLockSettingsScreen.tsx b/mobile/src/screens/AppLockSettingsScreen.tsx index 181c310..a06b7ad 100644 --- a/mobile/src/screens/AppLockSettingsScreen.tsx +++ b/mobile/src/screens/AppLockSettingsScreen.tsx @@ -23,6 +23,7 @@ import { verifyPin, } from '../lib/applock'; import { APP_VERSION } from '../lib/appVersion'; +import { usePreferences } from '../PreferencesContext'; import { theme } from '../theme'; import { PinEntry } from './PinEntry'; @@ -42,6 +43,7 @@ export function AppLockSettingsScreen() { const [stage, setStage] = useState('menu'); const [newPin, setNewPin] = useState(''); const [errorText, setErrorText] = useState(null); + const { gridColumns, setGridColumns } = usePreferences(); async function refresh() { const [s, bio] = await Promise.all([getSettings(), biometricAvailable()]); @@ -258,6 +260,27 @@ export function AppLockSettingsScreen() { + + Grid + Thumbnails per row in scene lists + + {[1, 2, 3].map((n) => { + const active = gridColumns === n; + return ( + setGridColumns(n)} + > + + {n === 1 ? '1 column' : `${n} columns`} + + + ); + })} + + + About diff --git a/mobile/src/screens/FavoritesScreen.tsx b/mobile/src/screens/FavoritesScreen.tsx index 8c5fd26..4cca111 100644 --- a/mobile/src/screens/FavoritesScreen.tsx +++ b/mobile/src/screens/FavoritesScreen.tsx @@ -18,7 +18,8 @@ import { } from 'react-native'; import { Image } from 'expo-image'; 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 { theme } from '../theme'; import type { FavoriteMovieOut, FavoriteOut, FavoriteStudioOut } from '../types'; @@ -27,6 +28,7 @@ type Tab = 'scenes' | 'performers' | 'studios' | 'movies'; export function FavoritesScreen() { const client = useClient(); + const { gridColumns } = usePreferences(); const queryClient = useQueryClient(); const nav = useNavigation>(); const [tab, setTab] = React.useState('scenes'); @@ -181,12 +183,12 @@ export function FavoritesScreen() { {tab === 'scenes' ? ( s.id} - numColumns={2} // patrz ScenesScreen: removeClippedSubviews blankuje miniaturki po scrollu. removeClippedSubviews={false} renderItem={({ item }) => ( @@ -209,7 +211,6 @@ export function FavoritesScreen() { } /> )} - columnWrapperStyle={styles.gridRow} refreshing={isRefetching} onRefresh={refetch} ListEmptyComponent={ diff --git a/mobile/src/screens/PerformerScenesScreen.tsx b/mobile/src/screens/PerformerScenesScreen.tsx index 032f0ef..2cf1373 100644 --- a/mobile/src/screens/PerformerScenesScreen.tsx +++ b/mobile/src/screens/PerformerScenesScreen.tsx @@ -21,7 +21,8 @@ import { import { useClient } from '../ClientContext'; import { FavoriteSceneRow } from '../components/FavoriteSceneRow'; 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 type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; @@ -33,6 +34,7 @@ const MOVIE_COLS = 2; export function PerformerScenesScreen() { const client = useClient(); + const { gridColumns } = usePreferences(); const queryClient = useQueryClient(); const navigation = useNavigation>(); @@ -232,10 +234,10 @@ export function PerformerScenesScreen() { {scenesQuery.error.message} )} s.id} - numColumns={2} // Android default removeClippedSubviews=true odpina miniaturki poza // viewportem i expo-image często nie re-renderuje ich po powrocie → // "miniaturki znikają przy scrollu" (bug-report f181d382 2026-06-07). @@ -243,7 +245,6 @@ export function PerformerScenesScreen() { renderItem={({ item }) => ( )} - columnWrapperStyle={styles.gridRow} refreshing={scenesQuery.isRefetching} onRefresh={scenesQuery.refetch} ListHeaderComponent={ diff --git a/mobile/src/screens/ScenesFilterModal.tsx b/mobile/src/screens/ScenesFilterModal.tsx index 0686dfe..f2e8cc6 100644 --- a/mobile/src/screens/ScenesFilterModal.tsx +++ b/mobile/src/screens/ScenesFilterModal.tsx @@ -25,6 +25,7 @@ export interface FilterState { hasPlayback: boolean; includeStubs: boolean; origin: string; + minDurationSec: number; // 0 = bazowy próg (60s); >0 podnosi minimum (filtr na klipy-reklamy) } 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ść) includeStubs: false, // default: ukryj 5-10min hqporner trailery bez release_date origin: '', // pusty = wszystkie źródła; substring match np. 'hqporner' + minDurationSec: 0, }; const SORT_OPTIONS: { value: ScenesSort; label: string }[] = [ @@ -44,6 +46,16 @@ const SORT_OPTIONS: { value: ScenesSort; label: string }[] = [ { 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({ visible, initial, @@ -106,7 +118,8 @@ export function ScenesFilterModal({ draft.tagSlugs.length + draft.studioSlugs.length + draft.performerIds.length + - (draft.hasPlayback ? 1 : 0); + (draft.hasPlayback ? 1 : 0) + + (draft.minDurationSec > 0 ? 1 : 0); return ( @@ -163,6 +176,30 @@ export function ScenesFilterModal({ +
+ + {DURATION_OPTIONS.map((opt) => ( + setDraft({ ...draft, minDurationSec: opt.value })} + > + + {opt.label} + + + ))} + +
+
>(); const [q, setQ] = useState(''); const [debouncedQ, setDebouncedQ] = useState(''); @@ -58,6 +61,8 @@ export function ScenesScreen() { sort: filter.sort, include_stubs: filter.includeStubs || 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, per_page: PER_PAGE, }), @@ -83,6 +88,27 @@ export function ScenesScreen() { (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 ( @@ -94,6 +120,22 @@ export function ScenesScreen() { placeholderTextColor={theme.muted} autoCapitalize="none" /> + {q.trim().length > 0 ? ( + { + const v = q.trim(); + if (v && !alreadySaved) addSaved.mutate(v); + }} + disabled={alreadySaved} + style={[styles.toolbarButton, alreadySaved && styles.toolbarButtonActive]} + > + + {alreadySaved ? '★ Saved' : '☆ Save'} + + + ) : null} setFilterOpen(true)} style={[styles.toolbarButton, activeCount > 0 && styles.toolbarButtonActive]} @@ -104,6 +146,43 @@ export function ScenesScreen() { + {saved.length > 0 ? ( + + {saved.map((s) => { + const active = debouncedQ === s.query; + return ( + 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), + }, + ]) + } + > + + {s.query} + + + ); + })} + + ) : null} + ) : ( s.id} - numColumns={2} // Android removeClippedSubviews=true (default) blankuje miniaturki po scrollu — // expo-image nie re-renderuje odpiętych subview. Bug-report "znikają miniaturki". removeClippedSubviews={false} renderItem={({ item }) => } - columnWrapperStyle={styles.gridRow} ListHeaderComponent={!debouncedQ && activeCount === 0 ? : null} refreshing={isRefetching} onRefresh={refetch} @@ -325,6 +403,20 @@ const styles = StyleSheet.create({ }, toolbarButtonActive: { borderColor: theme.accent, backgroundColor: `${theme.accent}1A` }, 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 }, subButton: { backgroundColor: theme.card, diff --git a/mobile/src/screens/SiteScenesScreen.tsx b/mobile/src/screens/SiteScenesScreen.tsx index 4b230d3..963c02a 100644 --- a/mobile/src/screens/SiteScenesScreen.tsx +++ b/mobile/src/screens/SiteScenesScreen.tsx @@ -22,15 +22,17 @@ import { TextInput, View, } from 'react-native'; -import { SceneTile } from '../components/SceneTile'; +import { SceneTile, sceneGridProps } from '../components/SceneTile'; import { Thumb } from '../components/Thumb'; import { useClient } from '../ClientContext'; +import { usePreferences } from '../PreferencesContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; import type { SceneOut, TagOut } from '../types'; export function SiteScenesScreen() { const client = useClient(); + const { gridColumns } = usePreferences(); const navigation = useNavigation>(); const route = useRoute>(); @@ -110,12 +112,11 @@ export function SiteScenesScreen() { {error instanceof Error && {error.message}} s.id} - numColumns={2} removeClippedSubviews={false} renderItem={({ item }) => } - columnWrapperStyle={styles.gridRow} refreshing={isRefetching} onRefresh={refetch} onEndReached={() => { diff --git a/mobile/src/screens/StudioScenesScreen.tsx b/mobile/src/screens/StudioScenesScreen.tsx index 8f5b8ba..8894d97 100644 --- a/mobile/src/screens/StudioScenesScreen.tsx +++ b/mobile/src/screens/StudioScenesScreen.tsx @@ -16,14 +16,16 @@ import { View, } from 'react-native'; import { FavoriteSceneRow } from '../components/FavoriteSceneRow'; -import { SceneTile } from '../components/SceneTile'; +import { SceneTile, sceneGridProps } from '../components/SceneTile'; import { useClient } from '../ClientContext'; +import { usePreferences } from '../PreferencesContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; import type { SceneOut } from '../types'; export function StudioScenesScreen() { const client = useClient(); + const { gridColumns } = usePreferences(); const queryClient = useQueryClient(); const navigation = useNavigation>(); @@ -135,14 +137,13 @@ export function StudioScenesScreen() { {error instanceof Error && {error.message}} s.id} - numColumns={2} removeClippedSubviews={false} renderItem={({ item }) => ( )} - columnWrapperStyle={styles.gridRow} refreshing={isRefetching} onRefresh={refetch} ListHeaderComponent={ diff --git a/mobile/src/screens/TagScenesScreen.tsx b/mobile/src/screens/TagScenesScreen.tsx index 61a80f0..408771b 100644 --- a/mobile/src/screens/TagScenesScreen.tsx +++ b/mobile/src/screens/TagScenesScreen.tsx @@ -10,15 +10,17 @@ import { Text, View, } from 'react-native'; -import { SceneTile } from '../components/SceneTile'; +import { SceneTile, sceneGridProps } from '../components/SceneTile'; import { SceneGridSkeleton } from '../components/SceneGridSkeleton'; import { useClient } from '../ClientContext'; +import { usePreferences } from '../PreferencesContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; import type { SceneOut } from '../types'; export function TagScenesScreen() { const client = useClient(); + const { gridColumns } = usePreferences(); const navigation = useNavigation>(); const route = useRoute>(); @@ -47,12 +49,11 @@ export function TagScenesScreen() { ) : ( s.id} - numColumns={2} removeClippedSubviews={false} renderItem={({ item }) => } - columnWrapperStyle={styles.gridRow} refreshing={isRefetching} onRefresh={refetch} ListHeaderComponent={ diff --git a/mobile/src/storage.ts b/mobile/src/storage.ts index 0577757..7d0461d 100644 --- a/mobile/src/storage.ts +++ b/mobile/src/storage.ts @@ -50,6 +50,20 @@ export async function setLastSeenChangelog(id: string): Promise { 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 { + 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 { + await SecureStore.setItemAsync(GRID_COLS_KEY, String(n)); +} + export interface Credentials { baseUrl: string; apiKey: string; diff --git a/mobile/src/types.ts b/mobile/src/types.ts index d959814..9e6e49c 100644 --- a/mobile/src/types.ts +++ b/mobile/src/types.ts @@ -354,6 +354,12 @@ export interface BlacklistOut { export type BlacklistKind = 'performer' | 'studio' | 'tag'; +export interface SavedSearchOut { + id: string; + query: string; + created_at: string; +} + export interface ProgressOut { scene_id: string; position_sec: number;