diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx
index 57e5d68..a7d7af7 100644
--- a/src/app/(app)/settings.tsx
+++ b/src/app/(app)/settings.tsx
@@ -14,6 +14,7 @@ import { Trash } from '@/components/ui/icons';
import { UpdateDialog } from '@/components/update-dialog';
import { LOCK_TIMEOUT_OPTIONS, useSecuritySettings } from '@/lib/hooks/use-security-settings';
import { useUpdateCheck } from '@/lib/hooks/use-update-check';
+import { useVideoAutoplay } from '@/lib/hooks/use-video-autoplay';
import { useTranslate } from '@/lib/i18n';
import { useDownloadedStore } from '@/lib/stores/downloaded-store';
import { ORIENTATIONS, useOrientationStore } from '@/lib/stores/orientation-store';
@@ -90,6 +91,7 @@ export default function Settings() {
dismissUpdateDialog,
openReleaseDownload,
} = useUpdateCheck(Env.VERSION);
+ const { autoplayEnabled, setAutoplayEnabled } = useVideoAutoplay();
useEffect(() => {
LocalAuthentication.hasHardwareAsync().then((has) => {
@@ -241,6 +243,37 @@ export default function Settings() {
/>
+ {/* Video playback */}
+
+
+
+
+
+
+
+
+ setAutoplayEnabled(!autoplayEnabled)}
+ testID="autoplay-toggle"
+ className={`h-6 w-11 rounded-full ${autoplayEnabled ? 'bg-primary-500' : 'bg-neutral-300 dark:bg-neutral-600'}`}
+ >
+
+
+
+
+
+
{/* Content orientation (multi-select, applied via the flag1 URL param) */}
diff --git a/src/app/post/[id].source.test.tsx b/src/app/post/[id].source.test.tsx
index 7632235..b994998 100644
--- a/src/app/post/[id].source.test.tsx
+++ b/src/app/post/[id].source.test.tsx
@@ -72,6 +72,15 @@ jest.mock('react-native-flash-message', () => ({
showMessage: jest.fn(),
}));
+const mockAutoplay = { enabled: true };
+
+jest.mock('@/lib/hooks/use-video-autoplay', () => ({
+ useVideoAutoplay: () => ({
+ autoplayEnabled: mockAutoplay.enabled,
+ setAutoplayEnabled: jest.fn(),
+ }),
+}));
+
jest.mock('@/lib/hooks', () => ({
useVideoDownload: (props: { videoId: string }) => ({
isDownloading: false,
@@ -122,6 +131,7 @@ const seedDownload = (quality: string) => {
beforeEach(() => {
jest.clearAllMocks();
+ mockAutoplay.enabled = true;
mockPlayer.playing = false;
mockDownloadState.existingFiles = new Set();
mockDetailState.isError = false;
@@ -222,6 +232,24 @@ describe('Post screen — player source selection', () => {
});
});
+describe('Post screen — autoplay', () => {
+ it('starts playback automatically once a source is available', async () => {
+ setup();
+
+ await waitFor(() => expect(lastPlayerSource()).toBe('https://example.com/v1_720p.mp4'));
+ expect(mockPlayer.play).toHaveBeenCalled();
+ });
+
+ it('does not start playback when autoplay is turned off', async () => {
+ mockAutoplay.enabled = false;
+
+ setup();
+
+ await waitFor(() => expect(lastPlayerSource()).toBe('https://example.com/v1_720p.mp4'));
+ expect(mockPlayer.play).not.toHaveBeenCalled();
+ });
+});
+
describe('Post screen — dead page toast', () => {
// The toast text depends on the active i18n locale (en in tests).
const EN_REMOVED = resources.en.translation.post.video_removed;
diff --git a/src/app/post/[id].test.tsx b/src/app/post/[id].test.tsx
index dde6cff..3ac59fa 100644
--- a/src/app/post/[id].test.tsx
+++ b/src/app/post/[id].test.tsx
@@ -63,6 +63,10 @@ jest.mock('@/api/video-queries', () => ({
}),
}));
+jest.mock('@/lib/hooks/use-video-autoplay', () => ({
+ useVideoAutoplay: () => ({ autoplayEnabled: false, setAutoplayEnabled: jest.fn() }),
+}));
+
jest.mock('@/lib/hooks', () => ({
useVideoDownload: jest.fn().mockReturnValue({
isDownloading: false,
diff --git a/src/app/post/[id].tsx b/src/app/post/[id].tsx
index 4b9fe86..ed6084c 100644
--- a/src/app/post/[id].tsx
+++ b/src/app/post/[id].tsx
@@ -8,6 +8,7 @@ import { showMessage } from 'react-native-flash-message';
import { useVideoDetail } from '@/api/video-queries';
import { ActivityIndicator, Button, FocusAwareStatusBar, Text } from '@/components/ui';
import { useVideoDownload } from '@/lib/hooks';
+import { useVideoAutoplay } from '@/lib/hooks/use-video-autoplay';
import { useTranslate } from '@/lib/i18n';
import { artistChipLabel } from '@/lib/r34/artists';
import { toOfflineDetail } from '@/lib/r34/offline-detail';
@@ -69,6 +70,8 @@ function VideoSection({ data }: VideoSectionProps): React.ReactElement {
const videoSource = isDownloaded && fileUri ? fileUri : selectedFormat?.url || '';
+ const { autoplayEnabled } = useVideoAutoplay();
+
const player = useVideoPlayer(videoSource, (player) => {
player.loop = true;
});
@@ -77,6 +80,16 @@ function VideoSection({ data }: VideoSectionProps): React.ReactElement {
isPlaying: player.playing,
});
+ // Start playback when the page opens (and when the source changes, e.g. a
+ // quality switch or the download taking over) — unless the user turned
+ // autoplay off. Manual pausing is never overridden: the effect only runs
+ // when the source or the setting itself changes.
+ React.useEffect(() => {
+ if (autoplayEnabled && videoSource) {
+ player.play();
+ }
+ }, [autoplayEnabled, player, videoSource]);
+
// The screen stays mounted in the stack after navigating away (e.g. to a
// tag or another post), so the player keeps playing — and two posts pushed
// on top of each other would play simultaneously. Pause whenever this
diff --git a/src/lib/hooks/use-video-autoplay.test.ts b/src/lib/hooks/use-video-autoplay.test.ts
new file mode 100644
index 0000000..2a0692f
--- /dev/null
+++ b/src/lib/hooks/use-video-autoplay.test.ts
@@ -0,0 +1,45 @@
+const mockMMKV = {
+ value: undefined as boolean | undefined,
+};
+
+jest.mock('react-native-mmkv', () => ({
+ // storage.tsx imports createMMKV from the same module — keep it available.
+ createMMKV: jest.fn(() => ({
+ set: jest.fn(),
+ getString: jest.fn(),
+ getAllKeys: jest.fn(() => []),
+ remove: jest.fn(),
+ })),
+ useMMKVBoolean: (_key: string, _storage: unknown) => [
+ mockMMKV.value,
+ (v: boolean) => {
+ mockMMKV.value = v;
+ },
+ ],
+}));
+
+import { act, renderHook } from '@testing-library/react-native';
+
+import { useVideoAutoplay } from './use-video-autoplay';
+
+beforeEach(() => {
+ mockMMKV.value = undefined;
+});
+
+describe('useVideoAutoplay', () => {
+ it('defaults to on before the user ever touches the toggle', () => {
+ const { result } = renderHook(() => useVideoAutoplay());
+
+ expect(result.current.autoplayEnabled).toBe(true);
+ });
+
+ it('reflects the stored choice and updates it', () => {
+ mockMMKV.value = false;
+ const { result } = renderHook(() => useVideoAutoplay());
+
+ expect(result.current.autoplayEnabled).toBe(false);
+
+ act(() => result.current.setAutoplayEnabled(true));
+ expect(mockMMKV.value).toBe(true);
+ });
+});
diff --git a/src/lib/hooks/use-video-autoplay.ts b/src/lib/hooks/use-video-autoplay.ts
new file mode 100644
index 0000000..374c86f
--- /dev/null
+++ b/src/lib/hooks/use-video-autoplay.ts
@@ -0,0 +1,21 @@
+import { useMMKVBoolean } from 'react-native-mmkv';
+
+import { storage } from '@/lib/storage';
+
+const VIDEO_AUTOPLAY_KEY = 'settings.video_autoplay';
+
+/** Autoplay is on until the user explicitly turns it off. */
+export const VIDEO_AUTOPLAY_DEFAULT = true;
+
+/**
+ * Whether video pages start playing on open. MMKV-backed so the choice
+ * survives restarts.
+ */
+export function useVideoAutoplay() {
+ const [enabled, setEnabled] = useMMKVBoolean(VIDEO_AUTOPLAY_KEY, storage);
+
+ return {
+ autoplayEnabled: enabled ?? VIDEO_AUTOPLAY_DEFAULT,
+ setAutoplayEnabled: (value: boolean) => setEnabled(value),
+ };
+}
diff --git a/src/translations/en.json b/src/translations/en.json
index a395c8c..5a21a6a 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -20,6 +20,8 @@
"app_lock": "App Lock",
"app_name": "App Name",
"auto_lock_after": "Auto-Lock After",
+ "autoplay": "Autoplay",
+ "autoplay_desc": "Start playing automatically when a video page opens",
"bottom_tabs": "Bottom Tabs",
"bottom_tabs_desc": "Home and Settings are always visible.",
"cache_cleared": "Image cache cleared.",
@@ -73,6 +75,7 @@
"update_check_failed": "Update check failed",
"update_now": "Update now",
"version": "Version",
+ "video_playback": "Video playback",
"website": "Website",
"wifi_only": "WiFi Only Downloads"
},
diff --git a/src/translations/es.json b/src/translations/es.json
index 2d8c2a3..b8fdc7c 100644
--- a/src/translations/es.json
+++ b/src/translations/es.json
@@ -20,6 +20,8 @@
"app_lock": "Bloqueo de app",
"app_name": "Nombre de la app",
"auto_lock_after": "Bloqueo automático tras",
+ "autoplay": "Reproducción automática",
+ "autoplay_desc": "Reproducir automáticamente al abrir un vídeo",
"bottom_tabs": "Pestañas inferiores",
"bottom_tabs_desc": "Inicio y Ajustes siempre son visibles.",
"cache_cleared": "Caché de imágenes borrada.",
@@ -73,6 +75,7 @@
"update_check_failed": "Error al buscar actualizaciones",
"update_now": "Actualizar ahora",
"version": "Versión",
+ "video_playback": "Reproducción de vídeo",
"website": "Sitio web",
"wifi_only": "Descargas solo por Wi-Fi"
},
diff --git a/src/translations/ja.json b/src/translations/ja.json
index eaa8195..74fccb6 100644
--- a/src/translations/ja.json
+++ b/src/translations/ja.json
@@ -20,6 +20,8 @@
"app_lock": "アプリロック",
"app_name": "アプリ名",
"auto_lock_after": "自動ロックの時間",
+ "autoplay": "自動再生",
+ "autoplay_desc": "動画ページを開いたときに自動で再生する",
"bottom_tabs": "ボトムタブ",
"bottom_tabs_desc": "ホームと設定は常に表示されます。",
"cache_cleared": "画像キャッシュをクリアしました。",
@@ -73,6 +75,7 @@
"update_check_failed": "アップデートの確認に失敗しました",
"update_now": "今すぐ更新",
"version": "バージョン",
+ "video_playback": "動画再生",
"website": "ウェブサイト",
"wifi_only": "Wi-Fi のみダウンロード"
},
diff --git a/src/translations/ko.json b/src/translations/ko.json
index 183de2d..3d79d32 100644
--- a/src/translations/ko.json
+++ b/src/translations/ko.json
@@ -20,6 +20,8 @@
"app_lock": "앱 잠금",
"app_name": "앱 이름",
"auto_lock_after": "자동 잠금 시간",
+ "autoplay": "자동 재생",
+ "autoplay_desc": "동영상 페이지를 열면 자동으로 재생합니다",
"bottom_tabs": "하단 탭",
"bottom_tabs_desc": "홈과 설정은 항상 표시됩니다.",
"cache_cleared": "이미지 캐시를 삭제했습니다.",
@@ -73,6 +75,7 @@
"update_check_failed": "업데이트 확인에 실패했습니다",
"update_now": "지금 업데이트",
"version": "버전",
+ "video_playback": "동영상 재생",
"website": "웹사이트",
"wifi_only": "Wi-Fi 전용 다운로드"
},
diff --git a/src/translations/pt.json b/src/translations/pt.json
index a9f2f7b..4457d30 100644
--- a/src/translations/pt.json
+++ b/src/translations/pt.json
@@ -20,6 +20,8 @@
"app_lock": "Bloqueio do app",
"app_name": "Nome do app",
"auto_lock_after": "Bloqueio automático após",
+ "autoplay": "Reprodução automática",
+ "autoplay_desc": "Reproduzir automaticamente ao abrir um vídeo",
"bottom_tabs": "Abas inferiores",
"bottom_tabs_desc": "Início e Configurações estão sempre visíveis.",
"cache_cleared": "Cache de imagens limpa.",
@@ -73,6 +75,7 @@
"update_check_failed": "Falha ao verificar atualizações",
"update_now": "Atualizar agora",
"version": "Versão",
+ "video_playback": "Reprodução de vídeo",
"website": "Site",
"wifi_only": "Downloads somente no Wi-Fi"
},
diff --git a/src/translations/zh-TW.json b/src/translations/zh-TW.json
index ea34803..812c74b 100644
--- a/src/translations/zh-TW.json
+++ b/src/translations/zh-TW.json
@@ -20,6 +20,8 @@
"app_lock": "應用鎖",
"app_name": "應用名稱",
"auto_lock_after": "自動鎖定於",
+ "autoplay": "自動播放",
+ "autoplay_desc": "開啟影片頁時自動開始播放",
"bottom_tabs": "底部標籤",
"bottom_tabs_desc": "首頁和設定始終顯示。",
"cache_cleared": "圖片快取已清除。",
@@ -73,6 +75,7 @@
"update_check_failed": "檢查更新失敗",
"update_now": "立即更新",
"version": "版本",
+ "video_playback": "影片播放",
"website": "網站",
"wifi_only": "僅 Wi-Fi 下載"
},
diff --git a/src/translations/zh.json b/src/translations/zh.json
index 8d161f8..86a49c7 100644
--- a/src/translations/zh.json
+++ b/src/translations/zh.json
@@ -20,6 +20,8 @@
"app_lock": "应用锁",
"app_name": "应用名称",
"auto_lock_after": "自动锁定于",
+ "autoplay": "自动播放",
+ "autoplay_desc": "打开视频页时自动开始播放",
"bottom_tabs": "底部标签",
"bottom_tabs_desc": "首页和设置始终显示。",
"cache_cleared": "图片缓存已清除。",
@@ -73,6 +75,7 @@
"update_check_failed": "检查更新失败",
"update_now": "立即更新",
"version": "版本",
+ "video_playback": "视频播放",
"website": "网站",
"wifi_only": "仅 WiFi 下载"
},