diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts
index e836368..38b791c 100644
--- a/mobile/src/changelog.ts
+++ b/mobile/src/changelog.ts
@@ -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',
diff --git a/mobile/src/screens/SceneDetailScreen.tsx b/mobile/src/screens/SceneDetailScreen.tsx
index cb9d27e..66750cb 100644
--- a/mobile/src/screens/SceneDetailScreen.tsx
+++ b/mobile/src/screens/SceneDetailScreen.tsx
@@ -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 {error.message};
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 (
@@ -265,6 +275,30 @@ export function SceneDetailScreen() {
) : 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). */}
+
+ {data.studio ? (
+
+ nav.push('StudioScenes', {
+ id: data.studio!.slug,
+ studioId: data.studio!.id,
+ name: data.studio!.name,
+ vertical: data.vertical,
+ })
+ }
+ >
+
+ {data.studio.name}
+ {data.studio.network ? ` · ${data.studio.network}` : ''}
+
+
+ ) : null}
+ {sourceLabel ? {sourceLabel} : null}
+
+
{(data.code || data.director) && (
{data.code ? code · {data.code} : null}
@@ -296,31 +330,6 @@ export function SceneDetailScreen() {
)}
- {data.studio && (
-
-
-
- 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,
- })
- }
- >
-
- {data.studio.name}
- {data.studio.network ? ` · ${data.studio.network}` : ''}
-
-
-
-
- )}
-
{data.tags.length > 0 && (
@@ -340,29 +349,6 @@ export function SceneDetailScreen() {
)}
- {data.external_refs.length > 0 && (
-
-
- {(() => {
- // 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();
- 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]) => (
-
-
- {source}{count > 1 ? ` ×${count}` : ''}
-
-
- ));
- })()}
-
-
- )}
-
{data.playback_sources.length > 0 && (
{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 ? (
+
+ {qLabel}
+
+ ) : null}
+ {durLabel ? (
+
+ {durLabel}
+
+ ) : null}
{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 (
- {title}
+
+ {title}
+
+
{children}
);
@@ -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',
},