feat(mobile): first-launch tutorial (pages, features, long-presses, player gestures)

A 7-slide carousel shown once on first launch:
- the three tabs (Scenes/Movies/Sites)
- search, filters, saved searches, Performers/Tags/Favorites
- long-press actions (hide/duplicate a scene, remove a wrong performer, link diagnostics)
- player gestures (tap controls, double-tap ±15s, swipe to scrub, unmute)
- favorites, Hidden content, PIN lock, the ? report button, Sites ★ ratings

Gated by a SecureStore flag; replayable from Settings ⚙ → Replay tutorial (via a
tiny onboarding bus). Suppresses the What's-new popup for brand-new users (the tour
covers it) and marks the changelog seen on finish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-22 10:08:56 +02:00
parent c154deab37
commit db23b63e46
7 changed files with 321 additions and 1 deletions

View file

@ -25,6 +25,7 @@ import { GoonClient } from './src/api';
import { ClientProvider } from './src/ClientContext';
import { PreferencesProvider } from './src/PreferencesContext';
import { SceneActionsProvider } from './src/SceneActionsContext';
import { OnboardingModal } from './src/components/OnboardingModal';
import { WhatsNewModal } from './src/components/WhatsNewModal';
import { ErrorBoundary } from './src/ErrorBoundary';
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
@ -415,6 +416,7 @@ export default function App() {
}}
/>
<WhatsNewModal />
<OnboardingModal />
</SceneActionsProvider>
</PreferencesProvider>
</ClientProvider>

View file

@ -16,6 +16,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-06-22b',
date: 'June 2026',
items: [
'New: a quick tutorial on first launch — tabs, search, long-press actions and player gestures. Replay it anytime from Settings ⚙.',
],
},
{
id: '2026-06-22',
date: 'June 2026',

View file

@ -0,0 +1,260 @@
/**
* Tutorial przy pierwszym odpaleniu paged carousel po stronach, funkcjach,
* longpressach i gestach playera. Pokazuje się raz (flaga goon.onboarding_seen_v1
* w SecureStore); replay z ustawień przez onboardingBus. Montowany raz nad
* nawigacją (App.tsx), niezależny od ekranów slajdy opisują UI ilustracyjnie,
* bez spotlightowania realnych elementów (odporne na zmiany layoutu).
*
* Treść trzymać ZGODNĄ z realnymi gestami/akcjami (patrz SceneActionsContext,
* PlayerScreen gesty, SceneDetail performer long-press, navigation TopTabs).
*/
import React from 'react';
import {
Dimensions,
Modal,
NativeScrollEvent,
NativeSyntheticEvent,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { NEWEST_CHANGELOG_ID } from '../changelog';
import { onReplay } from '../lib/onboardingBus';
import { getOnboardingSeen, setLastSeenChangelog, setOnboardingSeen } from '../storage';
import { theme } from '../theme';
type Slide = { icon: string; title: string; lines: string[] };
const SLIDES: Slide[] = [
{
icon: '👋',
title: 'Welcome to Goon',
lines: [
'Scenes and movies pulled together from 30+ sites — browse and play in one place.',
'Quick tour, about 30 seconds. Swipe or tap Next.',
],
},
{
icon: '🗂️',
title: 'Three tabs up top',
lines: [
'Scenes — individual clips.',
'Movies — full-length, often multi-part.',
'Sites — browse by source, each rated 05★.',
],
},
{
icon: '🔎',
title: 'Find what you want',
lines: [
'Search, then Filter by length, tags, studio, performer or sort.',
'Tap ☆ Save next to the search box to reuse a query as a chip.',
'From Scenes you can jump to Performers, Tags and Favorites.',
],
},
{
icon: '👆',
title: 'Long-press for more',
lines: [
'Hold a scene tile → Hide it, or mark it as a duplicate.',
'Hold a performer on a scene → remove a wrong tag.',
'Hold a play link → open in browser (diagnostics) or mark it broken.',
],
},
{
icon: '▶️',
title: 'Player gestures',
lines: [
'Tap once → show or hide the controls.',
'Double-tap left / right → skip back / forward 15s.',
'Swipe across the video → scrub to any point.',
'It starts muted — tap the speaker for sound.',
],
},
{
icon: '⭐',
title: 'Make it yours',
lines: [
'Heart scenes, movies and performers — they collect in Favorites.',
'Settings ⚙ → Hidden content hides tags, performers or studios. Grid columns and a PIN lock live there too.',
'Hit a problem? The ? button sends a report — and you can read replies.',
],
},
{
icon: '✅',
title: "You're set",
lines: [
'On Sites, the ★ rating tells you whats fresh and what actually plays — tap the stars for the breakdown.',
'Replay this tour anytime: Settings ⚙ → Replay tutorial.',
],
},
];
export function OnboardingModal() {
const [visible, setVisible] = React.useState(false);
const [index, setIndex] = React.useState(0);
const scrollRef = React.useRef<ScrollView>(null);
const width = Dimensions.get('window').width;
// Pierwsze odpalenie: pokaż jeśli jeszcze nie widziany.
React.useEffect(() => {
let cancelled = false;
getOnboardingSeen().then((seen) => {
if (!cancelled && !seen) setVisible(true);
});
return () => {
cancelled = true;
};
}, []);
// Replay z ustawień.
React.useEffect(
() =>
onReplay(() => {
setIndex(0);
setVisible(true);
// reset pozycji scrolla po otwarciu
requestAnimationFrame(() => scrollRef.current?.scrollTo({ x: 0, animated: false }));
}),
[],
);
const finish = React.useCallback(() => {
setVisible(false);
setOnboardingSeen(true).catch(() => {});
// Nowy user nie powinien po tutorialu dostać od razu "What's new" — oznacz
// changelog jako widziany do najnowszego wpisu.
if (NEWEST_CHANGELOG_ID) setLastSeenChangelog(NEWEST_CHANGELOG_ID).catch(() => {});
}, []);
const goTo = React.useCallback(
(i: number) => {
const clamped = Math.max(0, Math.min(SLIDES.length - 1, i));
setIndex(clamped);
scrollRef.current?.scrollTo({ x: clamped * width, animated: true });
},
[width],
);
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const i = Math.round(e.nativeEvent.contentOffset.x / width);
if (i !== index) setIndex(i);
};
if (!visible) return null;
const isLast = index === SLIDES.length - 1;
return (
<Modal visible transparent animationType="fade" onRequestClose={finish}>
<View style={styles.backdrop}>
<View style={styles.card}>
<View style={styles.topRow}>
<Text style={styles.brand}>GOON</Text>
<Pressable onPress={finish} hitSlop={10}>
<Text style={styles.skip}>{isLast ? '' : 'Skip'}</Text>
</Pressable>
</View>
<ScrollView
ref={scrollRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={onScroll}
style={styles.pager}
>
{SLIDES.map((s, i) => (
<View key={i} style={[styles.slide, { width: width - 48 }]}>
<Text style={styles.icon}>{s.icon}</Text>
<Text style={styles.title}>{s.title}</Text>
<View style={styles.lines}>
{s.lines.map((ln, j) => (
<View key={j} style={styles.lineRow}>
<Text style={styles.bullet}></Text>
<Text style={styles.lineText}>{ln}</Text>
</View>
))}
</View>
</View>
))}
</ScrollView>
<View style={styles.dots}>
{SLIDES.map((_, i) => (
<View key={i} style={[styles.dot, i === index && styles.dotActive]} />
))}
</View>
<View style={styles.btnRow}>
<Pressable
onPress={() => goTo(index - 1)}
disabled={index === 0}
style={[styles.btnGhost, index === 0 && styles.btnHidden]}
>
<Text style={styles.btnGhostText}>Back</Text>
</Pressable>
<Pressable onPress={() => (isLast ? finish() : goTo(index + 1))} style={styles.btnPrimary}>
<Text style={styles.btnPrimaryText}>{isLast ? 'Start browsing' : 'Next'}</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.82)',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
card: {
width: '100%',
backgroundColor: theme.bgElevated,
borderRadius: 18,
borderColor: theme.border,
borderWidth: 1,
paddingVertical: 18,
paddingHorizontal: 24,
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 6,
},
brand: { color: theme.accent, fontWeight: '900', letterSpacing: 2, fontSize: 13 },
skip: { color: theme.muted, fontSize: 14, fontWeight: '600', minWidth: 36, textAlign: 'right' },
pager: { flexGrow: 0 },
slide: { paddingTop: 14, paddingBottom: 8, minHeight: 250 },
icon: { fontSize: 44, marginBottom: 12 },
title: { color: theme.fg, fontSize: 22, fontWeight: '800', marginBottom: 16 },
lines: { gap: 12 },
lineRow: { flexDirection: 'row', gap: 10 },
bullet: { color: theme.accent, fontSize: 16, lineHeight: 22 },
lineText: { color: theme.fg, fontSize: 15, lineHeight: 22, flex: 1 },
dots: { flexDirection: 'row', justifyContent: 'center', gap: 6, marginVertical: 16 },
dot: { width: 7, height: 7, borderRadius: 4, backgroundColor: theme.border },
dotActive: { backgroundColor: theme.accent, width: 18 },
btnRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
btnGhost: { paddingVertical: 12, paddingHorizontal: 18, borderRadius: 12 },
btnHidden: { opacity: 0 },
btnGhostText: { color: theme.muted, fontSize: 15, fontWeight: '700' },
btnPrimary: {
flex: 1,
backgroundColor: theme.accent,
borderRadius: 12,
paddingVertical: 13,
alignItems: 'center',
},
btnPrimaryText: { color: theme.fg, fontSize: 15, fontWeight: '800' },
});

View file

@ -13,7 +13,7 @@ import {
unseenEntries,
type ChangelogEntry,
} from '../changelog';
import { getLastSeenChangelog, setLastSeenChangelog } from '../storage';
import { getLastSeenChangelog, getOnboardingSeen, setLastSeenChangelog } from '../storage';
import { theme } from '../theme';
export function WhatsNewModal() {
@ -24,6 +24,10 @@ export function WhatsNewModal() {
let cancelled = false;
(async () => {
try {
// Nowy user (jeszcze nie przeszedł tutoriala) → tutorial ma pierwszeństwo,
// nie wyskakuj changelogiem. Onboarding po sobie oznaczy changelog widziany.
const onboarded = await getOnboardingSeen();
if (!onboarded) return;
const lastSeen = await getLastSeenChangelog();
const unseen = unseenEntries(lastSeen);
if (!cancelled && unseen.length > 0) {

View file

@ -0,0 +1,17 @@
/**
* Mini event-bus do ręcznego odpalenia tutoriala ("Replay tutorial" w ustawieniach).
* OnboardingModal subskrybuje `onReplay`, ekran ustawień woła `replayOnboarding()`.
* Zero zależności lista listenerów w module scope.
*/
type Listener = () => void;
const listeners = new Set<Listener>();
export function onReplay(cb: Listener): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
export function replayOnboarding(): void {
listeners.forEach((cb) => cb());
}

View file

@ -25,6 +25,7 @@ import {
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { APP_VERSION } from '../lib/appVersion';
import { replayOnboarding } from '../lib/onboardingBus';
import { usePreferences } from '../PreferencesContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
@ -315,6 +316,13 @@ export function AppLockSettingsScreen() {
</View>
<Text style={styles.linkValue}>{REPO_LABEL} </Text>
</Pressable>
<Pressable style={styles.row} onPress={() => replayOnboarding()}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Replay tutorial</Text>
<Text style={styles.hint}>Show the first-launch tour again</Text>
</View>
<Text style={styles.linkValue}></Text>
</Pressable>
</View>
</ScrollView>
);

View file

@ -50,6 +50,28 @@ export async function setLastSeenChangelog(id: string): Promise<void> {
await SecureStore.setItemAsync(CHANGELOG_SEEN_KEY, id);
}
// Onboarding/tutorial — pokazany raz przy pierwszym odpaleniu (carousel: tabs,
// funkcje, longpressy, gesty playera). Wersjonowany klucz → bump gdy tutorial
// istotnie się zmieni i chcemy pokazać ponownie. Replay z ustawień przez bus.
const ONBOARDING_SEEN_KEY = 'goon.onboarding_seen_v1';
export async function getOnboardingSeen(): Promise<boolean> {
try {
return (await SecureStore.getItemAsync(ONBOARDING_SEEN_KEY)) === '1';
} catch {
return true; // brak SecureStore → nie blokuj UI tutorialem w kółko
}
}
export async function setOnboardingSeen(seen: boolean): Promise<void> {
try {
if (seen) await SecureStore.setItemAsync(ONBOARDING_SEEN_KEY, '1');
else await SecureStore.deleteItemAsync(ONBOARDING_SEEN_KEY);
} catch {
// ignore — keychain quirks
}
}
// Liczba kolumn w siatkach scen (1/2/3). Feedback mobilism: domyślne 2 kol = za małe
// miniaturki na telefonie do rozpoznania performera. Default 2 (tablet-friendly).
const GRID_COLS_KEY = 'goon.grid_columns';