feat(ui): przebudowa ekranu szczegolow sceny wg makiety (kierunek A)

Hero dostaje badge w rogach: jakosc po lewej, dlugosc po prawej, ten sam jezyk co na
kafelku, wiec przejscie z siatki do szczegolow nie zmienia zasad czytania.

Sekcja STUDIO zwinieta do linii metadanych (studio - serwis) pod tytulem. Zajmowala caly
blok na jeden chip. WAZNE: studio ZOSTAJE klikalne i dalej prowadzi do swoich scen,
zwiniecie wygladu nie moglo zabrac funkcji. Zweryfikowane na emulatorze: tap w "Gang AV"
otwiera ekran studia z 200 scenami.

Sekcja SOURCES usunieta. Pokazywala wewnetrzna nazwe konektora ("TUBE-SCRAPER"), ktora
uzytkownikowi nic nie mowila. Zastapiona nazwa serwisu wyliczana z originu (PORNDOE,
FULLPORNER) w tej samej linii co studio.

Naglowki sekcji dostaly kreske ciagnaca sie do prawej krawedzi: cztery sekcje mialy
cztery rozne odstepy i ekran nie mial rytmu.

Opis (Description) zostaje, wbrew makiecie, ktora go zgubila.

Zweryfikowane na emulatorze na prawdziwych danych (build debugowy podpisany kluczem
release): badge dlugosci, linia "Gang AV / PORNDOE", kreski przy naglowkach, zero
wystapien "TUBE-SCRAPER".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-08-18 14:36:56 +02:00
parent bbc2059728
commit 068a65dd8c
2 changed files with 109 additions and 60 deletions

View file

@ -16,6 +16,15 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-08-18h',
date: 'August 2026',
items: [
'Cleaner scene page. Quality and length now sit in the corners of the artwork, and the studio, site and length share one line under it instead of taking a whole section each. The studio is still tappable.',
'Dropped the "Sources" box that showed an internal name like TUBE-SCRAPER; the site the scene comes from is now named properly next to the studio.',
'Section headings got a divider line, so the page has a steadier rhythm as you scroll.',
],
},
{
id: '2026-08-18g',
date: 'August 2026',

View file

@ -26,7 +26,7 @@ import { resolveSxyprnPage } from '../lib/sxyprnResolver';
import { resolveEpornerPage } from '../lib/epornerResolver';
import { resolveFpoxxxPage } from '../lib/fpoxxxResolver';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import { fonts, theme } from '../theme';
import type { PlaybackSource, SceneOut, StreamLink } from '../types';
import { PlaybackQualityModal, pickByDefaultQuality } from './PlaybackQualityModal';
import { usePreferences } from '../PreferencesContext';
@ -243,6 +243,16 @@ export function SceneDetailScreen() {
if (error instanceof Error) return <Text style={styles.error}>{error.message}</Text>;
if (!data) return null;
// Nazwa serwisu z originu ('tube:sxyprncom' → 'sxyprn'). To zastępuje sekcję SOURCES,
// która pokazywała wewnętrzną nazwę konektora ("TUBE-SCRAPER") i nic użytkownikowi
// nie mówiła (decyzja 2026-08-18).
const sourceLabel = (() => {
const origin = data.playback_sources.find((x) => x.origin)?.origin;
if (!origin) return null;
const raw = origin.replace(/^tube:/, '').trim();
return raw ? raw.replace(/(com|net|org|xxx|tv|to)$/i, '') || raw : null;
})();
return (
<ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}>
<Hero data={data} onThumbState={setThumbState} />
@ -265,6 +275,30 @@ export function SceneDetailScreen() {
</Pressable>
) : null}
{/* Studio · serwis · długość w jednej linii, zamiast osobnej sekcji STUDIO na
jeden chip. Studio ZOSTAJE klikalne, bo to wejście do jego scen (zwinięcie
wyglądu nie może zabrać funkcji). */}
<View style={styles.factRow}>
{data.studio ? (
<Pressable
onPress={() =>
nav.push('StudioScenes', {
id: data.studio!.slug,
studioId: data.studio!.id,
name: data.studio!.name,
vertical: data.vertical,
})
}
>
<Text style={styles.factLink} numberOfLines={1}>
{data.studio.name}
{data.studio.network ? ` · ${data.studio.network}` : ''}
</Text>
</Pressable>
) : null}
{sourceLabel ? <Text style={styles.factDim}>{sourceLabel}</Text> : null}
</View>
{(data.code || data.director) && (
<View style={styles.metaRow}>
{data.code ? <Text style={styles.metaDim}>code · {data.code}</Text> : null}
@ -296,31 +330,6 @@ export function SceneDetailScreen() {
</Section>
)}
{data.studio && (
<Section title="Studio">
<View style={styles.pillRow}>
<Pressable
style={styles.pillPrimary}
onPress={() =>
nav.push('StudioScenes', {
id: data.studio!.slug,
studioId: data.studio!.id,
name: data.studio!.name,
// Bez tego backend odcina wertykał i studio animacyjne wychodzi
// puste, co wyglądało jakby kliknięcie nic nie robiło (195ae42e).
vertical: data.vertical,
})
}
>
<Text style={styles.pillPrimaryText}>
{data.studio.name}
{data.studio.network ? ` · ${data.studio.network}` : ''}
</Text>
</Pressable>
</View>
</Section>
)}
{data.tags.length > 0 && (
<Section title="Tags">
<View style={styles.pillRow}>
@ -340,29 +349,6 @@ export function SceneDetailScreen() {
</Section>
)}
{data.external_refs.length > 0 && (
<Section title="Sources">
<View style={styles.pillRow}>
{(() => {
// Sceny popularne mogą mieć dziesiątki refów na ten sam tube (różne URL,
// np. /search/.../ + /tag/... + /performer/..., wszystkie dla tej samej sceny).
// Deduplikujemy po source name, pokazujemy count jeśli > 1.
const counts = new Map<string, number>();
for (const r of data.external_refs) {
counts.set(r.source, (counts.get(r.source) ?? 0) + 1);
}
return Array.from(counts.entries()).map(([source, count]) => (
<View key={source} style={styles.pillSource}>
<Text style={styles.pillSourceText}>
{source}{count > 1 ? ` ×${count}` : ''}
</Text>
</View>
));
})()}
</View>
</Section>
)}
{data.playback_sources.length > 0 && (
<Section title="Watch">
{data.playback_sources.map((p, i) => (
@ -467,14 +453,22 @@ function Hero({
if (!url) onThumbState?.('none');
}, [url, onThumbState]);
const minutes = data.duration_sec ? Math.floor(data.duration_sec / 60) : null;
const meta = [
data.release_date ?? null,
data.studio?.name ?? null,
minutes !== null ? `${minutes} min` : null,
]
.filter(Boolean)
.join(' · ');
// Data zostaje na hero; studio/serwis/długość przeniesione do klikalnej linii w body
// (makieta 2026-08-18), bo studio MUSI dalej prowadzić do swoich scen.
const meta = data.release_date ?? '';
const dur = data.duration_sec ?? 0;
const durLabel =
dur > 0
? dur >= 3600
? `${Math.floor(dur / 3600)}h${String(Math.floor((dur % 3600) / 60)).padStart(2, '0')}`
: `${Math.floor(dur / 60)}m`
: null;
const qLabel =
data.max_quality_p && data.max_quality_p >= 720
? data.max_quality_p >= 2160
? '4K'
: `${data.max_quality_p}p`
: null;
if (!url) {
return (
@ -503,6 +497,18 @@ function Hero({
style={StyleSheet.absoluteFill}
pointerEvents="none"
/>
{/* Rogi jak na kafelku: jakość po lewej, długość po prawej. Ten sam język,
więc przejście z siatki do szczegółów nie zmienia zasad czytania. */}
{qLabel ? (
<View style={styles.heroBadgeLeft}>
<Text style={styles.heroBadgeText}>{qLabel}</Text>
</View>
) : null}
{durLabel ? (
<View style={styles.heroBadgeRight}>
<Text style={styles.heroBadgeText}>{durLabel}</Text>
</View>
) : null}
<View style={styles.heroOverlay}>
<Text style={styles.heroTitle} numberOfLines={3}>
{data.title}
@ -513,10 +519,15 @@ function Hero({
);
}
/** Nagłówek + cienka kreska ciągnąca się do prawej krawędzi. Daje ekranowi rytm,
* którego brakowało, gdy cztery sekcje miały cztery różne odstępy (makieta 2026-08-18). */
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={styles.section}>
<View style={styles.sectionHead}>
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.sectionRule} />
</View>
{children}
</View>
);
@ -947,6 +958,34 @@ const styles = StyleSheet.create({
backgroundColor: theme.card,
overflow: 'hidden',
},
heroBadgeLeft: {
position: 'absolute',
left: 12,
bottom: 12,
backgroundColor: 'rgba(0,0,0,0.78)',
paddingHorizontal: 7,
paddingVertical: 3,
borderRadius: 5,
},
heroBadgeRight: {
position: 'absolute',
right: 12,
bottom: 12,
backgroundColor: 'rgba(0,0,0,0.78)',
paddingHorizontal: 7,
paddingVertical: 3,
borderRadius: 5,
},
heroBadgeText: { color: theme.fg, fontSize: 11, fontFamily: fonts.mono, letterSpacing: 0.3 },
factRow: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 10, marginTop: 10 },
factLink: { color: theme.accentText, fontSize: 13, fontFamily: fonts.medium },
factDim: {
color: theme.mutedDim,
fontSize: 11,
fontFamily: fonts.mono,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
heroOverlay: {
position: 'absolute',
left: 16,
@ -980,12 +1019,13 @@ const styles = StyleSheet.create({
metaRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 14, marginTop: 8 },
metaDim: { color: theme.mutedDim, fontSize: 12, fontWeight: '500' },
section: { marginTop: 20 },
section: { marginTop: 22 },
sectionHead: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 10 },
sectionRule: { flex: 1, height: 1, backgroundColor: theme.border },
sectionTitle: {
color: theme.mutedDim,
fontSize: 11,
textTransform: 'uppercase',
marginBottom: 10,
letterSpacing: 1.2,
fontWeight: '700',
},