Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/app/(app)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -90,6 +91,7 @@ export default function Settings() {
dismissUpdateDialog,
openReleaseDownload,
} = useUpdateCheck(Env.VERSION);
const { autoplayEnabled, setAutoplayEnabled } = useVideoAutoplay();

useEffect(() => {
LocalAuthentication.hasHardwareAsync().then((has) => {
Expand Down Expand Up @@ -241,6 +243,37 @@ export default function Settings() {
/>
</View>

{/* Video playback */}
<View className="pt-2">
<Text
className="text-neutral-900 dark:text-neutral-100 pb-1 text-lg"
tx="settings.video_playback"
/>
<View className="rounded-md border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-800">
<View className="flex-row items-center justify-between px-4 py-3">
<View className="flex-1 pr-2">
<Text
className="text-neutral-900 dark:text-neutral-100"
tx="settings.autoplay"
/>
<Text
className="text-neutral-500 dark:text-neutral-400 mt-0.5 text-xs"
tx="settings.autoplay_desc"
/>
</View>
<TouchableOpacity
onPress={() => setAutoplayEnabled(!autoplayEnabled)}
testID="autoplay-toggle"
className={`h-6 w-11 rounded-full ${autoplayEnabled ? 'bg-primary-500' : 'bg-neutral-300 dark:bg-neutral-600'}`}
>
<View
className={`mt-0.5 h-5 w-5 rounded-full bg-white ${autoplayEnabled ? 'ml-5' : 'ml-0.5'}`}
/>
</TouchableOpacity>
</View>
</View>
</View>

{/* Content orientation (multi-select, applied via the flag1 URL param) */}
<View className="pt-2">
<View className="flex-row items-center justify-between">
Expand Down
28 changes: 28 additions & 0 deletions src/app/post/[id].source.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -122,6 +131,7 @@ const seedDownload = (quality: string) => {

beforeEach(() => {
jest.clearAllMocks();
mockAutoplay.enabled = true;
mockPlayer.playing = false;
mockDownloadState.existingFiles = new Set<string>();
mockDetailState.isError = false;
Expand Down Expand Up @@ -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(<Post />);

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(<Post />);

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;
Expand Down
4 changes: 4 additions & 0 deletions src/app/post/[id].test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions src/app/post/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
});
Expand All @@ -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
Expand Down
45 changes: 45 additions & 0 deletions src/lib/hooks/use-video-autoplay.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
21 changes: 21 additions & 0 deletions src/lib/hooks/use-video-autoplay.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
3 changes: 3 additions & 0 deletions src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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"
},
Expand Down
3 changes: 3 additions & 0 deletions src/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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"
},
Expand Down
3 changes: 3 additions & 0 deletions src/translations/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"app_lock": "アプリロック",
"app_name": "アプリ名",
"auto_lock_after": "自動ロックの時間",
"autoplay": "自動再生",
"autoplay_desc": "動画ページを開いたときに自動で再生する",
"bottom_tabs": "ボトムタブ",
"bottom_tabs_desc": "ホームと設定は常に表示されます。",
"cache_cleared": "画像キャッシュをクリアしました。",
Expand Down Expand Up @@ -73,6 +75,7 @@
"update_check_failed": "アップデートの確認に失敗しました",
"update_now": "今すぐ更新",
"version": "バージョン",
"video_playback": "動画再生",
"website": "ウェブサイト",
"wifi_only": "Wi-Fi のみダウンロード"
},
Expand Down
3 changes: 3 additions & 0 deletions src/translations/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"app_lock": "앱 잠금",
"app_name": "앱 이름",
"auto_lock_after": "자동 잠금 시간",
"autoplay": "자동 재생",
"autoplay_desc": "동영상 페이지를 열면 자동으로 재생합니다",
"bottom_tabs": "하단 탭",
"bottom_tabs_desc": "홈과 설정은 항상 표시됩니다.",
"cache_cleared": "이미지 캐시를 삭제했습니다.",
Expand Down Expand Up @@ -73,6 +75,7 @@
"update_check_failed": "업데이트 확인에 실패했습니다",
"update_now": "지금 업데이트",
"version": "버전",
"video_playback": "동영상 재생",
"website": "웹사이트",
"wifi_only": "Wi-Fi 전용 다운로드"
},
Expand Down
3 changes: 3 additions & 0 deletions src/translations/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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"
},
Expand Down
3 changes: 3 additions & 0 deletions src/translations/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"app_lock": "應用鎖",
"app_name": "應用名稱",
"auto_lock_after": "自動鎖定於",
"autoplay": "自動播放",
"autoplay_desc": "開啟影片頁時自動開始播放",
"bottom_tabs": "底部標籤",
"bottom_tabs_desc": "首頁和設定始終顯示。",
"cache_cleared": "圖片快取已清除。",
Expand Down Expand Up @@ -73,6 +75,7 @@
"update_check_failed": "檢查更新失敗",
"update_now": "立即更新",
"version": "版本",
"video_playback": "影片播放",
"website": "網站",
"wifi_only": "僅 Wi-Fi 下載"
},
Expand Down
3 changes: 3 additions & 0 deletions src/translations/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"app_lock": "应用锁",
"app_name": "应用名称",
"auto_lock_after": "自动锁定于",
"autoplay": "自动播放",
"autoplay_desc": "打开视频页时自动开始播放",
"bottom_tabs": "底部标签",
"bottom_tabs_desc": "首页和设置始终显示。",
"cache_cleared": "图片缓存已清除。",
Expand Down Expand Up @@ -73,6 +75,7 @@
"update_check_failed": "检查更新失败",
"update_now": "立即更新",
"version": "版本",
"video_playback": "视频播放",
"website": "网站",
"wifi_only": "仅 WiFi 下载"
},
Expand Down