Handle missing video files in player

This commit is contained in:
Zoe Roux 2026-04-04 10:51:56 +02:00
parent 066e50f8b5
commit c8c23a8f65
No known key found for this signature in database
5 changed files with 79 additions and 13 deletions

View File

@ -49,7 +49,7 @@
"part": "Part {{number}}",
"videos-map": "Edit video mappings",
"remap": "Remap",
"staff-as":"as {{character}}",
"staff-as": "as {{character}}",
"staff-kind": {
"actor": "Actor",
"director": "Director",
@ -253,7 +253,9 @@
"transmux": "Original",
"auto": "Auto",
"notInPristine": "Unavailable in pristine",
"unsupportedError": "Video codec not supported, transcoding in progress..."
"unsupportedError": "Video codec not supported, transcoding in progress...",
"not-available": "{{entry}} is not available on kyoo yet, ask your server admins about it",
"fatal": "Fatal playback error"
},
"search": {
"empty": "No result found. Try a different query."

View File

@ -49,8 +49,14 @@ export const FullVideo = Video.extend({
playedDate: zdate().nullable(),
videoId: z.string().nullable(),
}),
previous: z.object({ video: z.string(), entry: Entry }).nullable().optional(),
next: z.object({ video: z.string(), entry: Entry }).nullable().optional(),
previous: z
.object({ video: z.string().nullable(), entry: Entry })
.nullable()
.optional(),
next: z
.object({ video: z.string().nullable(), entry: Entry })
.nullable()
.optional(),
show: Show.optional().nullable(),
});
export type FullVideo = z.infer<typeof FullVideo>;

View File

@ -0,0 +1,29 @@
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Heading, IconButton, P } from "~/primitives";
import { cn } from "~/utils";
export const ErrorPopup = ({
message,
dismiss,
}: {
message: string;
dismiss: () => void;
}) => {
const { t } = useTranslation();
return (
<View
className={cn(
"absolute inset-x-6 top-1/2 flex-1 -translate-y-1/2 flex-row justify-between",
"rounded-xl border border-slate-700 bg-background p-5",
)}
>
<View className="flex-1 flex-wrap">
<Heading className="my-2">{t("player.fatal")}</Heading>
<P className="mt-2 flex-1">{message}</P>
</View>
<IconButton icon={Close} onPress={dismiss} />
</View>
);
};

View File

@ -20,6 +20,7 @@ export const Controls = ({
chapters,
playPrev,
playNext,
forceShow,
}: {
player: VideoPlayer;
showHref?: string;
@ -31,6 +32,7 @@ export const Controls = ({
chapters: Chapter[];
playPrev: (() => boolean) | null;
playNext: (() => boolean) | null;
forceShow?: boolean;
}) => {
const isTouch = useIsTouch();
@ -56,7 +58,7 @@ export const Controls = ({
<View className="absolute inset-0">
<TouchControls
player={player}
forceShow={hover || menuOpened}
forceShow={hover || menuOpened || forceShow}
className="absolute inset-0"
>
<Back

View File

@ -2,6 +2,7 @@ import "react-native-get-random-values";
import { Stack, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, View } from "react-native";
import { useEvent, useVideoPlayer, VideoView } from "react-native-video";
import { v4 as uuidv4 } from "uuid";
@ -14,6 +15,7 @@ import { type QueryIdentifier, useFetch } from "~/query";
import { Info } from "~/ui/info";
import { useQueryState } from "~/utils";
import { Controls, LoadingIndicator } from "./controls";
import { ErrorPopup } from "./controls/error-popup";
import { toggleFullscreen } from "./controls/misc";
import { PlayModeContext } from "./controls/tracks-menu";
import { useKeyboard } from "./keyboard";
@ -45,6 +47,7 @@ export const Player = () => {
);
const playModeState = useState(defaultPlayMode);
const [playMode, setPlayMode] = playModeState;
const [playbackError, setPlaybackError] = useState<KyooError | undefined>();
const player = useVideoPlayer(
{
uri: `${apiUrl}/api/videos/${slug}/${playMode === "direct" ? "direct" : "master.m3u8"}?clientId=${clientId}`,
@ -101,18 +104,39 @@ export const Player = () => {
}, [player, info?.fonts]);
const router = useRouter();
const { t } = useTranslation();
const playPrev = useCallback(() => {
if (!data?.previous) return false;
if (!data.previous.video) {
setPlaybackError({
status: "not-available",
message: t("player.not-available", {
entry: `${entryDisplayNumber(data.previous.entry)} ${data.previous.entry.name}`,
}),
});
return true;
}
setPlaybackError(undefined);
setStart("0");
setSlug(data.previous.video);
return true;
}, [data?.previous, setSlug, setStart]);
}, [data?.previous, setSlug, setStart, t]);
const playNext = useCallback(() => {
if (!data?.next) return false;
if (!data.next.video) {
setPlaybackError({
status: "not-available",
message: t("player.not-available", {
entry: `${entryDisplayNumber(data.next.entry)} ${data.next.entry.name}`,
}),
});
return true;
}
setPlaybackError(undefined);
setStart("0");
setSlug(data.next.video);
return true;
}, [data?.next, setSlug, setStart]);
}, [data?.next, setSlug, setStart, t]);
useProgressObserver(
player,
@ -148,7 +172,6 @@ export const Player = () => {
};
}, []);
const [playbackError, setPlaybackError] = useState<KyooError | undefined>();
useEvent(player, "onError", (error) => {
if (
error.code === "source/unsupported-content-type" &&
@ -157,9 +180,6 @@ export const Player = () => {
setPlayMode("hls");
else setPlaybackError({ status: error.code, message: error.message });
});
if (playbackError) {
throw playbackError;
}
return (
<View className="flex-1 bg-black">
@ -201,10 +221,17 @@ export const Player = () => {
: data?.path
}
chapters={info?.chapters ?? []}
playPrev={data?.previous?.video ? playPrev : null}
playNext={data?.next?.video ? playNext : null}
playPrev={data?.previous ? playPrev : null}
playNext={data?.next ? playNext : null}
forceShow={!!playbackError}
/>
</PlayModeContext.Provider>
{playbackError && (
<ErrorPopup
message={playbackError.message}
dismiss={() => setPlaybackError(undefined)}
/>
)}
</View>
);
};