diff --git a/mobile/src/navigation.tsx b/mobile/src/navigation.tsx index 2613853..9604752 100644 --- a/mobile/src/navigation.tsx +++ b/mobile/src/navigation.tsx @@ -57,6 +57,11 @@ export type RootStackParamList = { // ma /movies/{id}/progress oddzielnie od /scenes/{id}/progress (2026-05-28). // Default 'scene' dla back-compat z istniejącymi nav callami. entityKind?: 'scene' | 'movie'; + // playback_source.id — pozwala Playerowi oznaczyć źródło jako dead bez powrotu + // do SceneDetail (gdy CDN zwróci 404/410 przy odtwarzaniu — sxyprn/fpo „404 + // którego apka nie potrafi zidentyfikować”, bug a78cc3b6). Undefined dla + // legacy/non-tube nav callów → przycisk „Mark broken” się nie pokazuje. + playbackId?: string; durationSec?: number | null; refererHost?: string; title?: string; diff --git a/mobile/src/screens/MovieDetailScreen.tsx b/mobile/src/screens/MovieDetailScreen.tsx index b5e5cbd..8b5ed04 100644 --- a/mobile/src/screens/MovieDetailScreen.tsx +++ b/mobile/src/screens/MovieDetailScreen.tsx @@ -208,6 +208,7 @@ function WatchChip({ navigation.navigate('Player', { url: p.stream_url || p.embed_url || pb.page_url, sceneId: movieId, + playbackId: pb.id, entityKind: 'movie', durationSec: pb.duration_sec ?? null, title: `${title} — ${(p.raw as any).part_label ?? p.quality}`, @@ -231,6 +232,7 @@ function WatchChip({ // Bug-report b207ff17 2026-05-26 ("oznaczenie obejrzanych filmów") — backend // dostał movie_play_progress 2026-05-28. sceneId: movieId, + playbackId: pb.id, entityKind: 'movie', durationSec: pb.duration_sec ?? null, title, diff --git a/mobile/src/screens/PlayerScreen.tsx b/mobile/src/screens/PlayerScreen.tsx index 2355b6e..2f305a9 100644 --- a/mobile/src/screens/PlayerScreen.tsx +++ b/mobile/src/screens/PlayerScreen.tsx @@ -1,6 +1,7 @@ import type { RouteProp } from '@react-navigation/native'; import { useNavigation, useRoute } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useQueryClient } from '@tanstack/react-query'; import { useEvent } from 'expo'; import * as Haptics from 'expo-haptics'; import * as ScreenOrientation from 'expo-screen-orientation'; @@ -31,6 +32,9 @@ interface RouteParams { // progress zwracał 404 (silently caught). Po dodaniu /movies/{id}/progress // (2026-05-28) mamy proper routing. entityKind?: 'scene' | 'movie'; + // playback_source.id — gdy podane, ekran błędu (native 404/410 lub WebView + // onHttpError) pokazuje „Mark broken” który oznacza źródło dead i wraca. + playbackId?: string; durationSec?: number | null; refererHost?: string; title?: string; @@ -38,6 +42,48 @@ interface RouteParams { fallbackEmbedUrl?: string; } +// Wspólny helper dla native + WebView ekranu błędu: oznacza playback source jako +// dead (scene lub movie) i wraca do detalu. Bug a78cc3b6: gdy hoster zwraca 404 +// przy odtwarzaniu (sxyprn .vid CDN gone, fpo video usunięte), user nie miał jak +// tego zidentyfikować/zgłosić z Playera — tylko „Back”. Teraz: jeden tap → dead. +function useMarkSourceBroken(params: RouteParams) { + const client = useClient(); + const queryClient = useQueryClient(); + const nav = useNavigation>(); + const [busy, setBusy] = React.useState(false); + const canMark = !!params.playbackId; + const markBroken = React.useCallback(async () => { + if (!params.playbackId) { + nav.goBack(); + return; + } + setBusy(true); + try { + if (params.entityKind === 'movie') { + await client.markMoviePlaybackDead(params.sceneId, params.playbackId); + queryClient.invalidateQueries({ queryKey: ['movie', params.sceneId] }); + } else { + await client.markPlaybackDead(params.sceneId, params.playbackId); + queryClient.invalidateQueries({ queryKey: ['scene', params.sceneId] }); + } + queryClient.invalidateQueries({ queryKey: ['scenes'] }); + } catch { + // best-effort — i tak wracamy; źródło zostanie do następnej próby + } finally { + setBusy(false); + nav.goBack(); + } + }, [client, queryClient, nav, params.playbackId, params.entityKind, params.sceneId]); + return { markBroken, canMark, busy }; +} + +// Czy komunikat błędu ExoPlayera wskazuje na trwale usunięte źródło (404/410)? +// 403 NIE liczy się (auth/IP-bound → fallback proxy/WebView może uratować). +function isGoneError(msg: string | undefined | null): boolean { + if (!msg) return false; + return /\b(404|410)\b|response code:\s*4(0[14])|not found|gone/i.test(msg); +} + const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'; @@ -86,6 +132,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { const client = useClient(); const nav = useNavigation>(); const { url, sceneId, entityKind, durationSec, refererHost, title, fallbackEmbedUrl, headers: paramHeaders, fallbackProxyUrl } = params; + const { markBroken, canMark, busy: markBusy } = useMarkSourceBroken(params); // 'movie' → /movies/{id}/progress, 'scene' (default) → /scenes/{id}/progress. const upsertProgress = React.useCallback( (body: { position_sec: number; duration_sec?: number; finished?: boolean }) => @@ -604,13 +651,24 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { )} {status === 'error' && !fallbackEmbedUrl && ( - Playback failed - - {playerError?.message ?? 'The stream did not start.'} + + {isGoneError(playerError?.message) ? 'Source no longer available' : 'Playback failed'} - nav.goBack()}> - Back - + + {isGoneError(playerError?.message) + ? 'The host returned 404/410 — this video was removed.' + : (playerError?.message ?? 'The stream did not start.')} + + + {canMark && ( + + {markBusy ? 'Marking…' : 'Mark broken'} + + )} + nav.goBack()}> + Back + + )} {status === 'error' && fallbackEmbedUrl && ( @@ -1006,6 +1064,30 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) { return () => clearTimeout(t); }, [extractedUrl]); + const { markBroken, canMark, busy: markBusy } = useMarkSourceBroken(params); + // WebView main-document 404/410 → strona hostera usunięta (fpo/sxyprn „404 + // którego apka nie potrafi zidentyfikować”, bug a78cc3b6). Pokazujemy overlay + // z opcją oznaczenia źródła jako broken zamiast surowej 404-strony hostera. + const [httpDead, setHttpDead] = React.useState(null); + const onHttpError = React.useCallback( + (e: { nativeEvent?: { statusCode?: number; url?: string } }) => { + const ne = e?.nativeEvent; + const code = ne?.statusCode; + if (code !== 404 && code !== 410) return; + // Tylko główny dokument (ten sam host + ścieżka co załadowany URL) — nie + // false-trigger na 404 ad-subresource/thumbnail z tego samego hosta. + try { + const a = new URL(ne!.url || ''); + const b = new URL(url); + if (a.hostname !== b.hostname || a.pathname !== b.pathname) return; + } catch { + if (ne?.url !== url) return; + } + setHttpDead(code); + }, + [url], + ); + // Stage 0.8: mobile-side hoster resolvery. Mobile IP usera unika Cloudflare // Turnstile / CAPTCHA gate który blokuje Hetzner VPS — embed page renderuje // pełny HTML z player config, z którego liczymy direct m3u8/mp4 URL. @@ -1207,6 +1289,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) { injectedJavaScriptBeforeContentLoaded={INJECTED_JS} onMessage={onMessage} onShouldStartLoadWithRequest={onShouldStartLoad} + onHttpError={onHttpError} setSupportMultipleWindows={false} allowsInlineMediaPlayback mediaPlaybackRequiresUserAction={false} @@ -1223,12 +1306,33 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) { )} /> - {!extractedUrl && !revealEmbed && ( + {!extractedUrl && !revealEmbed && !httpDead && ( Loading video… )} + {httpDead && ( + + Source no longer available + + The host returned {httpDead} — this video was removed. + + + {canMark && ( + + {markBusy ? 'Marking…' : 'Mark broken'} + + )} + setHttpDead(null)}> + Try anyway + + nav.goBack()}> + Back + + + + )} {extractedUrl && (