diff --git a/README.md b/README.md index 89024f99..be01ad17 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/api/src/websockets.ts b/api/src/websockets.ts index f2f7ac0a..7b45016e 100644 --- a/api/src/websockets.ts +++ b/api/src/websockets.ts @@ -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!); } diff --git a/front/public/translations/en.json b/front/public/translations/en.json index 5beb771c..ba92ce8b 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -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": { diff --git a/front/src/models/user.ts b/front/src/models/user.ts index eb3c68f9..daeb1049 100644 --- a/front/src/models/user.ts +++ b/front/src/models/user.ts @@ -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 diff --git a/front/src/models/video-info.ts b/front/src/models/video-info.ts index 80e0466d..ce7edd03 100644 --- a/front/src/models/video-info.ts +++ b/front/src/models/video-info.ts @@ -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; diff --git a/front/src/ui/player/controls/skip-chapter.tsx b/front/src/ui/player/controls/skip-chapter.tsx index 5be1fe1f..a316f075 100644 --- a/front/src/ui/player/controls/skip-chapter.tsx +++ b/front/src/ui/player/controls/skip-chapter.tsx @@ -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("slug", undefined!); const { data } = useFetch(Info.infoQuery(slug)); + const lastAutoSkippedChapter = useRef(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 (