feat(performers): pelny filtr scen na ekranie performera

Prosba 39314780: "Add similar filters to when we search for videos by pornstars".

Ekran performera dostaje TEN SAM modal co glowny feed (ScenesFilterModal), nie osobny:
jakosc, minimalna dlugosc, sortowanie, zrodlo/hoster, tagi, studia oraz "Also featuring"
(sceny takze z inna osoba). Chipsy top-tagow zostaja OBOK, jako jednodotykowy skrot na
najczestszy przypadek; ich wybor scala sie z tagami z modala.

Trzy roznice wzgledem glownego feedu, wszystkie zamierzone:
* domyslne sortowanie po dacie premiery (tak ten ekran dzialal od zawsze),
* prog dlugosci domyslnie 0 (wejscie w konkretnego performera nie ma ukrywac scen o
  nieznanej dlugosci, bo NULL nie przechodzi `>= 60`; wybor "5+ min" podnosi go jawnie),
* "Also featuring" zamiast "Performers": backend laczy performer_ids koniunkcja, wiec
  wybrani sa dokladani do biezacego performera przez AND, nie zastepuja go. Modal
  dostal na to prop `performersLabel`.

Licznik na przycisku liczy sie wzgledem domyslnych TEGO ekranu, inaczej has_playback=true
podbijaloby "Filter 1" na czysto.

Backend bez zmian: /scenes juz przyjmuje wszystkie te parametry.
This commit is contained in:
goon-foss 2026-08-26 14:19:36 +02:00
parent ccba96376c
commit c0a5d02ece
3 changed files with 74 additions and 8 deletions

View file

@ -29,6 +29,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-08-26b',
date: 'August 2026',
items: [
'A performer page now has the same Filter as the main feed: quality, minimum length, sort order, source, tags, studios, and scenes that also feature another performer. The quick tag chips are still there for a one-tap filter.',
],
},
{
id: '2026-08-26a',
date: 'August 2026',

View file

@ -21,6 +21,7 @@ import {
import { useClient } from '../ClientContext';
import { MoviePosterCard } from '../components/MoviePosterCard';
import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { DEFAULT_FILTER, FilterState, ScenesFilterModal } from './ScenesFilterModal';
import { sortNewFirst } from '../lib/newScenes';
import { usePreferences } from '../PreferencesContext';
import { ErrorBoundary } from '../ErrorBoundary';
@ -32,6 +33,11 @@ type Tab = 'scenes' | 'movies';
const MOVIE_COLS = 2;
// Ten sam modal i stan filtra co główny feed (prośba 39314780: "similar filters to
// when we search for videos by pornstars"), z jedną różnicą: domyślne sortowanie po
// dacie premiery, bo tak ten ekran sortował od zawsze i taka jest intuicja "filmografii".
const PERFORMER_DEFAULT_FILTER: FilterState = { ...DEFAULT_FILTER, sort: 'release_date' };
export function PerformerScenesScreen() {
const client = useClient();
const { gridColumns } = usePreferences();
@ -155,23 +161,51 @@ export function PerformerScenesScreen() {
// Filtr tagu IN-PLACE (bug-report 0264a3ff: wybór tagu ma filtrować sceny tej
// aktorki w tym widoku, nie nawigować do osobnego ekranu). null = bez filtra.
// Chipsy zostają OBOK pełnego modala: to skrót jednodotykowy na najczęstszy
// przypadek, a modal dokłada resztę (jakość, długość, studio, sort, obsada).
const [selectedTag, setSelectedTag] = React.useState<{ slug: string; name: string } | null>(null);
const [filter, setFilter] = React.useState<FilterState>(PERFORMER_DEFAULT_FILTER);
const [filterOpen, setFilterOpen] = React.useState(false);
// Licznik liczony WZGLĘDEM domyślnych tego ekranu, nie jak w głównym feedzie, gdzie
// domyślne has_playback=true podbija licznik i "Filter 1" świeci na czysto.
const filterActiveCount =
filter.tagSlugs.length +
filter.studioSlugs.length +
filter.performerIds.length +
(filter.origin.trim() ? 1 : 0) +
(filter.minDurationSec > 0 ? 1 : 0) +
(filter.minQualityP > 0 ? 1 : 0) +
(filter.sort !== PERFORMER_DEFAULT_FILTER.sort ? 1 : 0) +
(!filter.hasPlayback ? 1 : 0);
const scenesQuery = useQuery({
queryKey: ['performer-scenes', id, selectedTag?.slug ?? null, vertical ?? null],
queryKey: ['performer-scenes', id, selectedTag?.slug ?? null, vertical ?? null, filter],
queryFn: () =>
client.listScenes({
performer_ids: [id],
// Wybrani w modalu to KONIUNKCJA z bieżącym performerem ("sceny także z..."),
// backend łączy performer_ids przez AND. Dubel id jest niegroźny, ale filtrujemy.
performer_ids: [id, ...filter.performerIds.filter((p) => p !== id)],
vertical: vertical ?? undefined,
// 0 = nie nakładaj progu długości. User wszedł JAWNIE w tę pozycję, więc
// ukrywanie scen o nieznanej długości (w SQL NULL nie przechodzi `>= 60`)
// wyglądało jak pusty albo zatrzymany serwis. Patrz komentarz w api.ts.
min_duration_sec: 0,
tags: selectedTag ? [selectedTag.slug] : undefined,
sort: 'release_date',
// Wybór w filtrze ("5+ min" itd.) podnosi próg jawnie.
min_duration_sec: filter.minDurationSec > 0 ? filter.minDurationSec : 0,
tags: (() => {
const merged = new Set(filter.tagSlugs);
if (selectedTag) merged.add(selectedTag.slug);
return merged.size ? [...merged] : undefined;
})(),
studio_slugs: filter.studioSlugs.length ? filter.studioSlugs : undefined,
origin: filter.origin.trim() || undefined,
min_quality_p: filter.minQualityP > 0 ? filter.minQualityP : undefined,
include_stubs: filter.includeStubs || undefined,
sort: filter.sort,
per_page: 200,
// Sceny TPDB/StashDB bez tube-linków nie są usable, zawsze filtrujemy.
has_playback: true,
// Domyślnie ukrywamy stuby TPDB/StashDB bez tube-linków; wyłączenie w modalu
// pokazuje pełną filmografię, z pozycjami, których (jeszcze) nie da się odtworzyć.
has_playback: filter.hasPlayback || undefined,
}),
});
@ -285,6 +319,14 @@ export function PerformerScenesScreen() {
<Text style={styles.actionBtnTextPrimary}>↻ Search more scenes</Text>
)}
</Pressable>
<Pressable
style={[styles.actionBtn, filterActiveCount > 0 && styles.actionBtnActive]}
onPress={() => setFilterOpen(true)}
>
<Text style={styles.actionBtnTextPrimary}>
Filter{filterActiveCount > 0 ? ` ${filterActiveCount}` : ''}
</Text>
</Pressable>
</View>
{tab === 'scenes' && !!tagsQuery.data?.items.length && (
<ScrollView
@ -383,6 +425,17 @@ export function PerformerScenesScreen() {
/>
</ErrorBoundary>
)}
<ScenesFilterModal
visible={filterOpen}
initial={filter}
vertical={vertical ?? undefined}
performersLabel="Also featuring"
onApply={(next) => {
setFilter(next);
setFilterOpen(false);
}}
onClose={() => setFilterOpen(false)}
/>
</View>
);
}
@ -441,6 +494,7 @@ const styles = StyleSheet.create({
paddingVertical: 6,
alignItems: 'center',
},
actionBtnActive: { borderColor: theme.accent },
actionBtnText: { color: theme.muted, fontWeight: '600', fontSize: 12 },
actionBtnTextPrimary: { color: theme.accentText, fontWeight: '700', fontSize: 12 },
tagRow: {

View file

@ -86,6 +86,7 @@ export function ScenesFilterModal({
onApply,
onClose,
vertical,
performersLabel,
}: {
visible: boolean;
initial: FilterState;
@ -94,6 +95,10 @@ export function ScenesFilterModal({
/** Wertykał ekranu ('animation'/'jav'); filtruje listę tagów do jego słownika.
* Bez tego sekcja animacji dostawała zachodnie tagi, których tam nie ma czym wypełnić. */
vertical?: string;
/** Tytuł sekcji performerów. Ekran performera podaje "Also featuring", bo tam wybór
* znaczy "sceny TAKŻE z tą osobą" (backend łączy performer_ids koniunkcją), a nie
* wybór, czyje sceny oglądamy. */
performersLabel?: string;
}) {
const client = useClient();
const [draft, setDraft] = useState<FilterState>(initial);
@ -362,7 +367,7 @@ export function ScenesFilterModal({
)}
</Section>
<Section title={`Performers (${draft.performerIds.length} selected)`}>
<Section title={`${performersLabel ?? 'Performers'} (${draft.performerIds.length} selected)`}>
<TextInput
style={styles.search}
value={performerQ}