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> /// </summary>
public ICollection<Track> Subtitles { get; set; } 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> /// <summary>
/// The list of chapters. See <see cref="Chapter"/> for more information. /// The list of chapters. See <see cref="Chapter"/> for more information.
/// </summary> /// </summary>
@ -138,8 +143,10 @@ namespace Kyoo.Abstractions.Models
/// <param name="library"> /// <param name="library">
/// A library manager to retrieve the next and previous episode and load the show and tracks of the episode. /// A library manager to retrieve the next and previous episode and load the show and tracks of the episode.
/// </param> /// </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> /// <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.Show);
await library.Load(ep, x => x.Tracks); await library.Load(ep, x => x.Tracks);
@ -204,37 +211,51 @@ namespace Kyoo.Abstractions.Models
Video = ep.Tracks.FirstOrDefault(x => x.Type == StreamType.Video), Video = ep.Tracks.FirstOrDefault(x => x.Type == StreamType.Video),
Audios = ep.Tracks.Where(x => x.Type == StreamType.Audio).ToArray(), Audios = ep.Tracks.Where(x => x.Type == StreamType.Audio).ToArray(),
Subtitles = ep.Tracks.Where(x => x.Type == StreamType.Subtitle).ToArray(), Subtitles = ep.Tracks.Where(x => x.Type == StreamType.Subtitle).ToArray(),
Fonts = await transcoder.ListFonts(ep),
PreviousEpisode = previous, PreviousEpisode = previous,
NextEpisode = next, NextEpisode = next,
Chapters = await _GetChapters(ep.Path), Chapters = await _GetChapters(ep, fs),
IsMovie = ep.Show.IsMovie IsMovie = ep.Show.IsMovie
}; };
} }
// TODO move this method in a controller to support abstraction. // 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(Episode episode, IFileSystem fs)
private static async Task<ICollection<Chapter>> _GetChapters(string episodePath)
{ {
string path = PathIO.Combine( string path = fs.Combine(
PathIO.GetDirectoryName(episodePath)!, await fs.GetExtraDirectory(episode),
"Chapters", "Chapters",
PathIO.GetFileNameWithoutExtension(episodePath) + ".txt" PathIO.GetFileNameWithoutExtension(episode.Path) + ".txt"
); );
if (!File.Exists(path)) if (!await fs.Exists(path))
return Array.Empty<Chapter>(); return Array.Empty<Chapter>();
try 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 => .Select(x =>
{ {
string[] values = x.Split(' '); 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(); .ToArray();
} }
catch catch (Exception ex)
{ {
await Console.Error.WriteLineAsync($"Invalid chapter file at {path}"); await Console.Error.WriteLineAsync($"Invalid chapter file at {path}");
Console.Error.WriteLine(ex.ToString());
return Array.Empty<Chapter>(); return Array.Empty<Chapter>();
} }
} }

View File

@ -172,60 +172,5 @@ namespace Kyoo.Core.Api
return BadRequest(new RequestError(ex.Message)); 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> /// </summary>
private readonly ILibraryManager _libraryManager; 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> /// <summary>
/// Create a new <see cref="WatchApi"/>. /// Create a new <see cref="WatchApi"/>.
/// </summary> /// </summary>
/// <param name="libraryManager"> /// <param name="libraryManager">
/// The library manager used to modify or retrieve information in the data store. /// The library manager used to modify or retrieve information in the data store.
/// </param> /// </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; _libraryManager = libraryManager;
_files = fs;
_transcoder = transcoder;
} }
/// <summary> /// <summary>
@ -77,7 +91,38 @@ namespace Kyoo.Core.Api
); );
if (item == null) if (item == null)
return NotFound(); 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 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({ export const ChapterP = z.object({
/** /**
* The start time of the chapter (in second from the start of the episode). * 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. * The list of subtitles tracks. See Track for more information.
*/ */
subtitles: z.array(TrackP), 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. * 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 { PersonAvatar } from "~/components/person";
import { ErrorComponent, ErrorPage } from "~/components/errors"; import { ErrorComponent, ErrorPage } from "~/components/errors";
import { HorizontalList } from "~/components/horizontal-list"; import { HorizontalList } from "~/components/horizontal-list";
import NextLink from "next/link";
const StudioText = ({ const StudioText = ({
studio, studio,
@ -149,9 +150,11 @@ export const ShowHeader = ({ data }: { data?: Show | Movie }) => {
)} )}
<Box sx={{ "& > *": { m: ".3rem !important" } }}> <Box sx={{ "& > *": { m: ".3rem !important" } }}>
<Tooltip title={t("show.play")}> <Tooltip title={t("show.play")}>
<Fab color="primary" size="small" aria-label={t("show.play")}> <NextLink href={data ? `/watch/${data.slug}` : ""} passHref>
<PlayArrow /> <Fab color="primary" size="small" aria-label={t("show.play")}>
</Fab> <PlayArrow />
</Fab>
</NextLink>
</Tooltip> </Tooltip>
<Tooltip title={t("show.trailer")} aria-label={t("show.trailer")}> <Tooltip title={t("show.trailer")} aria-label={t("show.trailer")}>
<IconButton> <IconButton>

View File

@ -20,18 +20,10 @@
import { QueryIdentifier, QueryPage } from "~/utils/query"; import { QueryIdentifier, QueryPage } from "~/utils/query";
import { withRoute } from "~/utils/router"; 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 { useFetch } from "~/utils/query";
import { ErrorPage } from "~/components/errors"; import { ErrorPage } from "~/components/errors";
import { import { useState, useRef, useEffect, memo, useMemo, useCallback, RefObject } from "react";
useState,
useRef,
useEffect,
memo,
useMemo,
useCallback,
RefObject,
} from "react";
import { import {
Box, Box,
CircularProgress, CircularProgress,
@ -43,7 +35,7 @@ import {
Menu, Menu,
MenuItem, MenuItem,
ListItemText, ListItemText,
BoxProps, BoxProps,
} from "@mui/material"; } from "@mui/material";
import useTranslation from "next-translate/useTranslation"; import useTranslation from "next-translate/useTranslation";
import { import {
@ -66,7 +58,7 @@ import { Link } from "~/utils/link";
import NextLink from "next/link"; import NextLink from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// @ts-ignore // @ts-ignore
import SubtitleOctopus from "@jellyfin/libass-wasm" import SubtitleOctopus from "@jellyfin/libass-wasm";
const toTimerString = (timer: number, duration?: number) => { const toTimerString = (timer: number, duration?: number) => {
if (!duration) duration = timer; if (!duration) duration = timer;
@ -264,9 +256,9 @@ const ProgressBar = ({
key={x.startTime} key={x.startTime}
sx={{ sx={{
position: "absolute", position: "absolute",
width: "2px", width: "4px",
top: 0, top: 0,
botton: 0, bottom: 0,
left: `${(x.startTime / duration) * 100}%`, left: `${(x.startTime / duration) * 100}%`,
background: (theme) => theme.palette.primary.dark, background: (theme) => theme.palette.primary.dark,
}} }}
@ -311,12 +303,13 @@ const LeftButtons = memo(function LeftButtons({
setVolume: (value: number) => void; setVolume: (value: number) => void;
}) { }) {
const { t } = useTranslation("player"); const { t } = useTranslation("player");
const router = useRouter();
return ( return (
<Box sx={{ display: "flex", "> *": { mx: "8px !important" } }}> <Box sx={{ display: "flex", "> *": { mx: "8px !important" } }}>
{previousSlug && ( {previousSlug && (
<Tooltip title={t("previous")}> <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" }}> <IconButton aria-label={t("previous")} sx={{ color: "white" }}>
<SkipPrevious /> <SkipPrevious />
</IconButton> </IconButton>
@ -334,7 +327,7 @@ const LeftButtons = memo(function LeftButtons({
</Tooltip> </Tooltip>
{nextSlug && ( {nextSlug && (
<Tooltip title={t("next")}> <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" }}> <IconButton aria-label={t("next")} sx={{ color: "white" }}>
<SkipNext /> <SkipNext />
</IconButton> </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 [selectedSubtitle, setSubtitle] = useState<Track | null>(null);
const [htmlTrack, setHtmlTrack] = useState<HTMLTrackElement | null>(null); const [htmlTrack, setHtmlTrack] = useState<HTMLTrackElement | null>(null);
const [subocto, setSubOcto] = useState<SubtitleOctopus | null>(null); const [subocto, setSubOcto] = useState<SubtitleOctopus | null>(null);
const { query: { subtitle } } = useRouter();
return [ const selectSubtitle = useCallback(
selectedSubtitle, (value: Track | null) => {
useCallback( const removeHtmlSubtitle = () => {
(value: Track | null) => { if (htmlTrack) htmlTrack.remove();
const removeHtmlSubtitle = () => { setHtmlTrack(null);
if (htmlTrack) htmlTrack.remove(); };
setHtmlTrack(null); const removeOctoSub = () => {
}; if (subocto) {
const removeOctoSub = () => { subocto.freeTrack();
if (subocto) { subocto.dispose();
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",
}),
);
} }
}, setSubOcto(null);
[htmlTrack, subocto, player], };
),
]; 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 = () => { const useVideoController = () => {
@ -537,7 +543,12 @@ const useVideoController = () => {
const [volume, setVolume] = useState(100); const [volume, setVolume] = useState(100);
const [isMuted, setMute] = useState(false); const [isMuted, setMute] = useState(false);
const [isFullscreen, setFullscreen] = 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(() => { useEffect(() => {
if (!player?.current?.duration) return; if (!player?.current?.duration) return;
@ -590,6 +601,7 @@ const useVideoController = () => {
[player, togglePlay, toggleFullscreen], [player, togglePlay, toggleFullscreen],
); );
return { return {
playerRef: player,
state: { state: {
isPlaying, isPlaying,
isLoading, isLoading,
@ -599,7 +611,6 @@ const useVideoController = () => {
volume, volume,
isMuted, isMuted,
isFullscreen, isFullscreen,
selectedSubtitle,
}, },
videoProps, videoProps,
togglePlay, togglePlay,
@ -621,7 +632,6 @@ const useVideoController = () => {
}, },
[player], [player],
), ),
selectSubtitle,
}; };
}; };
@ -637,25 +647,21 @@ let mouseCallback: NodeJS.Timeout;
const Player: QueryPage<{ slug: string }> = ({ slug }) => { const Player: QueryPage<{ slug: string }> = ({ slug }) => {
const { data, error } = useFetch(query(slug)); const { data, error } = useFetch(query(slug));
const { const {
state: { playerRef,
isPlaying, state: { isPlaying, isLoading, progress, duration, buffered, volume, isMuted, isFullscreen },
isLoading,
progress,
duration,
buffered,
volume,
isMuted,
isFullscreen,
selectedSubtitle,
},
videoProps, videoProps,
togglePlay, togglePlay,
toggleMute, toggleMute,
toggleFullscreen, toggleFullscreen,
setProgress, setProgress,
setVolume, setVolume,
selectSubtitle,
} = useVideoController(); } = useVideoController();
const [selectedSubtitle, selectSubtitle] = useSubtitleController(
playerRef,
slug,
data?.fonts,
data?.subtitles,
);
const [showHover, setHover] = useState(false); const [showHover, setHover] = useState(false);
const [mouseMoved, setMouseMoved] = useState(false); const [mouseMoved, setMouseMoved] = useState(false);