diff --git a/mobile/App.tsx b/mobile/App.tsx
index 93ee726..41a5c4e 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 { SceneActionsProvider } from './src/SceneActionsContext';
import { ErrorBoundary } from './src/ErrorBoundary';
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
import { APP_VERSION } from './src/lib/appVersion';
@@ -335,15 +336,17 @@ export default function App() {
) : null}
{client ? (
- {
- await clearCredentials();
- setClient(null);
- queryClient.clear();
- }}
- />
+
+ {
+ await clearCredentials();
+ setClient(null);
+ queryClient.clear();
+ }}
+ />
+
) : (
void;
+ /** W trybie selekcji: wybierz tę scenę jako oryginał i scal w nią duplikat. */
+ pickDuplicateTarget: (target: SceneOut) => void;
+ cancelDuplicate: () => void;
+}
+
+const Ctx = React.createContext(null);
+
+export function useSceneActions(): SceneActionsCtx {
+ const c = React.useContext(Ctx);
+ if (!c) throw new Error('useSceneActions used outside SceneActionsProvider');
+ return c;
+}
+
+const SCENE_LIST_KEYS = ['scenes', 'performer-scenes', 'studio-scenes', 'tag-scenes', 'site-scenes'];
+
+export function SceneActionsProvider({ children }: { children: React.ReactNode }) {
+ const client = useClient();
+ const queryClient = useQueryClient();
+ const [pendingDuplicate, setPending] = React.useState(null);
+
+ const invalidate = React.useCallback(() => {
+ for (const k of SCENE_LIST_KEYS) {
+ queryClient.invalidateQueries({ queryKey: [k] });
+ }
+ }, [queryClient]);
+
+ const hide = React.useCallback(
+ (scene: SceneOut) => {
+ Alert.alert('Ukryć scenę?', scene.title, [
+ { text: 'Anuluj', style: 'cancel' },
+ {
+ text: 'Ukryj',
+ style: 'destructive',
+ onPress: async () => {
+ try {
+ await client.hideScene(scene.id);
+ invalidate();
+ } catch (e: any) {
+ Alert.alert('Nie udało się ukryć', e?.message || 'unknown error');
+ }
+ },
+ },
+ ]);
+ },
+ [client, invalidate],
+ );
+
+ const openActions = React.useCallback(
+ (scene: SceneOut) => {
+ if (pendingDuplicate) return; // w trakcie wyboru duplikatu — ignoruj
+ Alert.alert(scene.title, 'Akcje sceny', [
+ { text: 'Ukryj scenę', style: 'destructive', onPress: () => hide(scene) },
+ { text: 'Oznacz jako duplikat', onPress: () => setPending(scene) },
+ { text: 'Anuluj', style: 'cancel' },
+ ]);
+ },
+ [pendingDuplicate, hide],
+ );
+
+ const pickDuplicateTarget = React.useCallback(
+ (target: SceneOut) => {
+ const dup = pendingDuplicate;
+ if (!dup || target.id === dup.id) {
+ setPending(null);
+ return;
+ }
+ Alert.alert(
+ 'Scalić duplikat?',
+ `„${dup.title}"\n\nscalić w\n\n„${target.title}"`,
+ [
+ { text: 'Anuluj', style: 'cancel', onPress: () => setPending(null) },
+ {
+ text: 'Scal',
+ onPress: async () => {
+ try {
+ // keep = wybrany oryginał (target), drop = długo-naciśnięty duplikat (dup)
+ await client.mergeDuplicateScene(target.id, dup.id);
+ invalidate();
+ } catch (e: any) {
+ Alert.alert('Scalanie nie powiodło się', e?.message || 'unknown error');
+ } finally {
+ setPending(null);
+ }
+ },
+ },
+ ],
+ );
+ },
+ [pendingDuplicate, client, invalidate],
+ );
+
+ const cancelDuplicate = React.useCallback(() => setPending(null), []);
+
+ const value = React.useMemo(
+ () => ({
+ pendingDuplicate,
+ isSelecting: !!pendingDuplicate,
+ openActions,
+ pickDuplicateTarget,
+ cancelDuplicate,
+ }),
+ [pendingDuplicate, openActions, pickDuplicateTarget, cancelDuplicate],
+ );
+
+ return (
+
+ {children}
+ {pendingDuplicate ? (
+
+
+
+ Wybierz oryginał — dotknij scenę, w którą scalić „{pendingDuplicate.title}"
+
+
+ Anuluj
+
+
+
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ banner: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ bottom: 0,
+ padding: 10,
+ },
+ bannerInner: {
+ backgroundColor: theme.accent,
+ borderRadius: 12,
+ padding: 12,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ },
+ bannerText: { color: theme.fg, fontSize: 13, fontWeight: '600', flex: 1 },
+ bannerCancel: {
+ backgroundColor: 'rgba(0,0,0,0.3)',
+ borderRadius: 8,
+ paddingVertical: 6,
+ paddingHorizontal: 12,
+ },
+ bannerCancelText: { color: theme.fg, fontWeight: '700', fontSize: 13 },
+});
diff --git a/mobile/src/api.ts b/mobile/src/api.ts
index a3740d9..0b41e14 100644
--- a/mobile/src/api.ts
+++ b/mobile/src/api.ts
@@ -113,8 +113,6 @@ export class GoonClient {
qs.set('performer_ids', params.performer_ids.join(','));
}
if (params.has_playback !== undefined) qs.set('has_playback', String(params.has_playback));
- if (params.has_animated_thumbnail !== undefined)
- qs.set('has_animated_thumbnail', String(params.has_animated_thumbnail));
// Default: filtrujemy sceny <60s — bug-report 2026-05-23 (40cd28aa):
// "Takie sceny po 1 min to można wywalić". Pornapp/freshporno czasem
// zassuje teasery/trailery 30-50s, które są bezużyteczne na listach.
@@ -241,6 +239,16 @@ export class GoonClient {
});
}
+ // Long-press tile akcje. hideScene → wszystkie playback dead (scena znika z list).
+ async hideScene(sceneId: string): Promise<{ scene_id: string; playback_marked_dead: number }> {
+ return this.request(`/scenes/${sceneId}/hide`, { method: 'POST' });
+ }
+
+ // Scal `dropId` w `keepId` (mark-duplicate flow: long-press dup → wybór oryginału).
+ async mergeDuplicateScene(keepId: string, dropId: string): Promise<{ keep_id: string; dropped_id: string }> {
+ return this.request(`/scenes/${keepId}/merge/${dropId}`, { method: 'POST' });
+ }
+
async markMoviePlaybackDead(movieId: string, playbackId: string): Promise {
await this.request(`/movies/${movieId}/playback/${playbackId}/mark-dead`, {
method: 'POST',
diff --git a/mobile/src/components/SceneTile.tsx b/mobile/src/components/SceneTile.tsx
index 94749cc..a62cb4f 100644
--- a/mobile/src/components/SceneTile.tsx
+++ b/mobile/src/components/SceneTile.tsx
@@ -22,10 +22,10 @@
*/
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
-import * as Haptics from 'expo-haptics';
-import React, { useState } from 'react';
+import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
+import { useSceneActions } from '../SceneActionsContext';
import type { RootStackParamList } from '../navigation';
import { fonts, theme } from '../theme';
import type { SceneOut } from '../types';
@@ -52,21 +52,31 @@ interface Props {
export function SceneTile({ scene, secondLine = 'studio', seenSince, onLongPress }: Props) {
const navigation =
useNavigation>();
- const [isPreviewing, setIsPreviewing] = useState(false);
+ const { isSelecting, pendingDuplicate, openActions, pickDuplicateTarget } = useSceneActions();
- const animatedUrl = scene.playback_sources.find((s) => s.animated_thumbnail_url)
- ?.animated_thumbnail_url;
- const staticUrl = scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url;
- const displayUrl = isPreviewing && animatedUrl ? animatedUrl : staticUrl ?? animatedUrl;
+ // animated_thumbnail_url używamy tylko jako still-fallback gdy brak statycznej
+ // (preview-on-hold usunięty — bug-report 5a6844db, gest nic nie robił).
+ const displayUrl =
+ scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url ??
+ scene.playback_sources.find((s) => s.animated_thumbnail_url)?.animated_thumbnail_url;
- const startPreview = () => {
+ const isPending = pendingDuplicate?.id === scene.id;
+
+ const handlePress = () => {
+ // Tryb wyboru duplikatu: tap = wybierz oryginał (chyba że to ta sama kafelka).
+ if (isSelecting && !isPending) {
+ pickDuplicateTarget(scene);
+ return;
+ }
+ navigation.navigate('SceneDetail', { id: scene.id });
+ };
+
+ const handleLongPress = () => {
if (onLongPress) {
onLongPress();
return;
}
- if (!animatedUrl) return;
- setIsPreviewing(true);
- Haptics.selectionAsync().catch(() => {});
+ openActions(scene);
};
const dim = scene.finished === true;
@@ -94,13 +104,24 @@ export function SceneTile({ scene, secondLine = 'studio', seenSince, onLongPress
return (
navigation.navigate('SceneDetail', { id: scene.id })}
- onLongPress={startPreview}
- onPressOut={() => setIsPreviewing(false)}
- delayLongPress={180}
+ onPress={handlePress}
+ onLongPress={handleLongPress}
+ delayLongPress={300}
>
-
+
+ {isPending ? (
+
+ DUPLIKAT
+
+ ) : null}
{scene.is_favorite ? (
★
@@ -146,6 +167,18 @@ const styles = StyleSheet.create({
},
thumb: { width: '100%', height: '100%' },
thumbDim: { opacity: 0.45 },
+ thumbSelectable: { borderWidth: 2, borderColor: theme.accent, opacity: 0.85 },
+ thumbPending: { borderWidth: 2, borderColor: theme.fg, opacity: 0.55 },
+ dupBadge: {
+ position: 'absolute',
+ top: 6,
+ left: 6,
+ backgroundColor: theme.accent,
+ paddingHorizontal: 6,
+ paddingVertical: 2,
+ borderRadius: 4,
+ },
+ dupBadgeText: { color: theme.fg, fontSize: 9, fontWeight: '800', letterSpacing: 0.6 },
favBadge: {
position: 'absolute',
top: 6,
diff --git a/mobile/src/types.ts b/mobile/src/types.ts
index d1581d7..d959814 100644
--- a/mobile/src/types.ts
+++ b/mobile/src/types.ts
@@ -128,7 +128,6 @@ export interface ScenesListParams {
tags?: string[];
performer_ids?: string[];
has_playback?: boolean;
- has_animated_thumbnail?: boolean;
min_duration_sec?: number;
max_duration_sec?: number;
released_within_days?: number;