From 0e2ce0605edd9191a9518bf8eeb052e608cceaa1 Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Tue, 1 Sep 2026 17:27:58 +0800 Subject: [PATCH 1/2] fix(post): pin the player to the downloaded file over network sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A downloaded video must stay playable offline. Previously the player's default quality came from the page's network formats (preferring 720p), so a download in any other quality — or a download whose store entry hydrated after the network detail — silently streamed from the network again, and a failed/removed video page could leave the screen without any playable source. The video section now subscribes to the downloaded entry for the video and, unless the user explicitly taps another quality pill, pins the selection to the local file. Network refreshes never override it, and a detail response without formats (removed video) still plays the local copy. --- src/app/post/[id].source.test.tsx | 208 ++++++++++++++++++++++++++++++ src/app/post/[id].tsx | 23 +++- 2 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 src/app/post/[id].source.test.tsx diff --git a/src/app/post/[id].source.test.tsx b/src/app/post/[id].source.test.tsx new file mode 100644 index 0000000..2720ab4 --- /dev/null +++ b/src/app/post/[id].source.test.tsx @@ -0,0 +1,208 @@ +// 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, +}; + +jest.mock('@/api/video-queries', () => ({ + useVideoDetail: () => ({ data: mockDetailState.data, isPending: false }), +})); + +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 { 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 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(); + 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')); + }); +}); diff --git a/src/app/post/[id].tsx b/src/app/post/[id].tsx index d8ad730..b3c67de 100644 --- a/src/app/post/[id].tsx +++ b/src/app/post/[id].tsx @@ -20,14 +20,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 +131,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); }} From 5fdda3fa024fa4ac73384d17ad1c146fe31aebbe Mon Sep 17 00:00:00 2001 From: ghostcoder42 Date: Thu, 3 Sep 2026 13:23:41 +0800 Subject: [PATCH 2/2] feat(post): toast when the video page can no longer be fetched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a video whose page is gone previously failed silently: a downloaded copy kept playing from disk but nothing explained the missing metadata, and a shell response (title-only, no formats) looked like a successful load. The toast distinguishes the two failure families: - 'post.video_removed' when the video is gone from the site — an HTTP 404/410 (fetchPage now attaches the status to its error), or a 200 shell page that parses but carries no formats; - 'post.site_unreachable' for everything else — offline, timeout, 5xx — where the video may be fine and only the site was unreachable. Both messages were added to all seven locales. Playback is unchanged: downloads keep playing locally. --- src/app/post/[id].source.test.tsx | 85 ++++++++++++++++++++++++++++++- src/app/post/[id].tsx | 36 ++++++++++++- src/lib/r34/scraper.ts | 8 ++- src/translations/en.json | 4 ++ src/translations/es.json | 4 ++ src/translations/ja.json | 4 ++ src/translations/ko.json | 4 ++ src/translations/pt.json | 4 ++ src/translations/zh-TW.json | 4 ++ src/translations/zh.json | 4 ++ 10 files changed, 154 insertions(+), 3 deletions(-) diff --git a/src/app/post/[id].source.test.tsx b/src/app/post/[id].source.test.tsx index 2720ab4..7632235 100644 --- a/src/app/post/[id].source.test.tsx +++ b/src/app/post/[id].source.test.tsx @@ -55,10 +55,21 @@ jest.mock('expo', () => ({ // 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 }), + 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', () => ({ @@ -72,11 +83,13 @@ jest.mock('@/lib/hooks', () => ({ }), })); +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]'; @@ -111,6 +124,8 @@ 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 }); @@ -206,3 +221,71 @@ describe('Post screen — player source selection', () => { 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 b3c67de..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'; @@ -200,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 @@ -216,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 应用" }