Add settings for auto-skip

This commit is contained in:
Zoe Roux 2026-04-17 23:44:21 +02:00
parent 90f2d5b190
commit bf2c5efafd
No known key found for this signature in database
11 changed files with 165 additions and 16 deletions

View File

@ -24,6 +24,8 @@ Kyoo does not have a plugin system and aim to have every features built-in (see
- **Video Preview Thumbnails:** Simply hover the video's progress bar and see a preview of the video.
- **Intro/Credit detection:** Automatically detect intro/credits with audio fingerprinting (or chapter title matching).
- **Enhanced Subtitle Support:** Subtitles are important, Kyoo supports PGS/VODSUB and SSA/ASS and uses the video's embedded fonts when available.
- **Anime Name Parsing**: Kyoo will match weird anime names (like `[Some-Stuffs] Jojo's Bizarre Adventure Stone Ocean 24 (1920x1080 Blu-Ray Opus) [2750810F].mkv`) without issue.

View File

@ -67,7 +67,7 @@ const actionMap = {
logger.info("No next video to prepare for ${slug}", {
slug: vid.path,
});
return
return;
}
await prepareVideo(next, ws.data.headers.authorization!);
}

View File

@ -188,6 +188,27 @@
"label": "Subtitle language",
"description": "The default subtitle language used",
"none": "None"
},
"chapterSkip": {
"label": "Chapter skip",
"behaviors": {
"autoSkip": "Auto skip",
"autoSkipExceptFirstAppearance": "Auto skip except first appearance",
"showSkipButton": "Show skip button",
"disabled": "Do nothing"
},
"types": {
"recap": "Recap",
"intro": "Intro",
"credits": "Credits",
"preview": "Preview"
},
"descriptions": {
"recap": "Control what happens when a recap chapter starts",
"intro": "Control what happens when an intro chapter starts",
"credits": "Control what happens when a credits chapter starts",
"preview": "Control what happens when a preview chapter starts"
}
}
},
"account": {

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

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>;

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",

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;

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 />}

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>}

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>
);
};

View File

@ -158,7 +158,7 @@ type Chapter struct {
/// The type value is used to mark special chapters (opening/credits...)
Type ChapterType `json:"type" db:"type"`
// true only for introductions where the audio track is new (first time we'we heard this one in the serie)
FirstAppearance *bool `json:"firstAppearance" db:"first_appearance"`
FirstAppearance *bool `json:"firstAppearance,omitempty" db:"first_appearance"`
/// Accuracy of the fingerprint match (0-100).
MatchAccuracy *int32 `json:"matchAccuracy,omitempty" db:"match_accuracy"`
}