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;