feat(mobile): tile long-press actions (hide / mark-duplicate), drop dead preview

bug-report 5a6844db: the hold-to-preview animated gesture did nothing useful.
Replace it with a long-press action menu on scene tiles:
  - Ukryj scenę → POST /scenes/{id}/hide
  - Oznacz jako duplikat → enter selection mode; tapping another tile merges the
    long-pressed scene INTO the tapped one (POST /scenes/{keep}/merge/{drop}).
SceneActionsProvider holds the selection state + a bottom banner, so it works across
all 5 scene-list screens via the shared SceneTile (no per-screen wiring). Selecting
mode highlights tappable tiles and badges the pending duplicate. Animated thumbnails
kept only as a still-fallback image; has_animated_thumbnail filter removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-09 09:52:15 +02:00
parent e1c7efb947
commit 904f8984c8
5 changed files with 242 additions and 28 deletions

View file

@ -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,6 +336,7 @@ export default function App() {
) : null}
{client ? (
<ClientProvider client={client}>
<SceneActionsProvider>
<AppNavigator
client={client}
appVersion={APP_VERSION}
@ -344,6 +346,7 @@ export default function App() {
queryClient.clear();
}}
/>
</SceneActionsProvider>
</ClientProvider>
) : (
<LoginScreen

View file

@ -0,0 +1,171 @@
/**
* SceneActions long-press akcje na kafelkach scen (bug-report 5a6844db).
* Zastępuje martwy animated-preview gesture. Long-press menu:
* - Ukryj scenę POST /scenes/{id}/hide (playback dead, znika z list)
* - Oznacz duplikat wejście w tryb wyboru; następny tap na inną scenę scala
* (merge długo-naciśniętej W wybraną). Banner na dole prowadzi przez wybór.
*
* Context bo SceneTile jest współdzielony przez 5 ekranów jeden provider daje
* tryb selekcji działający wszędzie bez per-screen wiringu.
*/
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useClient } from './ClientContext';
import { theme } from './theme';
import type { SceneOut } from './types';
interface SceneActionsCtx {
pendingDuplicate: SceneOut | null;
isSelecting: boolean;
/** Otwórz menu akcji dla sceny (long-press). */
openActions: (scene: SceneOut) => void;
/** W trybie selekcji: wybierz tę scenę jako oryginał i scal w nią duplikat. */
pickDuplicateTarget: (target: SceneOut) => void;
cancelDuplicate: () => void;
}
const Ctx = React.createContext<SceneActionsCtx | null>(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<SceneOut | null>(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 (
<Ctx.Provider value={value}>
{children}
{pendingDuplicate ? (
<View style={styles.banner} pointerEvents="box-none">
<View style={styles.bannerInner}>
<Text style={styles.bannerText} numberOfLines={2}>
Wybierz oryginał dotknij scenę, w którą scalić {pendingDuplicate.title}"
</Text>
<Pressable onPress={cancelDuplicate} style={styles.bannerCancel} hitSlop={8}>
<Text style={styles.bannerCancelText}>Anuluj</Text>
</Pressable>
</View>
</View>
) : null}
</Ctx.Provider>
);
}
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 },
});

View file

@ -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<void> {
await this.request(`/movies/${movieId}/playback/${playbackId}/mark-dead`, {
method: 'POST',

View file

@ -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<NativeStackNavigationProp<RootStackParamList>>();
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 (
<Pressable
style={styles.tile}
onPress={() => navigation.navigate('SceneDetail', { id: scene.id })}
onLongPress={startPreview}
onPressOut={() => setIsPreviewing(false)}
delayLongPress={180}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
>
<View
style={[
styles.thumbWrap,
dim && styles.thumbDim,
isSelecting && !isPending && styles.thumbSelectable,
isPending && styles.thumbPending,
]}
>
<View style={[styles.thumbWrap, dim && styles.thumbDim]}>
<Thumb url={displayUrl} style={styles.thumb} />
{isPending ? (
<View style={styles.dupBadge}>
<Text style={styles.dupBadgeText}>DUPLIKAT</Text>
</View>
) : null}
{scene.is_favorite ? (
<View style={styles.favBadge}>
<Text style={styles.favBadgeText}></Text>
@ -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,

View file

@ -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;