diff --git a/src/app/post/[id].source.test.tsx b/src/app/post/[id].source.test.tsx new file mode 100644 index 0000000..7632235 --- /dev/null +++ b/src/app/post/[id].source.test.tsx @@ -0,0 +1,291 @@ +// Player-source selection tests for the post screen. The download hook is +// mocked with the same contract as the real one: `isDownloaded`/`fileUri` +// reflect whether `${videoId}.mp4` exists on disk for the composite id the +// component asks about. +const mockDownloadState = { + existingFiles: new Set(), +}; + +// Captured from the useFocusEffect mock so tests can drive the focus +// lifecycle manually (not used for source assertions, but the component +// registers the effect on mount). + +jest.mock('expo-router', () => ({ + Link: () => null, + Stack: { Screen: () => null }, + useLocalSearchParams: jest.fn().mockReturnValue({ id: 'v1', slug: 'my-video' }), + useFocusEffect: (_cb: () => (() => void) | undefined) => undefined, +})); + +const mockPlayer = { + playing: false, + loop: false, + play: jest.fn(), + pause: jest.fn(), +}; + +jest.mock('expo-video', () => ({ + VideoView: () => null, + useVideoPlayer: jest.fn((_source: string, setup?: (p: typeof mockPlayer) => void) => { + setup?.(mockPlayer); + return mockPlayer; + }), +})); + +jest.mock('expo-image', () => ({ Image: 'Image' })); + +jest.mock('react-native-safe-area-context', () => { + const React: typeof import('react') = jest.requireActual('react'); + return { + SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children ?? null, + SafeAreaView: ({ children }: { children?: React.ReactNode }) => children ?? null, + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), + }; +}); + +jest.mock('react-native-edge-to-edge', () => ({ + edgeToEdge: jest.fn(), + SystemBars: () => null, +})); + +jest.mock('expo', () => ({ + useEvent: (_player: unknown, _event: string, initial: { isPlaying: boolean }) => initial, +})); + +// Per-test network detail returned by useVideoDetail. +const mockDetailState = { + data: null as object | null, + isError: false, + error: null as (Error & { status?: number }) | null, +}; + +jest.mock('@/api/video-queries', () => ({ + useVideoDetail: () => ({ + data: mockDetailState.data, + isPending: false, + isError: mockDetailState.isError, + error: mockDetailState.error, + }), +})); + +jest.mock('react-native-flash-message', () => ({ + showMessage: jest.fn(), +})); + +jest.mock('@/lib/hooks', () => ({ + useVideoDownload: (props: { videoId: string }) => ({ + isDownloading: false, + downloadProgress: 0, + isDownloaded: mockDownloadState.existingFiles.has(props.videoId), + handleDownload: jest.fn(), + fileUri: `file:///documents/videos/${props.videoId}.mp4`, + error: null, + }), +})); + +import { resources } from '@/lib/i18n/resources'; +import { useDownloadedStore } from '@/lib/stores/downloaded-store'; +import { useFavoritesStore } from '@/lib/stores/favorites-store'; +import { useHistoryStore } from '@/lib/stores/history-store'; +import { act, cleanup, screen, setup, waitFor } from '@/lib/test-utils'; +import { useVideoPlayer } from 'expo-video'; +import { showMessage } from 'react-native-flash-message'; + +import Post from './[id]'; + +const LOCAL_480P = 'file:///documents/videos/v1_480p.mp4'; + +const NETWORK_FORMATS = [ + { quality: '360p', url: 'https://example.com/v1_360p.mp4' }, + { quality: '480p', url: 'https://example.com/v1_480p.mp4' }, + { quality: '720p', url: 'https://example.com/v1_720p.mp4' }, +]; + +const seedDownload = (quality: string) => { + useDownloadedStore.setState({ + entries: [ + { + videoId: `v1_${quality}`, + title: 'Test Video', + thumbnail: '', + uri: LOCAL_480P, + size: 1, + quality, + downloadedAt: 1, + slug: 'my-video', + }, + ], + downloadedBaseIds: new Set(['v1']), + loaded: true, + }); +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockPlayer.playing = false; + mockDownloadState.existingFiles = new Set(); + mockDetailState.isError = false; + mockDetailState.error = null; + useHistoryStore.setState({ history: [] }); + useFavoritesStore.setState({ favorites: [] }); + useDownloadedStore.setState({ entries: [], downloadedBaseIds: new Set(), loaded: true }); + mockDetailState.data = { + id: 'v1', + slug: 'my-video', + title: 'Test Video', + thumbnail: '', + formats: NETWORK_FORMATS, + tags: [], + categories: [], + }; +}); + +afterEach(cleanup); + +const lastPlayerSource = () => (useVideoPlayer as jest.Mock).mock.calls.at(-1)?.[0]; + +describe('Post screen — player source selection', () => { + it('plays the downloaded file even when the network default quality differs', async () => { + // Downloaded copy is 480p; the network default (and first paint without a + // download) would pick 720p. + mockDownloadState.existingFiles.add('v1_480p'); + seedDownload('480p'); + + setup(); + + await waitFor(() => expect(lastPlayerSource()).toBe(LOCAL_480P)); + }); + + it('switches to the local file when the download store hydrates after the network detail', async () => { + // Store starts empty (e.g. hydrate still in flight) so the player first + // streams the network 720p URL… + setup(); + await waitFor(() => expect(lastPlayerSource()).toBe('https://example.com/v1_720p.mp4')); + + // …then the downloaded entry appears and must take over the player. + mockDownloadState.existingFiles.add('v1_480p'); + act(() => { + seedDownload('480p'); + }); + + await waitFor(() => expect(lastPlayerSource()).toBe(LOCAL_480P)); + }); + + it('still plays the local file when the network detail resolves without formats (removed video)', async () => { + mockDownloadState.existingFiles.add('v1_360p'); + seedDownload('360p'); + mockDetailState.data = { + id: 'v1', + slug: 'my-video', + title: 'Test Video', + thumbnail: '', + formats: [], + tags: [], + categories: [], + }; + + setup(); + + await waitFor(() => expect(lastPlayerSource()).toBe('file:///documents/videos/v1_360p.mp4')); + }); + + it('keeps the local file across network refreshes — the download owns the default', async () => { + mockDownloadState.existingFiles.add('v1_480p'); + seedDownload('480p'); + + const { rerender } = setup(); + await waitFor(() => expect(lastPlayerSource()).toBe(LOCAL_480P)); + + // A later network refresh delivering new format objects must not move the + // player back to a network URL. + act(() => { + mockDetailState.data = { + ...(mockDetailState.data as object), + formats: NETWORK_FORMATS, + }; + }); + rerender(); + + expect(lastPlayerSource()).toBe(LOCAL_480P); + }); + + it('honors an explicit quality pick by switching to that network source', async () => { + mockDownloadState.existingFiles.add('v1_480p'); + seedDownload('480p'); + + const { user } = setup(); + await waitFor(() => expect(lastPlayerSource()).toBe(LOCAL_480P)); + + await user.press(screen.getByText('720p')); + + await waitFor(() => expect(lastPlayerSource()).toBe('https://example.com/v1_720p.mp4')); + }); +}); + +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; + const EN_UNREACHABLE = resources.en.translation.post.site_unreachable; + + const lastToastMessage = () => (showMessage as jest.Mock).mock.calls[0]?.[0]?.message; + + it('toasts "removed" for an HTTP 404 detail fetch', async () => { + mockDownloadState.existingFiles.add('v1_480p'); + seedDownload('480p'); + mockDetailState.isError = true; + mockDetailState.data = null; + const notFound = new Error( + 'Failed to fetch https://rule34video.com/video/v1/x/: 404' + ) as Error & { + status?: number; + }; + notFound.status = 404; + mockDetailState.error = notFound; + + setup(); + + await waitFor(() => expect(showMessage).toHaveBeenCalledTimes(1)); + expect(lastToastMessage()).toBe(EN_REMOVED); + const call = (showMessage as jest.Mock).mock.calls[0][0]; + expect(call.position).toBe('center'); // centered so it clears the status bar / cutout + }); + + it('toasts "unreachable" for a network error (offline / timeout / 5xx)', async () => { + mockDownloadState.existingFiles.add('v1_480p'); + seedDownload('480p'); + mockDetailState.isError = true; + mockDetailState.data = null; + mockDetailState.error = new Error('Network request failed'); // no status attached + + setup(); + + await waitFor(() => expect(showMessage).toHaveBeenCalledTimes(1)); + expect(lastToastMessage()).toBe(EN_UNREACHABLE); + }); + + it('toasts "removed" when the site serves a shell page (no formats)', async () => { + mockDownloadState.existingFiles.add('v1_480p'); + seedDownload('480p'); + mockDetailState.data = { + id: 'v1', + slug: 'my-video', + title: 'Test Video', + thumbnail: '', + formats: [], // shell page parses "successfully" but carries no video info + tags: [], + categories: [], + }; + + setup(); + + await waitFor(() => expect(showMessage).toHaveBeenCalledTimes(1)); + expect(lastToastMessage()).toBe(EN_REMOVED); + }); + + it('does not toast for a healthy page', async () => { + setup(); + + await waitFor(() => expect(lastPlayerSource()).toBe('https://example.com/v1_720p.mp4')); + expect(showMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/post/[id].tsx b/src/app/post/[id].tsx index d8ad730..f073146 100644 --- a/src/app/post/[id].tsx +++ b/src/app/post/[id].tsx @@ -3,10 +3,12 @@ import { Link, Stack, useFocusEffect, useLocalSearchParams } from 'expo-router'; import { VideoView, useVideoPlayer } from 'expo-video'; import * as React from 'react'; import { ScrollView, TouchableOpacity, View } from 'react-native'; +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 { useTranslate } from '@/lib/i18n'; import { toOfflineDetail } from '@/lib/r34/offline-detail'; import type { VideoDetail, VideoFormat } from '@/lib/r34/types'; import { baseIdOf, useDownloadedStore } from '@/lib/stores/downloaded-store'; @@ -20,14 +22,32 @@ type VideoSectionProps = { function VideoSection({ data }: VideoSectionProps): React.ReactElement { const [selectedQuality, setSelectedQuality] = React.useState(''); const [selectedFormat, setSelectedFormat] = React.useState(null); + // Set when the user taps a quality pill; after that the selection is theirs. + const manualPickRef = React.useRef(false); + + // Any downloaded copy of this video (regardless of quality). When present it + // owns the player source — a download must stay playable offline, so neither + // a successful network refresh nor a failed/deleted video page may move the + // player back to a network URL. + const downloaded = useDownloadedStore( + (s) => s.entries.find((e) => baseIdOf(e.videoId) === data.id) ?? null + ); React.useEffect(() => { - if (data.formats?.length && !selectedQuality) { + if (manualPickRef.current) return; + // The downloaded file wins even when the network detail offers other + // qualities (it defaults to 720p) or arrives after this page opened. + if (downloaded) { + setSelectedQuality(downloaded.quality); + setSelectedFormat({ url: downloaded.uri, quality: downloaded.quality, ext: 'mp4' }); + return; + } + if (data.formats?.length) { const defaultFormat = data.formats.find((f) => f.quality === '720p') || data.formats[0]; setSelectedQuality(defaultFormat.quality); setSelectedFormat(defaultFormat); } - }, [data.formats, selectedQuality]); + }, [downloaded, data.formats]); const { isDownloading, @@ -113,6 +133,7 @@ function VideoSection({ data }: VideoSectionProps): React.ReactElement { key={format.quality} variant={selectedQuality === format.quality ? 'default' : 'secondary'} onPress={() => { + manualPickRef.current = true; setSelectedQuality(format.quality); setSelectedFormat(format); }} @@ -181,12 +202,18 @@ function FavoriteButton({ data }: { data: VideoDetail }): React.ReactElement { export default function Post(): React.ReactElement | null { const local = useLocalSearchParams<{ id: string; slug: string }>(); + const t = useTranslate(); // Old download records didn't store slug; resolve it from history/favorites // so the online detail fetch can succeed and refresh the page. const historySlug = useHistoryStore((s) => s.history.find((h) => h.id === local.id)?.slug ?? ''); const favSlug = useFavoritesStore((s) => s.favorites.find((f) => f.id === local.id)?.slug ?? ''); const slug = local.slug || historySlug || favSlug || ''; - const { data: networkData, isPending } = useVideoDetail({ + const { + data: networkData, + isPending, + isError, + error, + } = useVideoDetail({ variables: { id: local.id, slug }, }); // Offline fallback: a downloaded copy lets the page render and play the @@ -197,6 +224,32 @@ export default function Post(): React.ReactElement | null { const data = networkData ?? (downloaded ? toOfflineDetail(downloaded, local.id) : undefined); const addHistory = useHistoryStore((s) => s.addHistory); + // Why the page info couldn't be refreshed, so the toast can tell the two + // apart. A removed video shows up two ways: an HTTP 404/410, or a 200 + // "shell" page (title only, no formats) that parses successfully. Anything + // else — offline, timeout, 5xx — means we simply couldn't reach the site; + // the video itself may be fine. Playback is unaffected either way: a + // download (if any) still plays from disk. + const failureKind = React.useMemo<'removed' | 'unreachable' | null>(() => { + if (networkData && networkData.formats.length === 0) return 'removed'; + if (isError) { + const status = (error as (Error & { status?: number }) | null)?.status; + return status === 404 || status === 410 ? 'removed' : 'unreachable'; + } + return null; + }, [networkData, isError, error]); + + React.useEffect(() => { + if (!failureKind) return; + showMessage({ + message: t(failureKind === 'removed' ? 'post.video_removed' : 'post.site_unreachable'), + type: 'warning', + // Centered: top toasts sit under the status bar / cutout on some devices. + position: 'center', + duration: 2500, + }); + }, [failureKind, t]); + // Record this video in watch history once its metadata is available. React.useEffect(() => { if (data) { diff --git a/src/lib/r34/scraper.ts b/src/lib/r34/scraper.ts index 334138d..21adbff 100644 --- a/src/lib/r34/scraper.ts +++ b/src/lib/r34/scraper.ts @@ -19,7 +19,13 @@ export async function fetchPage(url: string): Promise { }); if (!response.ok) { - throw new Error(`Failed to fetch ${url}: ${response.status}`); + // Attach the status so callers can tell "page gone" (404/410) apart + // from reachability problems (offline, timeout, 5xx, …). + const error = new Error(`Failed to fetch ${url}: ${response.status}`) as Error & { + status?: number; + }; + error.status = response.status; + throw error; } return await response.text(); diff --git a/src/translations/en.json b/src/translations/en.json index 73852c8..e511622 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -95,5 +95,9 @@ "unfollow": "Unfollow", "follow": "Follow" }, + "post": { + "video_removed": "This video has been removed from the site", + "site_unreachable": "Couldn't reach the site — check your connection" + }, "welcome": "Welcome to obytes app site" } diff --git a/src/translations/es.json b/src/translations/es.json index acf816c..de4ac71 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -95,5 +95,9 @@ "unfollow": "Dejar de seguir", "follow": "Seguir" }, + "post": { + "video_removed": "Este vídeo ha sido eliminado del sitio", + "site_unreachable": "No se puede acceder al sitio; comprueba tu conexión" + }, "welcome": "Bienvenido a la aplicación obytes" } diff --git a/src/translations/ja.json b/src/translations/ja.json index d3129fb..3f801f9 100644 --- a/src/translations/ja.json +++ b/src/translations/ja.json @@ -95,5 +95,9 @@ "unfollow": "フォロー解除", "follow": "フォロー" }, + "post": { + "video_removed": "この動画はサイトから削除されました", + "site_unreachable": "サイトに接続できません。ネットワークを確認してください" + }, "welcome": "obytes アプリへようこそ" } diff --git a/src/translations/ko.json b/src/translations/ko.json index 9790fae..936f509 100644 --- a/src/translations/ko.json +++ b/src/translations/ko.json @@ -95,5 +95,9 @@ "unfollow": "팔로우 취소", "follow": "팔로우" }, + "post": { + "video_removed": "이 영상은 사이트에서 삭제되었습니다", + "site_unreachable": "사이트에 연결할 수 없습니다. 네트워크를 확인하세요" + }, "welcome": "obytes 앱에 오신 것을 환영합니다" } diff --git a/src/translations/pt.json b/src/translations/pt.json index d6f221d..11e1b96 100644 --- a/src/translations/pt.json +++ b/src/translations/pt.json @@ -95,5 +95,9 @@ "unfollow": "Deixar de seguir", "follow": "Seguir" }, + "post": { + "video_removed": "Este vídeo foi removido do site", + "site_unreachable": "Não foi possível acessar o site; verifique sua conexão" + }, "welcome": "Bem-vindo ao aplicativo obytes" } diff --git a/src/translations/zh-TW.json b/src/translations/zh-TW.json index 6d49934..c34b73d 100644 --- a/src/translations/zh-TW.json +++ b/src/translations/zh-TW.json @@ -95,5 +95,9 @@ "unfollow": "取消追蹤", "follow": "追蹤" }, + "post": { + "video_removed": "目前影片已被網站刪除", + "site_unreachable": "無法存取網站,請檢查網路" + }, "welcome": "歡迎使用 obytes 應用" } diff --git a/src/translations/zh.json b/src/translations/zh.json index bf4804c..dd0d7dc 100644 --- a/src/translations/zh.json +++ b/src/translations/zh.json @@ -95,5 +95,9 @@ "unfollow": "取消关注", "follow": "关注" }, + "post": { + "video_removed": "当前视频已被网站删除", + "site_unreachable": "无法访问网站,请检查网络" + }, "welcome": "欢迎使用 obytes 应用" }