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:
parent
bcee5851e9
commit
00f4779abe
15 changed files with 337 additions and 45 deletions
|
|
@ -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<string | null>(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<number | null>(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 (
|
||||
<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
|
||||
// unlock so a freshly-disabled lock doesn't bounce back next background.
|
||||
if (locked) {
|
||||
|
|
@ -354,6 +393,7 @@ export default function App() {
|
|||
) : null}
|
||||
{client ? (
|
||||
<ClientProvider client={client}>
|
||||
<PreferencesProvider>
|
||||
<SceneActionsProvider>
|
||||
<AppNavigator
|
||||
client={client}
|
||||
|
|
@ -366,6 +406,7 @@ export default function App() {
|
|||
/>
|
||||
<WhatsNewModal />
|
||||
</SceneActionsProvider>
|
||||
</PreferencesProvider>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoginScreen
|
||||
|
|
|
|||
38
mobile/src/PreferencesContext.tsx
Normal file
38
mobile/src/PreferencesContext.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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<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: {
|
||||
q?: string;
|
||||
order?: 'popular' | 'name';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Stage>('menu');
|
||||
const [newPin, setNewPin] = useState('');
|
||||
const [errorText, setErrorText] = useState<string | null>(null);
|
||||
const { gridColumns, setGridColumns } = usePreferences();
|
||||
|
||||
async function refresh() {
|
||||
const [s, bio] = await Promise.all([getSettings(), biometricAvailable()]);
|
||||
|
|
@ -258,6 +260,27 @@ export function AppLockSettingsScreen() {
|
|||
</Text>
|
||||
</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}>
|
||||
<Text style={styles.sectionTitle}>About</Text>
|
||||
<View style={styles.row}>
|
||||
|
|
|
|||
|
|
@ -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<NativeStackNavigationProp<RootStackParamList, 'Favorites'>>();
|
||||
const [tab, setTab] = React.useState<Tab>('scenes');
|
||||
|
|
@ -181,12 +183,12 @@ export function FavoritesScreen() {
|
|||
|
||||
{tab === 'scenes' ? (
|
||||
<FlatList
|
||||
// key per tab: scenes używa numColumns=2, reszta 1 — bez remountu RN rzuca
|
||||
// "Changing numColumns on the fly is not supported" (GOON-11).
|
||||
key="fav-scenes"
|
||||
{...sceneGridProps(gridColumns)}
|
||||
// key per tab + per liczba kolumn: bez remountu RN rzuca "Changing numColumns
|
||||
// on the fly is not supported" (GOON-11). Zmiana kolumn też wymaga remountu.
|
||||
key={`fav-scenes-${gridColumns}`}
|
||||
data={scenesQuery.data?.items ?? []}
|
||||
keyExtractor={(s) => 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={
|
||||
|
|
|
|||
|
|
@ -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<NativeStackNavigationProp<RootStackParamList, 'PerformerScenes'>>();
|
||||
|
|
@ -232,10 +234,10 @@ export function PerformerScenesScreen() {
|
|||
<Text style={styles.error}>{scenesQuery.error.message}</Text>
|
||||
)}
|
||||
<FlatList
|
||||
key="scenes-list"
|
||||
{...sceneGridProps(gridColumns)}
|
||||
key={`scenes-list-${gridColumns}`}
|
||||
data={sortedScenes}
|
||||
keyExtractor={(s) => 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 }) => (
|
||||
<SceneTile scene={item} seenSince={seenSince} secondLine="studio" />
|
||||
)}
|
||||
columnWrapperStyle={styles.gridRow}
|
||||
refreshing={scenesQuery.isRefetching}
|
||||
onRefresh={scenesQuery.refetch}
|
||||
ListHeaderComponent={
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
|
|
@ -163,6 +176,30 @@ export function ScenesFilterModal({
|
|||
</View>
|
||||
</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">
|
||||
<TextInput
|
||||
style={styles.search}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useNavigation } from '@react-navigation/native';
|
||||
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 {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
|
|
@ -14,10 +15,11 @@ import {
|
|||
} from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { SceneTile } from '../components/SceneTile';
|
||||
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';
|
||||
|
|
@ -25,6 +27,7 @@ import { DEFAULT_FILTER, FilterState, ScenesFilterModal } from './ScenesFilterMo
|
|||
|
||||
export function ScenesScreen() {
|
||||
const client = useClient();
|
||||
const { gridColumns } = usePreferences();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
|
||||
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 (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.toolbar}>
|
||||
|
|
@ -94,6 +120,22 @@ export function ScenesScreen() {
|
|||
placeholderTextColor={theme.muted}
|
||||
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
|
||||
onPress={() => setFilterOpen(true)}
|
||||
style={[styles.toolbarButton, activeCount > 0 && styles.toolbarButtonActive]}
|
||||
|
|
@ -104,6 +146,43 @@ export function ScenesScreen() {
|
|||
</Pressable>
|
||||
</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}>
|
||||
<Pressable
|
||||
style={styles.subButton}
|
||||
|
|
@ -126,14 +205,13 @@ export function ScenesScreen() {
|
|||
<SceneGridSkeleton count={8} />
|
||||
) : (
|
||||
<FlatList
|
||||
{...sceneGridProps(gridColumns)}
|
||||
data={items}
|
||||
keyExtractor={(s) => 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 }) => <SceneTile scene={item} />}
|
||||
columnWrapperStyle={styles.gridRow}
|
||||
ListHeaderComponent={!debouncedQ && activeCount === 0 ? <ContinueWatchingRail /> : 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,
|
||||
|
|
|
|||
|
|
@ -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<NativeStackNavigationProp<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>}
|
||||
|
||||
<FlatList
|
||||
{...sceneGridProps(gridColumns)}
|
||||
data={items}
|
||||
keyExtractor={(s) => s.id}
|
||||
numColumns={2}
|
||||
removeClippedSubviews={false}
|
||||
renderItem={({ item }) => <SceneTile scene={item} secondLine="studio" />}
|
||||
columnWrapperStyle={styles.gridRow}
|
||||
refreshing={isRefetching}
|
||||
onRefresh={refetch}
|
||||
onEndReached={() => {
|
||||
|
|
|
|||
|
|
@ -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<NativeStackNavigationProp<RootStackParamList, 'StudioScenes'>>();
|
||||
|
|
@ -135,14 +137,13 @@ export function StudioScenesScreen() {
|
|||
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
||||
|
||||
<FlatList
|
||||
{...sceneGridProps(gridColumns)}
|
||||
data={sortedItems}
|
||||
keyExtractor={(s) => s.id}
|
||||
numColumns={2}
|
||||
removeClippedSubviews={false}
|
||||
renderItem={({ item }) => (
|
||||
<SceneTile scene={item} seenSince={seenSince} secondLine="performers" />
|
||||
)}
|
||||
columnWrapperStyle={styles.gridRow}
|
||||
refreshing={isRefetching}
|
||||
onRefresh={refetch}
|
||||
ListHeaderComponent={
|
||||
|
|
|
|||
|
|
@ -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<NativeStackNavigationProp<RootStackParamList, 'TagScenes'>>();
|
||||
const route = useRoute<RouteProp<RootStackParamList, 'TagScenes'>>();
|
||||
|
|
@ -47,12 +49,11 @@ export function TagScenesScreen() {
|
|||
<SceneGridSkeleton count={10} />
|
||||
) : (
|
||||
<FlatList
|
||||
{...sceneGridProps(gridColumns)}
|
||||
data={data?.items ?? []}
|
||||
keyExtractor={(s) => s.id}
|
||||
numColumns={2}
|
||||
removeClippedSubviews={false}
|
||||
renderItem={({ item }) => <SceneTile scene={item} secondLine="studio" />}
|
||||
columnWrapperStyle={styles.gridRow}
|
||||
refreshing={isRefetching}
|
||||
onRefresh={refetch}
|
||||
ListHeaderComponent={
|
||||
|
|
|
|||
|
|
@ -50,6 +50,20 @@ export async function setLastSeenChangelog(id: string): Promise<void> {
|
|||
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 {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue