diff --git a/mobile/App.tsx b/mobile/App.tsx
index 25110b0..20c67a0 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -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() {
}}
/>
+
diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts
index 87ccaa3..86c8914 100644
--- a/mobile/src/changelog.ts
+++ b/mobile/src/changelog.ts
@@ -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',
diff --git a/mobile/src/components/OnboardingModal.tsx b/mobile/src/components/OnboardingModal.tsx
new file mode 100644
index 0000000..804ae87
--- /dev/null
+++ b/mobile/src/components/OnboardingModal.tsx
@@ -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 0–5★.',
+ ],
+ },
+ {
+ 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 what’s 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(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) => {
+ 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 (
+
+
+
+
+ GOON
+
+ {isLast ? '' : 'Skip'}
+
+
+
+
+ {SLIDES.map((s, i) => (
+
+ {s.icon}
+ {s.title}
+
+ {s.lines.map((ln, j) => (
+
+ •
+ {ln}
+
+ ))}
+
+
+ ))}
+
+
+
+ {SLIDES.map((_, i) => (
+
+ ))}
+
+
+
+ goTo(index - 1)}
+ disabled={index === 0}
+ style={[styles.btnGhost, index === 0 && styles.btnHidden]}
+ >
+ Back
+
+ (isLast ? finish() : goTo(index + 1))} style={styles.btnPrimary}>
+ {isLast ? 'Start browsing' : 'Next'}
+
+
+
+
+
+ );
+}
+
+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' },
+});
diff --git a/mobile/src/components/WhatsNewModal.tsx b/mobile/src/components/WhatsNewModal.tsx
index d011425..cd9fccb 100644
--- a/mobile/src/components/WhatsNewModal.tsx
+++ b/mobile/src/components/WhatsNewModal.tsx
@@ -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) {
diff --git a/mobile/src/lib/onboardingBus.ts b/mobile/src/lib/onboardingBus.ts
new file mode 100644
index 0000000..44825df
--- /dev/null
+++ b/mobile/src/lib/onboardingBus.ts
@@ -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();
+
+export function onReplay(cb: Listener): () => void {
+ listeners.add(cb);
+ return () => listeners.delete(cb);
+}
+
+export function replayOnboarding(): void {
+ listeners.forEach((cb) => cb());
+}
diff --git a/mobile/src/screens/AppLockSettingsScreen.tsx b/mobile/src/screens/AppLockSettingsScreen.tsx
index e735799..1095b18 100644
--- a/mobile/src/screens/AppLockSettingsScreen.tsx
+++ b/mobile/src/screens/AppLockSettingsScreen.tsx
@@ -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() {
{REPO_LABEL} ›
+ replayOnboarding()}>
+
+ Replay tutorial
+ Show the first-launch tour again
+
+ ›
+
);
diff --git a/mobile/src/storage.ts b/mobile/src/storage.ts
index 7d0461d..64ec9e4 100644
--- a/mobile/src/storage.ts
+++ b/mobile/src/storage.ts
@@ -50,6 +50,28 @@ export async function setLastSeenChangelog(id: string): Promise {
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 {
+ 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 {
+ 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';