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:
parent
bbc2059728
commit
068a65dd8c
2 changed files with 109 additions and 60 deletions
|
|
@ -16,6 +16,15 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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',
|
id: '2026-08-18g',
|
||||||
date: 'August 2026',
|
date: 'August 2026',
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import { resolveSxyprnPage } from '../lib/sxyprnResolver';
|
||||||
import { resolveEpornerPage } from '../lib/epornerResolver';
|
import { resolveEpornerPage } from '../lib/epornerResolver';
|
||||||
import { resolveFpoxxxPage } from '../lib/fpoxxxResolver';
|
import { resolveFpoxxxPage } from '../lib/fpoxxxResolver';
|
||||||
import type { RootStackParamList } from '../navigation';
|
import type { RootStackParamList } from '../navigation';
|
||||||
import { theme } from '../theme';
|
import { fonts, theme } from '../theme';
|
||||||
import type { PlaybackSource, SceneOut, StreamLink } from '../types';
|
import type { PlaybackSource, SceneOut, StreamLink } from '../types';
|
||||||
import { PlaybackQualityModal, pickByDefaultQuality } from './PlaybackQualityModal';
|
import { PlaybackQualityModal, pickByDefaultQuality } from './PlaybackQualityModal';
|
||||||
import { usePreferences } from '../PreferencesContext';
|
import { usePreferences } from '../PreferencesContext';
|
||||||
|
|
@ -243,6 +243,16 @@ export function SceneDetailScreen() {
|
||||||
if (error instanceof Error) return <Text style={styles.error}>{error.message}</Text>;
|
if (error instanceof Error) return <Text style={styles.error}>{error.message}</Text>;
|
||||||
if (!data) return null;
|
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 (
|
return (
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}>
|
||||||
<Hero data={data} onThumbState={setThumbState} />
|
<Hero data={data} onThumbState={setThumbState} />
|
||||||
|
|
@ -265,6 +275,30 @@ export function SceneDetailScreen() {
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : 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) && (
|
{(data.code || data.director) && (
|
||||||
<View style={styles.metaRow}>
|
<View style={styles.metaRow}>
|
||||||
{data.code ? <Text style={styles.metaDim}>code · {data.code}</Text> : null}
|
{data.code ? <Text style={styles.metaDim}>code · {data.code}</Text> : null}
|
||||||
|
|
@ -296,31 +330,6 @@ export function SceneDetailScreen() {
|
||||||
</Section>
|
</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 && (
|
{data.tags.length > 0 && (
|
||||||
<Section title="Tags">
|
<Section title="Tags">
|
||||||
<View style={styles.pillRow}>
|
<View style={styles.pillRow}>
|
||||||
|
|
@ -340,29 +349,6 @@ export function SceneDetailScreen() {
|
||||||
</Section>
|
</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 && (
|
{data.playback_sources.length > 0 && (
|
||||||
<Section title="Watch">
|
<Section title="Watch">
|
||||||
{data.playback_sources.map((p, i) => (
|
{data.playback_sources.map((p, i) => (
|
||||||
|
|
@ -467,14 +453,22 @@ function Hero({
|
||||||
if (!url) onThumbState?.('none');
|
if (!url) onThumbState?.('none');
|
||||||
}, [url, onThumbState]);
|
}, [url, onThumbState]);
|
||||||
|
|
||||||
const minutes = data.duration_sec ? Math.floor(data.duration_sec / 60) : null;
|
// Data zostaje na hero; studio/serwis/długość przeniesione do klikalnej linii w body
|
||||||
const meta = [
|
// (makieta 2026-08-18), bo studio MUSI dalej prowadzić do swoich scen.
|
||||||
data.release_date ?? null,
|
const meta = data.release_date ?? '';
|
||||||
data.studio?.name ?? null,
|
const dur = data.duration_sec ?? 0;
|
||||||
minutes !== null ? `${minutes} min` : null,
|
const durLabel =
|
||||||
]
|
dur > 0
|
||||||
.filter(Boolean)
|
? dur >= 3600
|
||||||
.join(' · ');
|
? `${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) {
|
if (!url) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -503,6 +497,18 @@ function Hero({
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
pointerEvents="none"
|
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}>
|
<View style={styles.heroOverlay}>
|
||||||
<Text style={styles.heroTitle} numberOfLines={3}>
|
<Text style={styles.heroTitle} numberOfLines={3}>
|
||||||
{data.title}
|
{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 }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHead}>
|
||||||
<Text style={styles.sectionTitle}>{title}</Text>
|
<Text style={styles.sectionTitle}>{title}</Text>
|
||||||
|
<View style={styles.sectionRule} />
|
||||||
|
</View>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
@ -947,6 +958,34 @@ const styles = StyleSheet.create({
|
||||||
backgroundColor: theme.card,
|
backgroundColor: theme.card,
|
||||||
overflow: 'hidden',
|
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: {
|
heroOverlay: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 16,
|
left: 16,
|
||||||
|
|
@ -980,12 +1019,13 @@ const styles = StyleSheet.create({
|
||||||
metaRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 14, marginTop: 8 },
|
metaRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 14, marginTop: 8 },
|
||||||
metaDim: { color: theme.mutedDim, fontSize: 12, fontWeight: '500' },
|
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: {
|
sectionTitle: {
|
||||||
color: theme.mutedDim,
|
color: theme.mutedDim,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
marginBottom: 10,
|
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue