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 { 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
|
||||||
|
|
|
||||||
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,
|
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';
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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={() => {
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue