mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-04-25 02:20:02 -04:00
Add settings for auto-skip
This commit is contained in:
parent
90f2d5b190
commit
bf2c5efafd
@ -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.
|
||||
|
||||
@ -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!);
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>;
|
||||
|
||||
|
||||
@ -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,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;
|
||||
|
||||
@ -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 />}
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user