mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Move the fonts API into the watch API & Fix chapters API
This commit is contained in:
parent
6eccb2cede
commit
07e8504cea
@ -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>();
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user