From f2533f6c3d1276a05e80a9dc777404a96ff684d0 Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Sat, 5 Sep 2026 15:16:01 +0800 Subject: [PATCH] feat(settings): video autoplay toggle, on by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'Video playback' section in Settings with an autoplay switch (MMKV-backed, default on). Video pages start playing as soon as a source is available — including quality switches and a download taking over the player — and never override a manual pause. Turning the toggle off restores fully manual playback. --- src/app/(app)/settings.tsx | 33 +++++++++++++++++ src/app/post/[id].source.test.tsx | 28 +++++++++++++++ src/app/post/[id].test.tsx | 4 +++ src/app/post/[id].tsx | 13 +++++++ src/lib/hooks/use-video-autoplay.test.ts | 45 ++++++++++++++++++++++++ src/lib/hooks/use-video-autoplay.ts | 21 +++++++++++ src/translations/en.json | 3 ++ src/translations/es.json | 3 ++ src/translations/ja.json | 3 ++ src/translations/ko.json | 3 ++ src/translations/pt.json | 3 ++ src/translations/zh-TW.json | 3 ++ src/translations/zh.json | 3 ++ 13 files changed, 165 insertions(+) create mode 100644 src/lib/hooks/use-video-autoplay.test.ts create mode 100644 src/lib/hooks/use-video-autoplay.ts 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 下载" },