diff --git a/back/src/Kyoo.Abstractions/Models/WatchItem.cs b/back/src/Kyoo.Abstractions/Models/WatchItem.cs index c4116545..70e61836 100644 --- a/back/src/Kyoo.Abstractions/Models/WatchItem.cs +++ b/back/src/Kyoo.Abstractions/Models/WatchItem.cs @@ -126,6 +126,11 @@ namespace Kyoo.Abstractions.Models /// public ICollection Subtitles { get; set; } + /// + /// The list of fonts that can be used to draw the subtitles. + /// + public ICollection Fonts { get; set; } + /// /// The list of chapters. See for more information. /// @@ -138,8 +143,10 @@ namespace Kyoo.Abstractions.Models /// /// A library manager to retrieve the next and previous episode and load the show and tracks of the episode. /// + /// A file system used to retrieve chapters informations. + /// The transcoder used to list fonts. /// A new WatchItem representing the given episode. - public static async Task FromEpisode(Episode ep, ILibraryManager library) + public static async Task 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> _GetChapters(string episodePath) + private static async Task> _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(); 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(); } } diff --git a/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs b/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs index 5c26ba99..317b382e 100644 --- a/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs @@ -172,60 +172,5 @@ namespace Kyoo.Core.Api return BadRequest(new RequestError(ex.Message)); } } - - /// - /// List fonts - /// - /// - /// List available fonts for this episode. - /// - /// The ID or slug of the . - /// An object containing the name of the font followed by the url to retrieve it. - [HttpGet("{identifier:id}/fonts")] - [HttpGet("{identifier:id}/font", Order = AlternativeRoute)] - [PartialPermission(Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task>> GetFonts(Identifier identifier) - { - Episode episode = await identifier.Match( - id => _libraryManager.GetOrDefault(id), - slug => _libraryManager.GetOrDefault(slug) - ); - if (episode == null) - return NotFound(); - return Ok(await _transcoder.ListFonts(episode)); - } - - /// - /// Get font - /// - /// - /// Get a font file that is used in subtitles of this episode. - /// - /// The ID or slug of the . - /// The slug of the font to retrieve. - /// A page of collections. - /// No show with the given ID/slug could be found or the font does not exist. - [HttpGet("{identifier:id}/fonts/{slug}")] - [HttpGet("{identifier:id}/font/{slug}", Order = AlternativeRoute)] - [PartialPermission(Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetFont(Identifier identifier, string slug) - { - Episode episode = await identifier.Match( - id => _libraryManager.GetOrDefault(id), - slug => _libraryManager.GetOrDefault(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); - } } } diff --git a/back/src/Kyoo.Core/Views/Watch/WatchApi.cs b/back/src/Kyoo.Core/Views/Watch/WatchApi.cs index dbc539b2..506176ad 100644 --- a/back/src/Kyoo.Core/Views/Watch/WatchApi.cs +++ b/back/src/Kyoo.Core/Views/Watch/WatchApi.cs @@ -45,15 +45,29 @@ namespace Kyoo.Core.Api /// private readonly ILibraryManager _libraryManager; + /// + /// A file system used to retrieve chapters informations. + /// + private readonly IFileSystem _files; + + /// + /// The transcoder used to list fonts. + /// + private readonly ITranscoder _transcoder; + /// /// Create a new . /// /// /// The library manager used to modify or retrieve information in the data store. /// - public WatchApi(ILibraryManager libraryManager) + /// A file system used to retrieve chapters informations. + /// The transcoder used to list fonts. + public WatchApi(ILibraryManager libraryManager, IFileSystem fs, ITranscoder transcoder) { _libraryManager = libraryManager; + _files = fs; + _transcoder = transcoder; } /// @@ -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); + } + + /// + /// Get font + /// + /// + /// Get a font file that is used in subtitles of this episode. + /// + /// The ID or slug of the . + /// The slug of the font to retrieve. + /// A page of collections. + /// No show with the given ID/slug could be found or the font does not exist. + [HttpGet("{identifier:id}/fonts/{slug}")] + [HttpGet("{identifier:id}/font/{slug}", Order = AlternativeRoute)] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetFont(Identifier identifier, string slug) + { + Episode episode = await identifier.Match( + id => _libraryManager.GetOrDefault(id), + slug => _libraryManager.GetOrDefault(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); } } } diff --git a/front/src/models/resources/watch-item.ts b/front/src/models/resources/watch-item.ts index e5e9f1fd..08a8ebe6 100644 --- a/front/src/models/resources/watch-item.ts +++ b/front/src/models/resources/watch-item.ts @@ -62,6 +62,22 @@ export const TrackP = ResourceP.extend({ }); export type Track = z.infer; +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; + 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. */ diff --git a/front/src/pages/movie/[slug].tsx b/front/src/pages/movie/[slug].tsx index 26687366..f62ceed6 100644 --- a/front/src/pages/movie/[slug].tsx +++ b/front/src/pages/movie/[slug].tsx @@ -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 }) => { )} *": { m: ".3rem !important" } }}> - - - + + + + + diff --git a/front/src/pages/watch/[slug].tsx b/front/src/pages/watch/[slug].tsx index fdafa9f2..846f4bc4 100644 --- a/front/src/pages/watch/[slug].tsx +++ b/front/src/pages/watch/[slug].tsx @@ -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 ( *": { mx: "8px !important" } }}> {previousSlug && ( - + @@ -334,7 +327,7 @@ const LeftButtons = memo(function LeftButtons({ {nextSlug && ( - + @@ -466,65 +459,78 @@ const Back = memo(function Back({ name, href }: { name?: string; href: string }) ); }); -const useSubtitleController = (player: RefObject): [Track | null, (value: Track | null) => void] => { +const useSubtitleController = ( + player: RefObject, + slug: string, + fonts?: Font[], + subtitles?: Track[], +): [Track | null, (value: Track | null) => void] => { const [selectedSubtitle, setSubtitle] = useState(null); const [htmlTrack, setHtmlTrack] = useState(null); const [subocto, setSubOcto] = useState(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);