mobile: let players identify & report 404/410 sources
When a host returns 404/410 at playback time (CDN gone, video removed) the player previously showed only a raw error and a Back button — the user could not tell it was a dead source or report it without going back to the detail screen (bug a78cc3b6: "fpo i sxyprn to 404, którego apka nie potrafi zidentyfikować"). - Thread playback_source.id into Player route params (scenes + movies). - Native player error overlay: detect 404/410 in the ExoPlayer error, show "Source no longer available" and a "Mark broken" button that marks the source dead and returns. 403 is excluded (proxy/WebView fallback may save it). - WebView player: add onHttpError; on a main-document 404/410 show the same overlay (Mark broken / Try anyway / Back) instead of the host's 404 page. Guarded to the loaded document (host+path) so same-host ad/subresource 404s don't false-trigger. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
86c9bd438b
commit
967123d5d6
4 changed files with 123 additions and 7 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<NativeStackNavigationProp<RootStackParamList, 'Player'>>();
|
||||
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<NativeStackNavigationProp<RootStackParamList, 'Player'>>();
|
||||
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 && (
|
||||
<View style={styles.overlay}>
|
||||
<Text style={styles.errorTitle}>Playback failed</Text>
|
||||
<Text style={styles.errorBody}>
|
||||
{playerError?.message ?? 'The stream did not start.'}
|
||||
<Text style={styles.errorTitle}>
|
||||
{isGoneError(playerError?.message) ? 'Source no longer available' : 'Playback failed'}
|
||||
</Text>
|
||||
<Pressable style={styles.btn} onPress={() => nav.goBack()}>
|
||||
<Text style={styles.btnText}>Back</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.errorBody}>
|
||||
{isGoneError(playerError?.message)
|
||||
? 'The host returned 404/410 — this video was removed.'
|
||||
: (playerError?.message ?? 'The stream did not start.')}
|
||||
</Text>
|
||||
<View style={styles.errorBtnRow}>
|
||||
{canMark && (
|
||||
<Pressable style={[styles.btn, styles.btnDanger]} onPress={markBroken} disabled={markBusy}>
|
||||
<Text style={styles.btnText}>{markBusy ? 'Marking…' : 'Mark broken'}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable style={styles.btn} onPress={() => nav.goBack()}>
|
||||
<Text style={styles.btnText}>Back</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{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<number | null>(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 }) {
|
|||
</View>
|
||||
)}
|
||||
/>
|
||||
{!extractedUrl && !revealEmbed && (
|
||||
{!extractedUrl && !revealEmbed && !httpDead && (
|
||||
<View style={styles.coverOverlay}>
|
||||
<ActivityIndicator color={theme.fg} size="large" />
|
||||
<Text style={styles.overlayText}>Loading video…</Text>
|
||||
</View>
|
||||
)}
|
||||
{httpDead && (
|
||||
<View style={[styles.coverOverlay, styles.overlay]}>
|
||||
<Text style={styles.errorTitle}>Source no longer available</Text>
|
||||
<Text style={styles.errorBody}>
|
||||
The host returned {httpDead} — this video was removed.
|
||||
</Text>
|
||||
<View style={styles.errorBtnRow}>
|
||||
{canMark && (
|
||||
<Pressable style={[styles.btn, styles.btnDanger]} onPress={markBroken} disabled={markBusy}>
|
||||
<Text style={styles.btnText}>{markBusy ? 'Marking…' : 'Mark broken'}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable style={styles.btn} onPress={() => setHttpDead(null)}>
|
||||
<Text style={styles.btnText}>Try anyway</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.btn} onPress={() => nav.goBack()}>
|
||||
<Text style={styles.btnText}>Back</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{extractedUrl && (
|
||||
<Pressable
|
||||
style={styles.extractBanner}
|
||||
|
|
@ -1288,6 +1392,8 @@ const styles = StyleSheet.create({
|
|||
borderRadius: 10,
|
||||
},
|
||||
btnText: { color: theme.fg, fontWeight: '700' },
|
||||
errorBtnRow: { flexDirection: 'row', gap: 12 },
|
||||
btnDanger: { backgroundColor: theme.bad },
|
||||
extractBanner: {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
|
|
|
|||
|
|
@ -479,6 +479,7 @@ function PlaybackButton({
|
|||
nav.navigate('Player', {
|
||||
url: initialUrl,
|
||||
sceneId,
|
||||
playbackId: source.id,
|
||||
durationSec: sceneDurationSec,
|
||||
refererHost,
|
||||
title: source.origin,
|
||||
|
|
@ -543,6 +544,7 @@ function PlaybackButton({
|
|||
nav.navigate('Player', {
|
||||
url: target,
|
||||
sceneId,
|
||||
playbackId: source.id,
|
||||
durationSec: sceneDurationSec,
|
||||
refererHost,
|
||||
title: source.origin,
|
||||
|
|
@ -618,6 +620,7 @@ function PlaybackButton({
|
|||
nav.navigate('Player', {
|
||||
url: link.embed_url,
|
||||
sceneId,
|
||||
playbackId: source.id,
|
||||
durationSec: sceneDurationSec,
|
||||
refererHost,
|
||||
title: source.origin,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue