Add settings for auto-skip

This commit is contained in:
Zoe Roux
2026-04-17 23:44:21 +02:00
parent 90f2d5b190
commit bf2c5efafd
11 changed files with 165 additions and 16 deletions
+28
View File
@@ -1,5 +1,14 @@
import { z } from "zod/v4";
const ChapterSkipBehavior = z
.enum([
"autoSkip",
"autoSkipExceptFirstAppearance",
"showSkipButton",
"disabled",
])
.catch("showSkipButton");
export const User = z
.object({
id: z.string(),
@@ -28,11 +37,30 @@ export const User = z
.catch("original"),
audioLanguage: z.string().catch("default"),
subtitleLanguage: z.string().nullable().catch(null),
chapterSkip: z
.object({
recap: ChapterSkipBehavior,
intro: ChapterSkipBehavior,
credits: ChapterSkipBehavior,
preview: ChapterSkipBehavior,
})
.catch({
recap: "showSkipButton",
intro: "showSkipButton",
credits: "showSkipButton",
preview: "showSkipButton",
}),
})
.default({
downloadQuality: "original",
audioLanguage: "default",
subtitleLanguage: null,
chapterSkip: {
recap: "showSkipButton",
intro: "showSkipButton",
credits: "showSkipButton",
preview: "showSkipButton",
},
}),
}),
oidc: z
+2
View File
@@ -65,6 +65,8 @@ export const Chapter = z.object({
endTime: z.number(),
name: z.string(),
type: z.enum(["content", "recap", "intro", "credits", "preview"]),
firstAppearance: z.boolean().optional(),
matchAccuracy: z.number().optional(),
});
export type Chapter = z.infer<typeof Chapter>;
+39 -9
View File
@@ -1,8 +1,9 @@
import { useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEvent, type VideoPlayer } from "react-native-video";
import type { Chapter } from "~/models";
import { Button } from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { useFetch } from "~/query";
import { Info } from "~/ui/info";
import { cn, useQueryState } from "~/utils";
@@ -19,8 +20,10 @@ export const SkipChapterButton = ({
isVisible: boolean;
}) => {
const { t } = useTranslation();
const account = useAccount();
const [slug] = useQueryState<string>("slug", undefined!);
const { data } = useFetch(Info.infoQuery(slug));
const lastAutoSkippedChapter = useRef<number | null>(null);
const [progress, setProgress] = useState(player.currentTime || 0);
useEvent(player, "onProgress", ({ currentTime }) => {
@@ -31,23 +34,50 @@ export const SkipChapterButton = ({
(chapter) => chapter.startTime <= progress && progress < chapter.endTime,
);
if (!chapter || chapter.type === "content") return null;
const behavior =
(chapter &&
chapter.type !== "content" &&
account?.claims.settings.chapterSkip[chapter.type]) ||
"showSkipButton";
const shouldAutoSkip =
behavior === "autoSkip" ||
(behavior === "autoSkipExceptFirstAppearance" && !chapter!.firstAppearance);
// delay credits appearance by a few seconds, we want to make sure it doesn't
// show on top of the end of the serie. it's common for the end credits music
// to start playing on top of the episode also.
const start = chapter.startTime + +(chapter.type === "credits") * 4;
const start = chapter
? chapter.startTime + +(chapter.type === "credits") * 4
: Infinity;
const skipChapter = useCallback(() => {
if (!chapter) return;
if (data?.durationSeconds && data.durationSeconds <= chapter.endTime + 3) {
return seekEnd();
}
player.seekTo(chapter.endTime);
}, [player, chapter, data?.durationSeconds, seekEnd]);
useEffect(() => {
if (
chapter &&
shouldAutoSkip &&
progress >= start &&
lastAutoSkippedChapter.current !== chapter.startTime
) {
lastAutoSkippedChapter.current = chapter.startTime;
skipChapter();
}
}, [chapter, progress, shouldAutoSkip, start, skipChapter]);
if (!chapter || chapter.type === "content" || behavior === "disabled")
return null;
if (!isVisible && progress >= start + 8) return null;
return (
<Button
text={t(`player.chapters.skip`, { type: chapter.type })}
onPress={() => {
if (data?.durationSeconds && data.durationSeconds <= chapter.endTime) {
return seekEnd();
}
player.seekTo(chapter.endTime);
}}
onPress={skipChapter}
className={cn(
"absolute right-safe bottom-2/10 m-8",
"z-20 bg-slate-900/70 px-4 py-2",
+1 -1
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useEvent, type VideoPlayer } from "react-native-video";
import type { Chapter } from "~/models";
@@ -7,7 +8,6 @@ import { useToken } from "~/providers/account-context";
import { type QueryIdentifier, useFetch } from "~/query";
import { useQueryState } from "~/utils";
import { toTimerString } from "./controls/progress";
import { useTranslation } from "react-i18next";
type Thumb = {
from: number;
+2 -1
View File
@@ -3,7 +3,7 @@ import { useAccount } from "~/providers/account-context";
import { AccountSettings } from "./account";
import { About, GeneralSettings } from "./general";
import { OidcSettings } from "./oidc";
import { PlaybackSettings } from "./playback";
import { ChapterSkipSettings, PlaybackSettings } from "./playback";
import { SessionsSettings } from "./sessions";
export const SettingsPage = () => {
@@ -12,6 +12,7 @@ export const SettingsPage = () => {
<ScrollView contentContainerClassName="gap-8 pb-8">
<GeneralSettings />
{account && <PlaybackSettings />}
{account && <ChapterSkipSettings />}
{account && <AccountSettings />}
{account && <SessionsSettings />}
{account && <OidcSettings />}
+2
View File
@@ -23,6 +23,8 @@ export const OidcSettings = () => {
invalidate: ["auth", "users", "me"],
});
if (data && Object.keys(data.oidc).length === 0) return null;
return (
<SettingsContainer title={t("settings.oidc.label")}>
{unlinkError && <P className="text-red-500">{unlinkError}</P>}
+66 -3
View File
@@ -1,6 +1,10 @@
import SubtitleLanguage from "@material-symbols/svg-400/rounded/closed_caption-fill.svg";
import PlayModeI from "@material-symbols/svg-400/rounded/display_settings-fill.svg";
import AudioLanguage from "@material-symbols/svg-400/rounded/music_note-fill.svg";
import SubtitleLanguage from "@material-symbols/svg-400/rounded/closed_caption.svg";
import PlayModeI from "@material-symbols/svg-400/rounded/display_settings.svg";
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
import AudioLanguage from "@material-symbols/svg-400/rounded/music_note.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow.svg";
import Replay from "@material-symbols/svg-400/rounded/replay.svg";
import Theaters from "@material-symbols/svg-400/rounded/theaters.svg";
import langmap from "langmap";
import { useTranslation } from "react-i18next";
import { Select } from "~/primitives";
@@ -85,3 +89,62 @@ export const PlaybackSettings = () => {
</SettingsContainer>
);
};
const defaultChapterSkipBehaviors = [
"autoSkip",
"showSkipButton",
"disabled",
] as const;
const introCreditsChapterSkipBehaviors = [
"autoSkip",
"autoSkipExceptFirstAppearance",
"showSkipButton",
"disabled",
] as const;
const chapterTypes = [
{ type: "recap", icon: Replay },
{ type: "intro", icon: PlayArrow },
{ type: "credits", icon: Theaters },
{ type: "preview", icon: MovieInfo },
] as const;
export const ChapterSkipSettings = () => {
const { t } = useTranslation();
const [chapterSkip, setChapterSkip] = useSetting("chapterSkip")!;
return (
<SettingsContainer title={t("settings.playback.chapterSkip.label")}>
{chapterTypes.map(({ type, icon }) => {
const values =
type === "intro" || type === "credits"
? introCreditsChapterSkipBehaviors
: defaultChapterSkipBehaviors;
return (
<Preference
key={type}
icon={icon}
label={t(`settings.playback.chapterSkip.types.${type}`)}
description={t(
`settings.playback.chapterSkip.descriptions.${type}`,
)}
>
<Select
label={t(`settings.playback.chapterSkip.types.${type}`)}
value={chapterSkip[type]}
onValueChange={(value) =>
setChapterSkip({ ...chapterSkip, [type]: value })
}
values={[...values]}
getLabel={(key) =>
t(`settings.playback.chapterSkip.behaviors.${key}`)
}
/>
</Preference>
);
})}
</SettingsContainer>
);
};