feat(mobile): scalaj wiele duplikatow naraz zamiast parami
Zgloszenie c7f5e59e: "przydalaby sie mozliwosc zaznaczania wiecej niz 2 video przy duplikatach". Przy pieciu kopiach tej samej sceny stary uklad wymagal czterech przebiegow, kazdy z osobnym dialogiem, i za kazdym razem trzeba bylo na nowo wskazac oryginal. Model jest teraz odwrocony: dlugie przytrzymanie wybiera scene, ktora ZOSTAJE (plakietka KEEP), a kolejne tapniecia zaznaczaja i odznaczaja jej duplikaty (plakietka DUPLICATE, kafelek gasnie). Banner liczy zaznaczone i scala wszystkie jednym przyciskiem. Dzieki temu uzytkownik swiadomie wybiera te kopie, ktorej tytul i miniaturka przetrwaja, zamiast trafiac w nia przypadkiem. Backend bez zmian, endpoint scala parami, wiec lecimy petla. Kazde scalenie to osobna transakcja, wiec czesciowa porazka nie cofa udanych i raportujemy ja wprost zamiast udawac sukces. Sprawdzone na emulatorze na opublikowanej paczce: pozycja w menu, plakietki KEEP i DUPLICATE, licznik 2 -> 1 -> 2 przy odznaczaniu, tresc potwierdzenia z lista scen. Samego scalania nie wykonalem, to sa prawdziwe sceny z produkcji.
This commit is contained in:
parent
646f03d7be
commit
c9fa6edc6d
3 changed files with 143 additions and 59 deletions
|
|
@ -1,28 +1,42 @@
|
||||||
/**
|
/**
|
||||||
* SceneActions, long-press akcje na kafelkach scen (bug-report 5a6844db).
|
* SceneActions, long-press akcje na kafelkach scen (bug-report 5a6844db).
|
||||||
* Zastępuje martwy animated-preview gesture. Long-press → menu:
|
* Zastępuje martwy animated-preview gesture. Long-press → menu:
|
||||||
* - Ukryj scenę → POST /scenes/{id}/hide (playback dead, znika z list)
|
* - Hide scene → POST /scenes/{id}/hide (playback dead, znika z list)
|
||||||
* - Oznacz duplikat → wejście w tryb wyboru; następny tap na inną scenę scala
|
* - Merge duplicates → tryb wyboru: długo-naciśnięta scena ZOSTAJE, kolejne
|
||||||
* (merge długo-naciśniętej W wybraną). Banner na dole prowadzi przez wybór.
|
* tapnięcia zaznaczają jej duplikaty, przycisk w bannerze scala wszystkie naraz.
|
||||||
|
*
|
||||||
|
* Dlaczego długo-naciśnięta scena jest tą, która zostaje (do 2026-08-23 było
|
||||||
|
* odwrotnie): zgłoszenie c7f5e59e prosiło o zaznaczanie więcej niż dwóch scen.
|
||||||
|
* Przy pięciu duplikatach na ekranie performera stary układ wymagał czterech
|
||||||
|
* przebiegów, każdy z osobnym dialogiem, i za każdym razem trzeba było na nowo
|
||||||
|
* wskazać oryginał. Teraz trzymasz tę scenę, którą chcesz zachować, tapiesz
|
||||||
|
* resztę i scalasz raz.
|
||||||
|
*
|
||||||
|
* Backend scala parami (`POST /scenes/{keep}/merge/{drop}`), więc lecimy pętlą.
|
||||||
|
* Każde scalenie jest osobną transakcją, dlatego częściowa porażka nie cofa tych,
|
||||||
|
* które przeszły, i raportujemy ją wprost zamiast udawać sukces.
|
||||||
*
|
*
|
||||||
* Context bo SceneTile jest współdzielony przez 5 ekranów, jeden provider daje
|
* Context bo SceneTile jest współdzielony przez 5 ekranów, jeden provider daje
|
||||||
* tryb selekcji działający wszędzie bez per-screen wiringu.
|
* tryb selekcji działający wszędzie bez per-screen wiringu.
|
||||||
*/
|
*/
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { ActivityIndicator, Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
import { useClient } from './ClientContext';
|
import { useClient } from './ClientContext';
|
||||||
import { theme } from './theme';
|
import { theme } from './theme';
|
||||||
import type { SceneOut } from './types';
|
import type { SceneOut } from './types';
|
||||||
|
|
||||||
interface SceneActionsCtx {
|
interface SceneActionsCtx {
|
||||||
pendingDuplicate: SceneOut | null;
|
/** Scena, która przetrwa scalanie (ta długo-naciśnięta). Null = brak trybu wyboru. */
|
||||||
|
keeper: SceneOut | null;
|
||||||
isSelecting: boolean;
|
isSelecting: boolean;
|
||||||
|
/** Czy scena jest zaznaczona jako duplikat keepera. */
|
||||||
|
isMarked: (sceneId: string) => boolean;
|
||||||
/** Otwórz menu akcji dla sceny (long-press). */
|
/** Otwórz menu akcji dla sceny (long-press). */
|
||||||
openActions: (scene: SceneOut) => void;
|
openActions: (scene: SceneOut) => void;
|
||||||
/** W trybie selekcji: wybierz tę scenę jako oryginał i scal w nią duplikat. */
|
/** W trybie wyboru: dorzuć scenę do duplikatów albo ją odznacz. */
|
||||||
pickDuplicateTarget: (target: SceneOut) => void;
|
toggleDuplicate: (scene: SceneOut) => void;
|
||||||
cancelDuplicate: () => void;
|
cancelDuplicate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +53,9 @@ const SCENE_LIST_KEYS = ['scenes', 'performer-scenes', 'studio-scenes', 'tag-sce
|
||||||
export function SceneActionsProvider({ children }: { children: React.ReactNode }) {
|
export function SceneActionsProvider({ children }: { children: React.ReactNode }) {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [pendingDuplicate, setPending] = React.useState<SceneOut | null>(null);
|
const [keeper, setKeeper] = React.useState<SceneOut | null>(null);
|
||||||
|
const [marked, setMarked] = React.useState<SceneOut[]>([]);
|
||||||
|
const [merging, setMerging] = React.useState(false);
|
||||||
|
|
||||||
const invalidate = React.useCallback(() => {
|
const invalidate = React.useCallback(() => {
|
||||||
for (const k of SCENE_LIST_KEYS) {
|
for (const k of SCENE_LIST_KEYS) {
|
||||||
|
|
@ -47,6 +63,11 @@ export function SceneActionsProvider({ children }: { children: React.ReactNode }
|
||||||
}
|
}
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const reset = React.useCallback(() => {
|
||||||
|
setKeeper(null);
|
||||||
|
setMarked([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const hide = React.useCallback(
|
const hide = React.useCallback(
|
||||||
(scene: SceneOut) => {
|
(scene: SceneOut) => {
|
||||||
Alert.alert('Hide scene?', scene.title, [
|
Alert.alert('Hide scene?', scene.title, [
|
||||||
|
|
@ -70,71 +91,109 @@ export function SceneActionsProvider({ children }: { children: React.ReactNode }
|
||||||
|
|
||||||
const openActions = React.useCallback(
|
const openActions = React.useCallback(
|
||||||
(scene: SceneOut) => {
|
(scene: SceneOut) => {
|
||||||
if (pendingDuplicate) return; // w trakcie wyboru duplikatu, ignoruj
|
if (keeper) return; // w trakcie wyboru duplikatów, ignoruj
|
||||||
Alert.alert(scene.title, 'Scene actions', [
|
Alert.alert(scene.title, 'Scene actions', [
|
||||||
{ text: 'Hide scene', style: 'destructive', onPress: () => hide(scene) },
|
{ text: 'Hide scene', style: 'destructive', onPress: () => hide(scene) },
|
||||||
{ text: 'Mark as duplicate', onPress: () => setPending(scene) },
|
{ text: 'Merge duplicates into this', onPress: () => setKeeper(scene) },
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
[pendingDuplicate, hide],
|
[keeper, hide],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pickDuplicateTarget = React.useCallback(
|
const toggleDuplicate = React.useCallback(
|
||||||
(target: SceneOut) => {
|
(scene: SceneOut) => {
|
||||||
const dup = pendingDuplicate;
|
if (!keeper || merging || scene.id === keeper.id) return;
|
||||||
if (!dup || target.id === dup.id) {
|
setMarked((prev) =>
|
||||||
setPending(null);
|
prev.some((s) => s.id === scene.id)
|
||||||
return;
|
? prev.filter((s) => s.id !== scene.id)
|
||||||
}
|
: [...prev, scene],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[keeper, merging],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isMarked = React.useCallback(
|
||||||
|
(sceneId: string) => marked.some((s) => s.id === sceneId),
|
||||||
|
[marked],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runMerge = React.useCallback(() => {
|
||||||
|
if (!keeper || marked.length === 0 || merging) return;
|
||||||
|
// Tytuły tniemy do trzech: Alert na Androidzie scrolluje treść, ale przy
|
||||||
|
// kilkunastu pozycjach przyciski uciekają poza ekran.
|
||||||
|
const preview = marked.slice(0, 3).map((s) => `- ${s.title}`).join('\n');
|
||||||
|
const more = marked.length > 3 ? `\n- and ${marked.length - 3} more` : '';
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Merge duplicate?',
|
marked.length === 1 ? 'Merge duplicate?' : `Merge ${marked.length} duplicates?`,
|
||||||
`"${dup.title}"\n\nmerge into\n\n"${target.title}"`,
|
`Keeping:\n${keeper.title}\n\nMerging in and removing:\n${preview}${more}`,
|
||||||
[
|
[
|
||||||
{ text: 'Cancel', style: 'cancel', onPress: () => setPending(null) },
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
{
|
{
|
||||||
text: 'Merge',
|
text: 'Merge',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
|
setMerging(true);
|
||||||
|
const failed: string[] = [];
|
||||||
|
for (const dup of marked) {
|
||||||
try {
|
try {
|
||||||
// keep = wybrany oryginał (target), drop = długo-naciśnięty duplikat (dup)
|
await client.mergeDuplicateScene(keeper.id, dup.id);
|
||||||
await client.mergeDuplicateScene(target.id, dup.id);
|
|
||||||
invalidate();
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('Merge failed', e?.message || 'unknown error');
|
failed.push(`${dup.title}: ${e?.message || 'unknown error'}`);
|
||||||
} finally {
|
}
|
||||||
setPending(null);
|
}
|
||||||
|
setMerging(false);
|
||||||
|
invalidate();
|
||||||
|
reset();
|
||||||
|
if (failed.length) {
|
||||||
|
Alert.alert(
|
||||||
|
`Merged ${marked.length - failed.length} of ${marked.length}`,
|
||||||
|
failed.slice(0, 5).join('\n\n'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
}, [keeper, marked, merging, client, invalidate, reset]);
|
||||||
[pendingDuplicate, client, invalidate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cancelDuplicate = React.useCallback(() => setPending(null), []);
|
|
||||||
|
|
||||||
const value = React.useMemo(
|
const value = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
pendingDuplicate,
|
keeper,
|
||||||
isSelecting: !!pendingDuplicate,
|
isSelecting: !!keeper,
|
||||||
|
isMarked,
|
||||||
openActions,
|
openActions,
|
||||||
pickDuplicateTarget,
|
toggleDuplicate,
|
||||||
cancelDuplicate,
|
cancelDuplicate: reset,
|
||||||
}),
|
}),
|
||||||
[pendingDuplicate, openActions, pickDuplicateTarget, cancelDuplicate],
|
[keeper, isMarked, openActions, toggleDuplicate, reset],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Ctx.Provider value={value}>
|
<Ctx.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
{pendingDuplicate ? (
|
{keeper ? (
|
||||||
<View style={styles.banner} pointerEvents="box-none">
|
<View style={styles.banner} pointerEvents="box-none">
|
||||||
<View style={styles.bannerInner}>
|
<View style={styles.bannerInner}>
|
||||||
<Text style={styles.bannerText} numberOfLines={2}>
|
<Text style={styles.bannerText} numberOfLines={2}>
|
||||||
Pick the original, tap the scene to merge "{pendingDuplicate.title}" into
|
{marked.length === 0
|
||||||
|
? `Tap every duplicate of "${keeper.title}"`
|
||||||
|
: `${marked.length} marked, keeping "${keeper.title}"`}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={cancelDuplicate} style={styles.bannerCancel} hitSlop={8}>
|
{merging ? (
|
||||||
|
<ActivityIndicator color={theme.fg} />
|
||||||
|
) : (
|
||||||
|
<Pressable
|
||||||
|
onPress={runMerge}
|
||||||
|
disabled={marked.length === 0}
|
||||||
|
style={[styles.bannerMerge, marked.length === 0 && styles.bannerMergeOff]}
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
<Text style={styles.bannerMergeText}>
|
||||||
|
{marked.length > 0 ? `Merge ${marked.length}` : 'Merge'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
<Pressable onPress={reset} style={styles.bannerCancel} hitSlop={8} disabled={merging}>
|
||||||
<Text style={styles.bannerCancelText}>Cancel</Text>
|
<Text style={styles.bannerCancelText}>Cancel</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -161,6 +220,14 @@ const styles = StyleSheet.create({
|
||||||
gap: 10,
|
gap: 10,
|
||||||
},
|
},
|
||||||
bannerText: { color: theme.fg, fontSize: 13, fontWeight: '600', flex: 1 },
|
bannerText: { color: theme.fg, fontSize: 13, fontWeight: '600', flex: 1 },
|
||||||
|
bannerMerge: {
|
||||||
|
backgroundColor: theme.fg,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
|
bannerMergeOff: { opacity: 0.4 },
|
||||||
|
bannerMergeText: { color: theme.accent, fontWeight: '800', fontSize: 13 },
|
||||||
bannerCancel: {
|
bannerCancel: {
|
||||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,13 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
id: '2026-08-23a',
|
||||||
|
date: 'August 2026',
|
||||||
|
items: [
|
||||||
|
'You can now merge more than two copies of the same scene at once. Long-press the copy you want to keep and pick "Merge duplicates into this", then tap every other copy, and merge them all in one go. Previously you had to repeat the whole thing for each pair.',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: '2026-08-22f',
|
id: '2026-08-22f',
|
||||||
date: 'August 2026',
|
date: 'August 2026',
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ function titleWithoutStudio(title: string, studio?: string | null): string {
|
||||||
function SceneTileBase({ scene, secondLine = 'studio', seenSince, onLongPress }: Props) {
|
function SceneTileBase({ scene, secondLine = 'studio', seenSince, onLongPress }: Props) {
|
||||||
const navigation =
|
const navigation =
|
||||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
const { isSelecting, pendingDuplicate, openActions, pickDuplicateTarget } = useSceneActions();
|
const { isSelecting, keeper, isMarked, openActions, toggleDuplicate } = useSceneActions();
|
||||||
|
|
||||||
// animated_thumbnail_url używamy tylko jako still-fallback gdy brak statycznej
|
// animated_thumbnail_url używamy tylko jako still-fallback gdy brak statycznej
|
||||||
// (preview-on-hold usunięty, bug-report 5a6844db, gest nic nie robił).
|
// (preview-on-hold usunięty, bug-report 5a6844db, gest nic nie robił).
|
||||||
|
|
@ -98,12 +98,14 @@ function SceneTileBase({ scene, secondLine = 'studio', seenSince, onLongPress }:
|
||||||
scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url ??
|
scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url ??
|
||||||
scene.playback_sources.find((s) => s.animated_thumbnail_url)?.animated_thumbnail_url;
|
scene.playback_sources.find((s) => s.animated_thumbnail_url)?.animated_thumbnail_url;
|
||||||
|
|
||||||
const isPending = pendingDuplicate?.id === scene.id;
|
const isKeeper = keeper?.id === scene.id;
|
||||||
|
const marked = isMarked(scene.id);
|
||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
// Tryb wyboru duplikatu: tap = wybierz oryginał (chyba że to ta sama kafelka).
|
// Tryb wyboru duplikatów: tap zaznacza i odznacza. Kafelek keepera nie reaguje,
|
||||||
if (isSelecting && !isPending) {
|
// bo scena nie może być duplikatem samej siebie.
|
||||||
pickDuplicateTarget(scene);
|
if (isSelecting && !isKeeper) {
|
||||||
|
toggleDuplicate(scene);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigation.navigate('SceneDetail', { id: scene.id });
|
navigation.navigate('SceneDetail', { id: scene.id });
|
||||||
|
|
@ -153,14 +155,17 @@ function SceneTileBase({ scene, secondLine = 'studio', seenSince, onLongPress }:
|
||||||
style={[
|
style={[
|
||||||
styles.thumbWrap,
|
styles.thumbWrap,
|
||||||
dim && styles.thumbDim,
|
dim && styles.thumbDim,
|
||||||
isSelecting && !isPending && styles.thumbSelectable,
|
isSelecting && !isKeeper && !marked && styles.thumbSelectable,
|
||||||
isPending && styles.thumbPending,
|
marked && styles.thumbMarked,
|
||||||
|
isKeeper && styles.thumbKeeper,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Thumb url={displayUrl} style={styles.thumb} />
|
<Thumb url={displayUrl} style={styles.thumb} />
|
||||||
{isPending ? (
|
{isKeeper || marked ? (
|
||||||
<View style={styles.dupBadge}>
|
<View style={[styles.dupBadge, isKeeper && styles.keepBadge]}>
|
||||||
<Text style={styles.dupBadgeText}>DUPLIKAT</Text>
|
<Text style={[styles.dupBadgeText, isKeeper && styles.keepBadgeText]}>
|
||||||
|
{isKeeper ? 'KEEP' : 'DUPLICATE'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{scene.is_favorite ? (
|
{scene.is_favorite ? (
|
||||||
|
|
@ -245,7 +250,10 @@ const styles = StyleSheet.create({
|
||||||
thumb: { width: '100%', height: '100%' },
|
thumb: { width: '100%', height: '100%' },
|
||||||
thumbDim: { opacity: 0.45 },
|
thumbDim: { opacity: 0.45 },
|
||||||
thumbSelectable: { borderWidth: 2, borderColor: theme.accentText, opacity: 0.85 },
|
thumbSelectable: { borderWidth: 2, borderColor: theme.accentText, opacity: 0.85 },
|
||||||
thumbPending: { borderWidth: 2, borderColor: theme.fg, opacity: 0.55 },
|
// Zaznaczony duplikat gaśnie, bo za chwilę zniknie. Keeper zostaje jasny i dostaje
|
||||||
|
// grubszą ramkę, żeby przy kilku zaznaczeniach było widać, w co się scala.
|
||||||
|
thumbMarked: { borderWidth: 2, borderColor: theme.fg, opacity: 0.55 },
|
||||||
|
thumbKeeper: { borderWidth: 3, borderColor: theme.accent, opacity: 1 },
|
||||||
dupBadge: {
|
dupBadge: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 6,
|
top: 6,
|
||||||
|
|
@ -256,6 +264,8 @@ const styles = StyleSheet.create({
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
},
|
},
|
||||||
dupBadgeText: { color: theme.fg, fontSize: 9, fontWeight: '800', letterSpacing: 0.6 },
|
dupBadgeText: { color: theme.fg, fontSize: 9, fontWeight: '800', letterSpacing: 0.6 },
|
||||||
|
keepBadge: { backgroundColor: theme.fg },
|
||||||
|
keepBadgeText: { color: theme.accent },
|
||||||
favBadge: {
|
favBadge: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 6,
|
top: 6,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue