Move the fonts API into the watch API & Fix chapters API

This commit is contained in:
Zoe Roux 2022-10-07 00:01:41 +09:00
parent 6eccb2cede
commit 07e8504cea
6 changed files with 196 additions and 156 deletions

View File

@ -126,6 +126,11 @@ namespace Kyoo.Abstractions.Models
/// </summary>
public ICollection<Track> Subtitles { get; set; }
/// <summary>
/// The list of fonts that can be used to draw the subtitles.
/// </summary>
public ICollection<Font> Fonts { get; set; }
/// <summary>
/// The list of chapters. See <see cref="Chapter"/> for more information.
/// </summary>
@ -138,8 +143,10 @@ namespace Kyoo.Abstractions.Models
/// <param name="library">
/// A library manager to retrieve the next and previous episode and load the show and tracks of the episode.
/// </param>
/// <param name="fs">A file system used to retrieve chapters informations.</param>
/// <param name="transcoder">The transcoder used to list fonts.</param>
/// <returns>A new WatchItem representing the given episode.</returns>
public static async Task<WatchItem> FromEpisode(Episode ep, ILibraryManager library)
public static async Task<WatchItem> FromEpisode(Episode ep, ILibraryManager library, IFileSystem fs, ITranscoder transcoder)
{
await library.Load(ep, x => x.Show);
await library.Load(ep, x => x.Tracks);
@ -204,37 +211,51 @@ namespace Kyoo.Abstractions.Models
Video = ep.Tracks.FirstOrDefault(x => x.Type == StreamType.Video),
Audios = ep.Tracks.Where(x => x.Type == StreamType.Audio).ToArray(),
Subtitles = ep.Tracks.Where(x => x.Type == StreamType.Subtitle).ToArray(),
Fonts = await transcoder.ListFonts(ep),
PreviousEpisode = previous,
NextEpisode = next,
Chapters = await _GetChapters(ep.Path),
Chapters = await _GetChapters(ep, fs),
IsMovie = ep.Show.IsMovie
};
}
// TODO move this method in a controller to support abstraction.
// TODO use a IFileManager to retrieve and read files.
private static async Task<ICollection<Chapter>> _GetChapters(string episodePath)
private static async Task<ICollection<Chapter>> _GetChapters(Episode episode, IFileSystem fs)
{
string path = PathIO.Combine(
PathIO.GetDirectoryName(episodePath)!,
string path = fs.Combine(
await fs.GetExtraDirectory(episode),
"Chapters",
PathIO.GetFileNameWithoutExtension(episodePath) + ".txt"
PathIO.GetFileNameWithoutExtension(episode.Path) + ".txt"
);
if (!File.Exists(path))
if (!await fs.Exists(path))
return Array.Empty<Chapter>();
try
{
return (await File.ReadAllLinesAsync(path))
using StreamReader sr = new(await fs.GetReader(path));
string chapters = await sr.ReadToEndAsync();
return chapters.Split('\n')
.Select(x =>
{
string[] values = x.Split(' ');
return new Chapter(float.Parse(values[0]), float.Parse(values[1]), string.Join(' ', values.Skip(2)));
if (
values.Length < 3
|| !float.TryParse(values[0], out float start)
|| !float.TryParse(values[1], out float end)
)
return null;
return new Chapter(
start,
end,
string.Join(' ', values.Skip(2))
);
})
.Where(x => x != null)
.ToArray();
}
catch
catch (Exception ex)
{
await Console.Error.WriteLineAsync($"Invalid chapter file at {path}");
Console.Error.WriteLine(ex.ToString());
return Array.Empty<Chapter>();
}
}

View File

@ -172,60 +172,5 @@ namespace Kyoo.Core.Api
return BadRequest(new RequestError(ex.Message));
}
}
/// <summary>
/// List fonts
/// </summary>
/// <remarks>
/// List available fonts for this episode.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
/// <returns>An object containing the name of the font followed by the url to retrieve it.</returns>
[HttpGet("{identifier:id}/fonts")]
[HttpGet("{identifier:id}/font", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ICollection<Font>>> GetFonts(Identifier identifier)
{
Episode episode = await identifier.Match(
id => _libraryManager.GetOrDefault<Episode>(id),
slug => _libraryManager.GetOrDefault<Episode>(slug)
);
if (episode == null)
return NotFound();
return Ok(await _transcoder.ListFonts(episode));
}
/// <summary>
/// Get font
/// </summary>
/// <remarks>
/// Get a font file that is used in subtitles of this episode.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
/// <param name="slug">The slug of the font to retrieve.</param>
/// <returns>A page of collections.</returns>
/// <response code="404">No show with the given ID/slug could be found or the font does not exist.</response>
[HttpGet("{identifier:id}/fonts/{slug}")]
[HttpGet("{identifier:id}/font/{slug}", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetFont(Identifier identifier, string slug)
{
Episode episode = await identifier.Match(
id => _libraryManager.GetOrDefault<Episode>(id),
slug => _libraryManager.GetOrDefault<Episode>(slug)
);
if (episode == null)
return NotFound();
if (slug.Contains('.'))
slug = slug[..slug.LastIndexOf('.')];
Font font = await _transcoder.GetFont(episode, slug);
if (font == null)
return NotFound();
return _files.FileResult(font.Path);
}
}
}

View File

@ -45,15 +45,29 @@ namespace Kyoo.Core.Api
/// </summary>
private readonly ILibraryManager _libraryManager;
/// <summary>
/// A file system used to retrieve chapters informations.
/// </summary>
private readonly IFileSystem _files;
/// <summary>
/// The transcoder used to list fonts.
/// </summary>
private readonly ITranscoder _transcoder;
/// <summary>
/// Create a new <see cref="WatchApi"/>.
/// </summary>
/// <param name="libraryManager">
/// The library manager used to modify or retrieve information in the data store.
/// </param>
public WatchApi(ILibraryManager libraryManager)
/// <param name="fs">A file system used to retrieve chapters informations.</param>
/// <param name="transcoder">The transcoder used to list fonts.</param>
public WatchApi(ILibraryManager libraryManager, IFileSystem fs, ITranscoder transcoder)
{
_libraryManager = libraryManager;
_files = fs;
_transcoder = transcoder;
}
/// <summary>
@ -77,7 +91,38 @@ namespace Kyoo.Core.Api
);
if (item == null)
return NotFound();
return await WatchItem.FromEpisode(item, _libraryManager);
return await WatchItem.FromEpisode(item, _libraryManager, _files, _transcoder);
}
/// <summary>
/// Get font
/// </summary>
/// <remarks>
/// Get a font file that is used in subtitles of this episode.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
/// <param name="slug">The slug of the font to retrieve.</param>
/// <returns>A page of collections.</returns>
/// <response code="404">No show with the given ID/slug could be found or the font does not exist.</response>
[HttpGet("{identifier:id}/fonts/{slug}")]
[HttpGet("{identifier:id}/font/{slug}", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetFont(Identifier identifier, string slug)
{
Episode episode = await identifier.Match(
id => _libraryManager.GetOrDefault<Episode>(id),
slug => _libraryManager.GetOrDefault<Episode>(slug)
);
if (episode == null)
return NotFound();
if (slug.Contains('.'))
slug = slug[..slug.LastIndexOf('.')];
Font font = await _transcoder.GetFont(episode, slug);
if (font == null)
return NotFound();
return _files.FileResult(font.Path);
}
}
}

View File

@ -62,6 +62,22 @@ export const TrackP = ResourceP.extend({
});
export type Track = z.infer<typeof TrackP>;
export const FontP = z.object({
/*
* A human-readable identifier, used in the URL.
*/
slug: z.string(),
/*
* The name of the font file (with the extension).
*/
file: z.string(),
/*
* The format of this font (the extension).
*/
format: z.string(),
});
export type Font = z.infer<typeof FontP>;
export const ChapterP = z.object({
/**
* The start time of the chapter (in second from the start of the episode).
@ -119,6 +135,10 @@ const WatchMovieP = z.preprocess(
* The list of subtitles tracks. See Track for more information.
*/
subtitles: z.array(TrackP),
/**
* The list of fonts that can be used to display subtitles.
*/
fonts: z.array(FontP),
/**
* The list of chapters. See Chapter for more information.
*/

View File

@ -45,6 +45,7 @@ import { Person, PersonP } from "~/models";
import { PersonAvatar } from "~/components/person";
import { ErrorComponent, ErrorPage } from "~/components/errors";
import { HorizontalList } from "~/components/horizontal-list";
import NextLink from "next/link";
const StudioText = ({
studio,
@ -149,9 +150,11 @@ export const ShowHeader = ({ data }: { data?: Show | Movie }) => {
)}
<Box sx={{ "& > *": { m: ".3rem !important" } }}>
<Tooltip title={t("show.play")}>
<Fab color="primary" size="small" aria-label={t("show.play")}>
<PlayArrow />
</Fab>
<NextLink href={data ? `/watch/${data.slug}` : ""} passHref>
<Fab color="primary" size="small" aria-label={t("show.play")}>
<PlayArrow />
</Fab>
</NextLink>
</Tooltip>
<Tooltip title={t("show.trailer")} aria-label={t("show.trailer")}>
<IconButton>

View File

@ -20,18 +20,10 @@
import { QueryIdentifier, QueryPage } from "~/utils/query";
import { withRoute } from "~/utils/router";
import { WatchItem, WatchItemP, Chapter, Track } from "~/models/resources/watch-item";
import { WatchItem, WatchItemP, Chapter, Track, Font } from "~/models/resources/watch-item";
import { useFetch } from "~/utils/query";
import { ErrorPage } from "~/components/errors";
import {
useState,
useRef,
useEffect,
memo,
useMemo,
useCallback,
RefObject,
} from "react";
import { useState, useRef, useEffect, memo, useMemo, useCallback, RefObject } from "react";
import {
Box,
CircularProgress,
@ -43,7 +35,7 @@ import {
Menu,
MenuItem,
ListItemText,
BoxProps,
BoxProps,
} from "@mui/material";
import useTranslation from "next-translate/useTranslation";
import {
@ -66,7 +58,7 @@ import { Link } from "~/utils/link";
import NextLink from "next/link";
import { useRouter } from "next/router";
// @ts-ignore
import SubtitleOctopus from "@jellyfin/libass-wasm"
import SubtitleOctopus from "@jellyfin/libass-wasm";
const toTimerString = (timer: number, duration?: number) => {
if (!duration) duration = timer;
@ -264,9 +256,9 @@ const ProgressBar = ({
key={x.startTime}
sx={{
position: "absolute",
width: "2px",
width: "4px",
top: 0,
botton: 0,
bottom: 0,
left: `${(x.startTime / duration) * 100}%`,
background: (theme) => theme.palette.primary.dark,
}}
@ -311,12 +303,13 @@ const LeftButtons = memo(function LeftButtons({
setVolume: (value: number) => void;
}) {
const { t } = useTranslation("player");
const router = useRouter();
return (
<Box sx={{ display: "flex", "> *": { mx: "8px !important" } }}>
{previousSlug && (
<Tooltip title={t("previous")}>
<NextLink href={`/watch/${previousSlug}`} passHref>
<NextLink href={{query: { ...router.query, slug: previousSlug }}} passHref>
<IconButton aria-label={t("previous")} sx={{ color: "white" }}>
<SkipPrevious />
</IconButton>
@ -334,7 +327,7 @@ const LeftButtons = memo(function LeftButtons({
</Tooltip>
{nextSlug && (
<Tooltip title={t("next")}>
<NextLink href={`/watch/${nextSlug}`} passHref>
<NextLink href={{query: { ...router.query, slug: nextSlug }}} passHref>
<IconButton aria-label={t("next")} sx={{ color: "white" }}>
<SkipNext />
</IconButton>
@ -466,65 +459,78 @@ const Back = memo(function Back({ name, href }: { name?: string; href: string })
);
});
const useSubtitleController = (player: RefObject<HTMLVideoElement>): [Track | null, (value: Track | null) => void] => {
const useSubtitleController = (
player: RefObject<HTMLVideoElement>,
slug: string,
fonts?: Font[],
subtitles?: Track[],
): [Track | null, (value: Track | null) => void] => {
const [selectedSubtitle, setSubtitle] = useState<Track | null>(null);
const [htmlTrack, setHtmlTrack] = useState<HTMLTrackElement | null>(null);
const [subocto, setSubOcto] = useState<SubtitleOctopus | null>(null);
const { query: { subtitle } } = useRouter();
return [
selectedSubtitle,
useCallback(
(value: Track | null) => {
const removeHtmlSubtitle = () => {
if (htmlTrack) htmlTrack.remove();
setHtmlTrack(null);
};
const removeOctoSub = () => {
if (subocto) {
subocto.freeTrack();
subocto.dispose();
}
setSubOcto(null);
};
if (!player.current) return;
setSubtitle(value);
if (!value) {
removeHtmlSubtitle();
removeOctoSub();
} else if (value.codec === "vtt" || value.codec === "srt") {
removeOctoSub();
const track: HTMLTrackElement = htmlTrack ?? document.createElement("track");
track.kind = "subtitles";
track.label = value.displayName;
if (value.language) track.srclang = value.language;
track.src = `subtitle/${value.slug}.vtt`;
track.className = "subtitle_container";
track.default = true;
track.onload = () => {
if (player.current) player.current.textTracks[0].mode = "showing";
};
player.current.appendChild(track);
setHtmlTrack(track);
} else if (value.codec === "ass") {
removeHtmlSubtitle();
removeOctoSub();
setSubOcto(
new SubtitleOctopus({
video: player.current,
subUrl: `/api/subtitle/${value.slug}`,
workerUrl: "/_next/static/chunks/subtitles-octopus-worker.js",
legacyWorkerUrl: "/_next/static/chunks/subtitles-octopus-worker-legacy.js",
/* fonts: */
renderMode: "wasm-blend",
}),
);
const selectSubtitle = useCallback(
(value: Track | null) => {
const removeHtmlSubtitle = () => {
if (htmlTrack) htmlTrack.remove();
setHtmlTrack(null);
};
const removeOctoSub = () => {
if (subocto) {
subocto.freeTrack();
subocto.dispose();
}
},
[htmlTrack, subocto, player],
),
];
setSubOcto(null);
};
if (!player.current) return;
setSubtitle(value);
if (!value) {
removeHtmlSubtitle();
removeOctoSub();
} else if (value.codec === "vtt" || value.codec === "srt") {
removeOctoSub();
const track: HTMLTrackElement = htmlTrack ?? document.createElement("track");
track.kind = "subtitles";
track.label = value.displayName;
if (value.language) track.srclang = value.language;
track.src = `subtitle/${value.slug}.vtt`;
track.className = "subtitle_container";
track.default = true;
track.onload = () => {
if (player.current) player.current.textTracks[0].mode = "showing";
};
player.current.appendChild(track);
setHtmlTrack(track);
} else if (value.codec === "ass") {
removeHtmlSubtitle();
removeOctoSub();
setSubOcto(
new SubtitleOctopus({
video: player.current,
subUrl: `/api/subtitle/${value.slug}`,
workerUrl: "/_next/static/chunks/subtitles-octopus-worker.js",
legacyWorkerUrl: "/_next/static/chunks/subtitles-octopus-worker-legacy.js",
fonts: fonts?.map((x) => `/api/watch/${slug}/font/${x.slug}.${x.format}`),
renderMode: "wasm-blend",
}),
);
}
},
[htmlTrack, subocto, player, fonts, slug],
);
const newSub = subtitles?.find(x => x.language === subtitle);
useEffect(() => {
if (newSub === undefined) return;
console.log("old", selectedSubtitle)
console.log("new", newSub)
if (newSub?.id !== selectedSubtitle?.id) selectSubtitle(newSub);
}, [player.current?.src, newSub, selectedSubtitle, selectSubtitle]);
return [selectedSubtitle, selectSubtitle];
};
const useVideoController = () => {
@ -537,7 +543,12 @@ const useVideoController = () => {
const [volume, setVolume] = useState(100);
const [isMuted, setMute] = useState(false);
const [isFullscreen, setFullscreen] = useState(false);
const [selectedSubtitle, selectSubtitle] = useSubtitleController(player);
useEffect(() => {
if (!player.current) return;
if (player.current.paused) player.current.play();
setPlay(!player.current.paused);
}, []);
useEffect(() => {
if (!player?.current?.duration) return;
@ -590,6 +601,7 @@ const useVideoController = () => {
[player, togglePlay, toggleFullscreen],
);
return {
playerRef: player,
state: {
isPlaying,
isLoading,
@ -599,7 +611,6 @@ const useVideoController = () => {
volume,
isMuted,
isFullscreen,
selectedSubtitle,
},
videoProps,
togglePlay,
@ -621,7 +632,6 @@ const useVideoController = () => {
},
[player],
),
selectSubtitle,
};
};
@ -637,25 +647,21 @@ let mouseCallback: NodeJS.Timeout;
const Player: QueryPage<{ slug: string }> = ({ slug }) => {
const { data, error } = useFetch(query(slug));
const {
state: {
isPlaying,
isLoading,
progress,
duration,
buffered,
volume,
isMuted,
isFullscreen,
selectedSubtitle,
},
playerRef,
state: { isPlaying, isLoading, progress, duration, buffered, volume, isMuted, isFullscreen },
videoProps,
togglePlay,
toggleMute,
toggleFullscreen,
setProgress,
setVolume,
selectSubtitle,
} = useVideoController();
const [selectedSubtitle, selectSubtitle] = useSubtitleController(
playerRef,
slug,
data?.fonts,
data?.subtitles,
);
const [showHover, setHover] = useState(false);
const [mouseMoved, setMouseMoved] = useState(false);